From 026f64f8e07d8b5588f7e67b1b7fc3f60c5d99bc Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 23 May 2026 13:27:16 -0500 Subject: [PATCH] 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. --- .../src/__tests__/textInputBurstInput.test.ts | 40 ++++++ ui-tui/src/components/textInput.tsx | 132 +++++++++++++----- 2 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 ui-tui/src/__tests__/textInputBurstInput.test.ts diff --git a/ui-tui/src/__tests__/textInputBurstInput.test.ts b/ui-tui/src/__tests__/textInputBurstInput.test.ts new file mode 100644 index 00000000000..1fdd5246614 --- /dev/null +++ b/ui-tui/src/__tests__/textInputBurstInput.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 8c9e5213b13..2e117a0a007 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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) const vRef = useRef(value) const self = useRef(false) - const pasteBuf = useRef('') - const pasteEnd = useRef(null) - const pasteTimer = useRef | null>(null) - const pastePos = useRef(0) + const keyBurstTimer = useRef | null>(null) const editVersionRef = useRef(0) const parentChangeTimer = useRef | null>(null) const pendingParentValue = useRef(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' } }