fix(tui): up-arrow inside a multi-line buffer moves cursor, not history

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.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 18:31:35 -05:00
parent 35a4b093d8
commit d30f6ac44e
2 changed files with 102 additions and 2 deletions

View file

@ -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)
})
})

View file

@ -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) ||