diff --git a/ui-tui/src/__tests__/textInputLineNav.test.ts b/ui-tui/src/__tests__/textInputLineNav.test.ts new file mode 100644 index 0000000000..56b3772a9f --- /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 11c9bde76d..536f2f0181 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) ||