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:
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) {
|
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) {
|
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')) {
|
if (isAction(key, ch, 'c')) {
|
||||||
|
|||||||
@@ -134,6 +134,39 @@ function wordRight(s: string, p: number) {
|
|||||||
return i
|
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) {
|
function cursorLayout(value: string, cursor: number, cols: number) {
|
||||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||||
const w = Math.max(1, cols - 1)
|
const w = Math.max(1, cols - 1)
|
||||||
@@ -367,22 +400,20 @@ export function TextInput({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected) {
|
setInputSelection({
|
||||||
setInputSelection({
|
clear: () => {
|
||||||
clear: () => {
|
if (selRef.current) {
|
||||||
selRef.current = null
|
selRef.current = null
|
||||||
setSel(null)
|
setSel(null)
|
||||||
},
|
}
|
||||||
end: selected.end,
|
},
|
||||||
start: selected.start,
|
end: selected?.end ?? curRef.current,
|
||||||
value: vRef.current
|
start: selected?.start ?? curRef.current,
|
||||||
})
|
value: vRef.current
|
||||||
} else {
|
})
|
||||||
setInputSelection(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => setInputSelection(null)
|
return () => setInputSelection(null)
|
||||||
}, [focus, selected])
|
}, [cur, focus, selected])
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -570,9 +601,21 @@ export function TextInput({
|
|||||||
return
|
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 (
|
if (
|
||||||
k.upArrow ||
|
|
||||||
k.downArrow ||
|
|
||||||
(k.ctrl && inp === 'c') ||
|
(k.ctrl && inp === 'c') ||
|
||||||
k.tab ||
|
k.tab ||
|
||||||
(k.shift && k.tab) ||
|
(k.shift && k.tab) ||
|
||||||
|
|||||||
Reference in New Issue
Block a user