Merge pull request #13726 from NousResearch/bb/tui-multiline-up-arrow

fix(tui): up-arrow inside a multi-line buffer moves cursor, not history
This commit is contained in:
brooklyn! 2026-04-21 18:58:56 -05:00 committed by GitHub
commit e2feccf7c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 129 additions and 18 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

@ -288,15 +288,28 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
}
if (key.upArrow && !cState.inputBuf.length) {
cycleQueue(1) || cycleHistory(-1)
const inputSel = getInputSelection()
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)
return
if (noLineAbove) {
cycleQueue(1) || cycleHistory(-1)
return
}
}
if (key.downArrow && !cState.inputBuf.length) {
cycleQueue(-1) || cycleHistory(1)
const inputSel = getInputSelection()
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0)
return
if (noLineBelow || cState.historyIdx !== null) {
cycleQueue(-1) || cycleHistory(1)
return
}
}
if (isAction(key, ch, 'c')) {

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)
@ -367,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(
() => () => {
@ -570,9 +601,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) ||