mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
35a4b093d8
commit
d30f6ac44e
2 changed files with 102 additions and 2 deletions
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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) ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue