From d30f6ac44eda33c964b1edf9b53c0eb7288b3c41 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 18:31:35 -0500 Subject: [PATCH 1/3] fix(tui): up-arrow inside a multi-line buffer moves cursor, not history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported during TUI v2 blitz retest: typing a multi-line message with shift-Enter and then pressing Up to edit an earlier line swapped the whole buffer for the previous history entry instead of moving the cursor up a line. Down then restored the draft → the buffer appeared to "flip" between the draft and a prior prompt. `useInputHandlers` cycles history on Up/Down, but textInput only checked `inputBuf.length` — that only counts lines committed with a trailing backslash, not shift-Enter newlines inside `input` itself. Fix: detect logical lines inside the input string and move the cursor one line up/down preserving column offset (clamp to line end when the destination is shorter, standard editor behavior). Only fall through to history cycling when the cursor is already on the first line (Up) or last line (Down). Adds unit coverage for the new `lineNav` helper. --- ui-tui/src/__tests__/textInputLineNav.test.ts | 55 +++++++++++++++++++ ui-tui/src/components/textInput.tsx | 49 ++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/textInputLineNav.test.ts diff --git a/ui-tui/src/__tests__/textInputLineNav.test.ts b/ui-tui/src/__tests__/textInputLineNav.test.ts new file mode 100644 index 00000000..56b3772a --- /dev/null +++ b/ui-tui/src/__tests__/textInputLineNav.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' + +import { lineNav } from '../components/textInput.js' + +describe('lineNav', () => { + it('returns null for single-line input (up)', () => { + expect(lineNav('hello world', 6, -1)).toBeNull() + }) + + it('returns null for single-line input (down)', () => { + expect(lineNav('hello world', 6, 1)).toBeNull() + }) + + it('returns null when cursor already on first line of a multiline block', () => { + expect(lineNav('one\ntwo\nthree', 2, -1)).toBeNull() + }) + + it('returns null when cursor on last line of a multiline block', () => { + expect(lineNav('one\ntwo\nthree', 10, 1)).toBeNull() + }) + + it('moves cursor up one line preserving column', () => { + // "hello\nworld" — cursor at col 3 of line 1 ('l' in world) → col 3 of line 0 ('l' in hello) + expect(lineNav('hello\nworld', 9, -1)).toBe(3) + }) + + it('moves cursor down one line preserving column', () => { + // cursor at col 2 of line 0 → col 2 of line 1 + expect(lineNav('hello\nworld', 2, 1)).toBe(8) + }) + + it('clamps to end of shorter destination line on up', () => { + // col 10 on long line → clamp to end of short line "abc" + const s = 'abc\nlong long text' + const from = 14 + + expect(lineNav(s, from, -1)).toBe(3) + }) + + it('clamps to end of shorter destination line on down', () => { + // col 10 on line 0 → clamp to end of "abc" on line 1 + const s = 'long long text\nabc' + + expect(lineNav(s, 10, 1)).toBe(18) + }) + + it('handles empty lines correctly', () => { + // "a\n\nb" — cursor at line 2 (b) → up to empty line 1 + expect(lineNav('a\n\nb', 3, -1)).toBe(2) + }) + + it('handles leading newline without crashing', () => { + expect(lineNav('\nfoo', 2, -1)).toBe(0) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 11c9bde7..536f2f01 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -134,6 +134,39 @@ function wordRight(s: string, p: number) { return i } +/** + * Move cursor one logical line up or down inside `s` while preserving the + * column offset from the current line's start. Returns `null` when the cursor + * is already on the first line (up) or last line (down) — callers use that + * signal to fall through to history cycling instead of eating the arrow key. + */ +export function lineNav(s: string, p: number, dir: -1 | 1): null | number { + const pos = snapPos(s, p) + const curStart = s.lastIndexOf('\n', pos - 1) + 1 + const col = pos - curStart + + if (dir < 0) { + if (curStart === 0) { + return null + } + + const prevStart = s.lastIndexOf('\n', curStart - 2) + 1 + + return snapPos(s, Math.min(prevStart + col, curStart - 1)) + } + + const nextBreak = s.indexOf('\n', pos) + + if (nextBreak < 0) { + return null + } + + const nextEnd = s.indexOf('\n', nextBreak + 1) + const lineEnd = nextEnd < 0 ? s.length : nextEnd + + return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd)) +} + 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) @@ -570,9 +603,21 @@ export function TextInput({ return } + if (k.upArrow || k.downArrow) { + const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1) + + if (next !== null) { + clearSel() + setCur(next) + curRef.current = next + + return + } + + return + } + if ( - k.upArrow || - k.downArrow || (k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || From 95fd023eeb8c9051732c3daacf174a7311d6acb4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 18:48:35 -0500 Subject: [PATCH 2/3] fix(tui): only cycle history at input boundaries on arrows Follow-up on #13726 from blitz feedback: Up/Down history cycling should only trigger when the caret is at the start/end boundary (or the input is empty).\n\nPreviously useInputHandlers intercepted arrows whenever inputBuf was empty, which still stole Up/Down from normal multiline editing. textInput now publishes caret position through inputSelectionStore even with no active selection, and useInputHandlers gates history/queue cycling on those boundaries. --- ui-tui/src/app/useInputHandlers.ts | 20 ++++++++++++++++---- ui-tui/src/components/textInput.tsx | 22 ++++++++++------------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 25243e99..f777ba27 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -288,15 +288,27 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (key.upArrow && !cState.inputBuf.length) { - cycleQueue(1) || cycleHistory(-1) + const inputSel = getInputSelection() + const atStart = !cState.input || (!!inputSel && inputSel.start === 0 && inputSel.end === 0) - return + if (atStart) { + cycleQueue(1) || cycleHistory(-1) + + return + } } if (key.downArrow && !cState.inputBuf.length) { - cycleQueue(-1) || cycleHistory(1) + const inputSel = getInputSelection() + const atEnd = + !cState.input || + (!!inputSel && inputSel.start === cState.input.length && inputSel.end === cState.input.length) - return + if (atEnd || cState.historyIdx !== null) { + cycleQueue(-1) || cycleHistory(1) + + return + } } if (isAction(key, ch, 'c')) { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 536f2f01..d5380faa 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -400,22 +400,20 @@ export function TextInput({ return } - if (selected) { - setInputSelection({ - clear: () => { + setInputSelection({ + clear: () => { + if (selRef.current) { selRef.current = null setSel(null) - }, - end: selected.end, - start: selected.start, - value: vRef.current - }) - } else { - setInputSelection(null) - } + } + }, + end: selected?.end ?? curRef.current, + start: selected?.start ?? curRef.current, + value: vRef.current + }) return () => setInputSelection(null) - }, [focus, selected]) + }, [cur, focus, selected]) useEffect( () => () => { From 35cc66df62e35daddcbb9d3f15f5938d35b11ae8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 18:55:57 -0500 Subject: [PATCH 3/3] fix(tui): arrow history fallback when no line exists Follow-up on multiline arrow behavior: Up/Down now fall back to queue/history whenever there is no logical line above/below the caret (not only at absolute start/end character positions). This makes Up from the end of the top line cycle history, matching expected readline-ish behavior. --- ui-tui/src/app/useInputHandlers.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index f777ba27..5c5f2784 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -289,9 +289,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (key.upArrow && !cState.inputBuf.length) { const inputSel = getInputSelection() - const atStart = !cState.input || (!!inputSel && inputSel.start === 0 && inputSel.end === 0) + const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null + const noLineAbove = + !cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0) - if (atStart) { + if (noLineAbove) { cycleQueue(1) || cycleHistory(-1) return @@ -300,11 +302,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (key.downArrow && !cState.inputBuf.length) { const inputSel = getInputSelection() - const atEnd = - !cState.input || - (!!inputSel && inputSel.start === cState.input.length && inputSel.end === cState.input.length) + const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null + const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0) - if (atEnd || cState.historyIdx !== null) { + if (noLineBelow || cState.historyIdx !== null) { cycleQueue(-1) || cycleHistory(1) return