mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
fix(tui): commit composer input bursts immediately (#31053)
* fix(tui): commit composer input bursts immediately Salvage the WSL/terminal multi-character input burst fix with focused regression coverage so delayed pseudo-paste buffers cannot reorder later edits. * fix(tui): keep newline input bursts on paste path Preserve paste handling for multi-character chunks with newlines while keeping repeated printable key bursts on the immediate composer path. * refactor(tui): share composer frame batch interval Use one frame-sized batching constant for parent updates, local renders, and input burst flushes.
This commit is contained in:
parent
ad11327db0
commit
026f64f8e0
2 changed files with 137 additions and 35 deletions
40
ui-tui/src/__tests__/textInputBurstInput.test.ts
Normal file
40
ui-tui/src/__tests__/textInputBurstInput.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { applyPrintableInsert, shouldRouteMultiCharInputAsPaste } from '../components/textInput.js'
|
||||
|
||||
describe('applyPrintableInsert', () => {
|
||||
it('applies non-bracketed multi-character bursts immediately', () => {
|
||||
const burst = applyPrintableInsert('abc', 3, 'xxxxx')
|
||||
|
||||
const repeated = [...'xxxxx'].reduce(
|
||||
(state, ch) => applyPrintableInsert(state.value, state.cursor, ch)!,
|
||||
{ cursor: 3, value: 'abc' }
|
||||
)
|
||||
|
||||
expect(burst).toEqual({ cursor: 8, value: 'abcxxxxx' })
|
||||
expect(burst).toEqual(repeated)
|
||||
})
|
||||
|
||||
it('replaces the selected range for burst input', () => {
|
||||
expect(applyPrintableInsert('abZZef', 4, 'cd', { end: 4, start: 2 })).toEqual({
|
||||
cursor: 4,
|
||||
value: 'abcdef'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects control or escape-bearing input', () => {
|
||||
expect(applyPrintableInsert('abc', 3, '\x1b[200~pasted')).toBeNull()
|
||||
expect(applyPrintableInsert('abc', 3, '\t')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRouteMultiCharInputAsPaste', () => {
|
||||
it('keeps newline-bearing chunks on the paste path', () => {
|
||||
expect(shouldRouteMultiCharInputAsPaste('hello\nworld')).toBe(true)
|
||||
expect(shouldRouteMultiCharInputAsPaste('hello\r\nworld'.replace(/\r\n/g, '\n'))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats repeated printable key bursts as immediate input', () => {
|
||||
expect(shouldRouteMultiCharInputAsPaste('xxxxx')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -34,6 +34,7 @@ const DIM_OFF = `${ESC}[22m`
|
|||
const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`)
|
||||
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
|
||||
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
|
||||
const FRAME_BATCH_MS = 16
|
||||
const MULTI_CLICK_MS = 500
|
||||
|
||||
const invert = (s: string) => INV + s + INV_OFF
|
||||
|
|
@ -91,6 +92,36 @@ function snapPos(s: string, p: number) {
|
|||
return last
|
||||
}
|
||||
|
||||
export interface TextInsertResult {
|
||||
cursor: number
|
||||
value: string
|
||||
}
|
||||
|
||||
export function applyPrintableInsert(
|
||||
value: string,
|
||||
cursor: number,
|
||||
text: string,
|
||||
range?: { end: number; start: number } | null
|
||||
): null | TextInsertResult {
|
||||
if (!PRINTABLE.test(text)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (range) {
|
||||
return {
|
||||
cursor: range.start + text.length,
|
||||
value: value.slice(0, range.start) + text + value.slice(range.end)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: cursor + text.length,
|
||||
value: value.slice(0, cursor) + text + value.slice(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n')
|
||||
|
||||
function prevPos(s: string, p: number) {
|
||||
const pos = snapPos(s, p)
|
||||
let prev = 0
|
||||
|
|
@ -308,6 +339,7 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env):
|
|||
// off by default in Termux mode; allow explicit opt-in for local debugging.
|
||||
if (isTermuxTuiMode(env)) {
|
||||
const override = String(env.HERMES_TUI_TERMUX_FAST_ECHO ?? '').trim().toLowerCase()
|
||||
|
||||
if (override) {
|
||||
return /^(?:1|true|yes|on)$/i.test(override)
|
||||
}
|
||||
|
|
@ -400,10 +432,7 @@ export function TextInput({
|
|||
const selRef = useRef<null | { end: number; start: number }>(null)
|
||||
const vRef = useRef(value)
|
||||
const self = useRef(false)
|
||||
const pasteBuf = useRef('')
|
||||
const pasteEnd = useRef<null | number>(null)
|
||||
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pastePos = useRef(0)
|
||||
const keyBurstTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const editVersionRef = useRef(0)
|
||||
const parentChangeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingParentValue = useRef<string | null>(null)
|
||||
|
|
@ -536,8 +565,8 @@ export function TextInput({
|
|||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pasteTimer.current) {
|
||||
clearTimeout(pasteTimer.current)
|
||||
if (keyBurstTimer.current) {
|
||||
clearTimeout(keyBurstTimer.current)
|
||||
}
|
||||
|
||||
if (parentChangeTimer.current) {
|
||||
|
|
@ -573,7 +602,7 @@ export function TextInput({
|
|||
return
|
||||
}
|
||||
|
||||
parentChangeTimer.current = setTimeout(flushParentChange, 16)
|
||||
parentChangeTimer.current = setTimeout(flushParentChange, FRAME_BATCH_MS)
|
||||
}
|
||||
|
||||
const cancelLocalRender = () => {
|
||||
|
|
@ -591,7 +620,7 @@ export function TextInput({
|
|||
localRenderTimer.current = setTimeout(() => {
|
||||
localRenderTimer.current = null
|
||||
setCur(curRef.current)
|
||||
}, 16)
|
||||
}, FRAME_BATCH_MS)
|
||||
}
|
||||
|
||||
const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY
|
||||
|
|
@ -695,21 +724,26 @@ export function TextInput({
|
|||
return !!h
|
||||
}
|
||||
|
||||
const flushPaste = () => {
|
||||
const text = pasteBuf.current
|
||||
const at = pastePos.current
|
||||
const end = pasteEnd.current ?? at
|
||||
pasteBuf.current = ''
|
||||
pasteEnd.current = null
|
||||
pasteTimer.current = null
|
||||
const flushKeyBurst = () => {
|
||||
if (keyBurstTimer.current) {
|
||||
clearTimeout(keyBurstTimer.current)
|
||||
keyBurstTimer.current = null
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
flushParentChange()
|
||||
}
|
||||
|
||||
const scheduleKeyBurstCommit = (next: string, nextCur: number) => {
|
||||
commit(next, nextCur, true, false, false)
|
||||
|
||||
if (keyBurstTimer.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) {
|
||||
commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length)
|
||||
}
|
||||
keyBurstTimer.current = setTimeout(() => {
|
||||
keyBurstTimer.current = null
|
||||
flushParentChange()
|
||||
}, FRAME_BATCH_MS)
|
||||
}
|
||||
|
||||
const clearSel = () => {
|
||||
|
|
@ -850,6 +884,8 @@ export function TextInput({
|
|||
// follow-up on #19835). The pass-through predicate is a no-op for
|
||||
// ordinary typing and plain paste when voice is unbound to 'v'.
|
||||
if (shouldPassThroughToGlobalHandler(inp, k, voiceRecordKey)) {
|
||||
flushKeyBurst()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -859,6 +895,8 @@ export function TextInput({
|
|||
eventRaw === '\x16' ||
|
||||
(isMac && isActionMod(k) && inp.toLowerCase() === 'v')
|
||||
) {
|
||||
flushKeyBurst()
|
||||
|
||||
if (cbPaste.current) {
|
||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
}
|
||||
|
|
@ -875,6 +913,8 @@ export function TextInput({
|
|||
}
|
||||
|
||||
if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') {
|
||||
flushKeyBurst()
|
||||
|
||||
const range = selRange()
|
||||
|
||||
if (range) {
|
||||
|
|
@ -887,6 +927,8 @@ export function TextInput({
|
|||
}
|
||||
|
||||
if (k.upArrow || k.downArrow) {
|
||||
flushKeyBurst()
|
||||
|
||||
const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1)
|
||||
|
||||
if (next !== null) {
|
||||
|
|
@ -899,11 +941,11 @@ export function TextInput({
|
|||
}
|
||||
|
||||
if (k.return) {
|
||||
flushKeyBurst()
|
||||
|
||||
if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) {
|
||||
flushParentChange()
|
||||
commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
|
||||
} else {
|
||||
flushParentChange()
|
||||
cbSubmit.current?.(vRef.current)
|
||||
}
|
||||
|
||||
|
|
@ -921,6 +963,11 @@ export function TextInput({
|
|||
const actionDeleteWord = (mod && inp === 'w') || isMacActionFallback(k, inp, 'w')
|
||||
const range = selRange()
|
||||
const delFwd = k.delete || fwdDel.current
|
||||
const isPrintableInput = (event.keypress.isPasted || inp.length > 0) && PRINTABLE.test(inp.replace(BRACKET_PASTE, ''))
|
||||
|
||||
if (!isPrintableInput) {
|
||||
flushKeyBurst()
|
||||
}
|
||||
|
||||
if (mod && inp === 'z') {
|
||||
return swap(undo, redo)
|
||||
|
|
@ -1050,31 +1097,44 @@ export function TextInput({
|
|||
}
|
||||
|
||||
if (text.length > 1 || text.includes('\n')) {
|
||||
if (!pasteBuf.current) {
|
||||
pastePos.current = range ? range.start : c
|
||||
pasteEnd.current = range ? range.end : pastePos.current
|
||||
if (shouldRouteMultiCharInputAsPaste(text)) {
|
||||
flushKeyBurst()
|
||||
|
||||
if (!emitPaste({ cursor: c, text, value: v })) {
|
||||
commit(ins(v, c, text), c + text.length)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pasteBuf.current += text
|
||||
const inserted = applyPrintableInsert(v, c, text, range)
|
||||
|
||||
if (pasteTimer.current) {
|
||||
clearTimeout(pasteTimer.current)
|
||||
if (!inserted) {
|
||||
return
|
||||
}
|
||||
|
||||
pasteTimer.current = setTimeout(flushPaste, 50)
|
||||
v = inserted.value
|
||||
c = inserted.cursor
|
||||
scheduleKeyBurstCommit(v, c)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (PRINTABLE.test(text)) {
|
||||
{
|
||||
const inserted = applyPrintableInsert(v, c, text, range)
|
||||
|
||||
if (!inserted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + text + v.slice(range.end)
|
||||
c = range.start + text.length
|
||||
v = inserted.value
|
||||
c = inserted.cursor
|
||||
} else {
|
||||
const simpleAppend = canFastAppend(v, c, text)
|
||||
|
||||
v = v.slice(0, c) + text + v.slice(c)
|
||||
c += text.length
|
||||
v = inserted.value
|
||||
c = inserted.cursor
|
||||
|
||||
if (simpleAppend) {
|
||||
stdout!.write(text)
|
||||
|
|
@ -1091,8 +1151,6 @@ export function TextInput({
|
|||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
|
|
@ -1125,11 +1183,13 @@ export function TextInput({
|
|||
if (e.button === 2) {
|
||||
e.stopImmediatePropagation?.()
|
||||
const decision = decideRightClickAction(vRef.current, selRange())
|
||||
|
||||
if (decision.action === 'copy') {
|
||||
void writeClipboardText(decision.text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
|
||||
return
|
||||
|
|
@ -1222,10 +1282,12 @@ export function decideRightClickAction(
|
|||
): RightClickDecision {
|
||||
if (range && range.end > range.start) {
|
||||
const text = value.slice(range.start, range.end)
|
||||
|
||||
if (text) {
|
||||
return { action: 'copy', text }
|
||||
}
|
||||
}
|
||||
|
||||
return { action: 'paste' }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue