mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(tui): blitz closeout — input wrap parity, shift-tab yolo, bottom statusline
- input wrap: add <Text wrap="wrap-char"> mode that drives wrap-ansi with wordWrap:false, and align cursorLayout/offsetFromPosition to that same boundary (w=cols, trailing-cell overflow). Word-wrap's whitespace reshuffle was causing the cursor to jump a word left/right on each keystroke near the right edge — blitz row 9 - shift-tab: toggle per-session yolo without submitting a turn (mirrors Claude Code's in-place dangerously-approve); slash /yolo still works for discoverability — blitz row 5 sub-item 11 - statusline: lift StatusRule out of ComposerPane to a new StatusRulePane anchored at the bottom of AppLayout, below the input — blitz row 5 sub-item 12
This commit is contained in:
parent
88564ad8bc
commit
7027ce42ef
8 changed files with 140 additions and 24 deletions
|
|
@ -69,6 +69,12 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
|||
flexDirection: 'row',
|
||||
textWrap: 'wrap'
|
||||
},
|
||||
'wrap-char': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap-char'
|
||||
},
|
||||
'wrap-trim': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ function wrapWithSoftWrap(
|
|||
maxWidth: number,
|
||||
textWrap: Parameters<typeof wrapText>[2]
|
||||
): { wrapped: string; softWrap: boolean[] | undefined } {
|
||||
if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') {
|
||||
if (textWrap !== 'wrap' && textWrap !== 'wrap-char' && textWrap !== 'wrap-trim') {
|
||||
return {
|
||||
wrapped: wrapText(plainText, maxWidth, textWrap),
|
||||
softWrap: undefined
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export type TextStyles = {
|
|||
export type Styles = {
|
||||
readonly textWrap?:
|
||||
| 'wrap'
|
||||
| 'wrap-char'
|
||||
| 'wrap-trim'
|
||||
| 'end'
|
||||
| 'middle'
|
||||
|
|
|
|||
|
|
@ -50,6 +50,19 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
|
|||
})
|
||||
}
|
||||
|
||||
// Char-granularity wrap: break at exact column boundaries regardless of
|
||||
// whitespace. Used for text inputs where the cursor position must track
|
||||
// the wrap boundary deterministically — word-wrap's whitespace-preferring
|
||||
// reshuffle causes visible cursor flicker as each keystroke can push a
|
||||
// word across a line break.
|
||||
if (wrapType === 'wrap-char') {
|
||||
return wrapAnsi(text, maxWidth, {
|
||||
trim: false,
|
||||
hard: true,
|
||||
wordWrap: false
|
||||
})
|
||||
}
|
||||
|
||||
if (wrapType === 'wrap-trim') {
|
||||
return wrapAnsi(text, maxWidth, {
|
||||
trim: true,
|
||||
|
|
|
|||
60
ui-tui/src/__tests__/textInputWrap.test.ts
Normal file
60
ui-tui/src/__tests__/textInputWrap.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cursorLayout, offsetFromPosition } from '../components/textInput.js'
|
||||
|
||||
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
|
||||
it('places cursor mid-line at its column', () => {
|
||||
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
|
||||
})
|
||||
|
||||
it('places cursor at end of a non-full line', () => {
|
||||
expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 })
|
||||
})
|
||||
|
||||
it('wraps to next line when cursor lands exactly at the right edge', () => {
|
||||
// 8 chars on an 8-col line: text fills the row exactly; the cursor's
|
||||
// inverted-space cell overflows to col 0 of the next row.
|
||||
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('tracks a word across a char-wrap boundary without jumping', () => {
|
||||
// With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" —
|
||||
// typing incremental letters doesn't reshuffle the word across lines.
|
||||
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 })
|
||||
})
|
||||
|
||||
it('honours explicit newlines', () => {
|
||||
expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('does not wrap when cursor is before the right edge', () => {
|
||||
expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
|
||||
})
|
||||
|
||||
it('maps clicks within a single line', () => {
|
||||
expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3)
|
||||
})
|
||||
|
||||
it('maps clicks past end to value length', () => {
|
||||
expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2)
|
||||
})
|
||||
|
||||
it('maps clicks on a wrapped second row at cols boundary', () => {
|
||||
// "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0
|
||||
// should land on 'i' (offset 8).
|
||||
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
|
||||
})
|
||||
|
||||
it('maps clicks past a \\n into the target line', () => {
|
||||
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
|
|||
|
||||
import type {
|
||||
ApprovalRespondResponse,
|
||||
ConfigSetResponse,
|
||||
SecretRespondResponse,
|
||||
SudoRespondResponse,
|
||||
VoiceRecordResponse
|
||||
|
|
@ -377,6 +378,16 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
return cActions.openEditor()
|
||||
}
|
||||
|
||||
// Shift-Tab toggles per-session yolo without submitting a turn — mirrors
|
||||
// Claude Code's in-place dangerously-approve toggle. Slash /yolo keeps
|
||||
// working for discoverability; this just skips the inference round-trip
|
||||
// when you only want to flip the flag mid-flow (blitz #5 sub-item 11).
|
||||
if (key.shift && key.tab && !cState.completions.length) {
|
||||
return void gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: live.sid })
|
||||
.then(r => actions.sys(`yolo ${r?.value === '1' ? 'on' : 'off'}`))
|
||||
}
|
||||
|
||||
if (key.tab && cState.completions.length) {
|
||||
const row = cState.completions[cState.compIdx]
|
||||
|
||||
|
|
|
|||
|
|
@ -184,24 +184,6 @@ const ComposerPane = memo(function ComposerPane({
|
|||
)}
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
{ui.statusBar && (
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
busy={ui.busy}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
showCost={ui.showCost}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
turnStartedAt={status.turnStartedAt}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FloatingOverlays
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
|
|
@ -273,6 +255,32 @@ const AgentsOverlayPane = memo(function AgentsOverlayPane() {
|
|||
)
|
||||
})
|
||||
|
||||
const StatusRulePane = memo(function StatusRulePane({ composer, status }: Pick<AppLayoutProps, 'composer' | 'status'>) {
|
||||
const ui = useStore($uiState)
|
||||
|
||||
if (!ui.statusBar) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
busy={ui.busy}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
showCost={ui.showCost}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
turnStartedAt={status.turnStartedAt}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const AppLayout = memo(function AppLayout({
|
||||
actions,
|
||||
composer,
|
||||
|
|
@ -305,6 +313,8 @@ export const AppLayout = memo(function AppLayout({
|
|||
)}
|
||||
|
||||
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
|
||||
|
||||
{!overlay.agents && <StatusRulePane composer={composer} status={status} />}
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -167,9 +167,14 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
|
|||
return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
|
||||
}
|
||||
|
||||
function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
// Cursor layout mirrors `wrap-ansi(text, cols, { wordWrap: false, hard: true })`
|
||||
// which is what `<Text wrap="wrap-char">` ends up feeding to the renderer.
|
||||
// Char-granularity wrap keeps wrap boundaries deterministic as the user
|
||||
// types — word-wrap's whitespace-preferring reshuffle causes the cursor
|
||||
// to flicker as each keystroke moves a word across a line break (blitz #9).
|
||||
export function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
const w = Math.max(1, cols - 1)
|
||||
const w = Math.max(1, cols)
|
||||
|
||||
let col = 0,
|
||||
line = 0
|
||||
|
|
@ -200,17 +205,27 @@ function cursorLayout(value: string, cursor: number, cols: number) {
|
|||
col += sw
|
||||
}
|
||||
|
||||
// The cursor renders as an inverted cell AFTER the character at `pos`
|
||||
// (or as a standalone trailing cell when `pos === value.length`). If
|
||||
// col has reached the wrap column, that cell overflows to the next row
|
||||
// — match wrap-ansi's behavior so the declared cursor doesn't sit past
|
||||
// the visual edge.
|
||||
if (col >= w) {
|
||||
line++
|
||||
col = 0
|
||||
}
|
||||
|
||||
return { column: col, line }
|
||||
}
|
||||
|
||||
function offsetFromPosition(value: string, row: number, col: number, cols: number) {
|
||||
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
|
||||
if (!value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const targetRow = Math.max(0, Math.floor(row))
|
||||
const targetCol = Math.max(0, Math.floor(col))
|
||||
const w = Math.max(1, cols - 1)
|
||||
const w = Math.max(1, cols)
|
||||
|
||||
let line = 0
|
||||
let column = 0
|
||||
|
|
@ -800,7 +815,7 @@ export function TextInput({
|
|||
}}
|
||||
ref={boxRef}
|
||||
>
|
||||
<Text wrap="wrap">{rendered}</Text>
|
||||
<Text wrap="wrap-char">{rendered}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue