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:
commit
e2feccf7c6
55
ui-tui/src/__tests__/textInputLineNav.test.ts
Normal file
55
ui-tui/src/__tests__/textInputLineNav.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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')) {
|
||||
|
||||
@ -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) ||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user