diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx index ea2a74c9a6..9459b78a24 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -69,6 +69,12 @@ const memoizedStylesForWrap: Record, Styles> = { flexDirection: 'row', textWrap: 'wrap' }, + 'wrap-char': { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'wrap-char' + }, 'wrap-trim': { flexGrow: 0, flexShrink: 1, diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index 5c9e62b468..dd7372a092 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -343,7 +343,7 @@ function wrapWithSoftWrap( maxWidth: number, textWrap: Parameters[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 diff --git a/ui-tui/packages/hermes-ink/src/ink/styles.ts b/ui-tui/packages/hermes-ink/src/ink/styles.ts index e5321f6e50..0fa6cc66e6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/styles.ts +++ b/ui-tui/packages/hermes-ink/src/ink/styles.ts @@ -55,6 +55,7 @@ export type TextStyles = { export type Styles = { readonly textWrap?: | 'wrap' + | 'wrap-char' | 'wrap-trim' | 'end' | 'middle' diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts index 4d157bc2af..c0b95df086 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -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, diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts new file mode 100644 index 0000000000..9414b9fbdb --- /dev/null +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 9d3ccdf09f..bb88383aea 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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('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] diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 959b6ea70c..d04922b7d4 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -184,24 +184,6 @@ const ComposerPane = memo(function ComposerPane({ )} - {ui.statusBar && ( - - )} - ) { + const ui = useStore($uiState) + + if (!ui.statusBar) { + return null + } + + return ( + + ) +}) + export const AppLayout = memo(function AppLayout({ actions, composer, @@ -305,6 +313,8 @@ export const AppLayout = memo(function AppLayout({ )} {!overlay.agents && } + + {!overlay.agents && } ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 12b228c1f8..4b6950cf5b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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 `` 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} > - {rendered} + {rendered} ) }