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:
brooklyn! 2026-05-23 13:27:16 -05:00 committed by GitHub
parent ad11327db0
commit 026f64f8e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 137 additions and 35 deletions

View 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)
})
})

View file

@ -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' }
}