TUI input corruption after gateway pipe break
Cluster 28419 · gateway. Data as of
Nothing open on GitHub. Fix merged in #35512. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #35512. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #42165. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A cluster of fixes addressing TUI input box flooding with ANSI escape sequences and degraded mouse/focus burst noise following a gateway pipe break. Changes include stripping ANSI from the input buffer, swallowing burst noise, and adding a terminal-mode exit backstop.
Open issues0
Open PRs0
Fixes on record
-
#35512 fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer
files:
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts,ui-tui/packages/hermes-ink/src/ink/parse-keypress.tsShow the diff
-
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts@@ -133,4 +133,35 @@ describe('fragmented SGR mouse recovery', () => { expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' }) }) + + it('swallows a fully degraded mouse-burst noise blob without leaking prompt text', () => { + // Captured from Windows Terminal during a heavy tool-call render: the event + // loop blocked past App's 50ms flush timer, so a long burst of SGR mouse + // reports (mode 1003 any-motion) arrived as text with prefixes AND + // too degraded for SGR_MOUSE_FRAGMENT_RE (1- and 2-param remnants, a + // stray focus-in `[I`), so without the whole-text noise fast path the entire + // blob types into the composer and locks the user out. + const blob = + 'M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22MM18M99;26M32MM38M63;44M47MM1;51M M4M54M' + const [events] = parseMultipleKeypresses(INITIAL_STATE, blob) + + expect(events).toEqual([]) + }) + + it('keeps plain prose that only contains scattered M and m letters', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'Mmm MMM mmm yummy') + + expect(key).toMatchObject({ kind: 'key', sequence: 'Mmm MMM mmm yummy' }) + }) + + it('swallows noise wholesale even when it contains intact recoverable fragments', () => { + // A noise blob can carry a few intact `<b;c;r M` fragments amid the chewed + // shards. The whole-text noise check must run BEFORE fragment recovery — + // otherwise parseTextWithSgrMouseFragments returns non-null and emits a + // pile of recovered mouse events instead of dropping the blob wholesale. + const blob = '<35;159;11M;44M20;43M0M7M<35;124;26M;47;40M9;15;32M5M2M' + const [events] = parseMultipleKeypresses(INITIAL_STATE, blob) + + expect(events).toEqual([]) + }) }) -
ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts@@ -65,6 +65,34 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/ const SGR_MOUSE_FRAGMENT_RE = /(?<!\d)(?:\[<|<)?(?:[0-9]|[1-9][0-9]|1\d{2}|2[0-4]\d|25[0-5]);\d+;\d+[Mm]/g +// Whole-text mouse-burst noise fast path. When a heavy render blocks the event +// loop past App's 50ms flush watchdog, a long burst of SGR mouse reports (mode +// 1003 any-motion / 1006 SGR) can arrive as a single text token with prefixes +// AND coordinate digits chewed off across many partial reads. The surviving +// shards (1- and 2-param remnants, stray focus-in `[I`, lone `M`/`m` +// terminators) are too degraded for SGR_MOUSE_FRAGMENT_RE, so the leftover +// tail leaks into the composer and locks the user out (they can't type or exit). +// +// If the ENTIRE text token is drawn only from the mouse-leak alphabet +// (`[ ] < ; I M m`, digits, and the stray spaces a burst can carry) AND it +// carries the structural signature of mouse coordinates — ≥3 `M`/`m` +// terminators, at least one digit, and at least one `;` separator — swallow it +// wholesale. All three constraints together preserve real prose: `Mmm MMM mmm` +// has no digit and no `;`, `see 1;2;3M for details` contains disqualifying +// letters, and `1234;56;78M9;10;11M` has only two terminators. +// eslint-disable-next-line no-control-regex +const MOUSE_BURST_NOISE_RE = /^(?=[\s\S]*\d)(?=[\s\S]*;)(?=(?:[^Mm]*[Mm]){3})[\d;<\[\]IMm \x1b]+$/ + +// Residual-shard variant for the gaps BETWEEN / AFTER recovered fragments +// inside parseTextWithSgrMouseFragments. A real recovery run leaves degraded +// remnants (e.g. `M6M`, `7M;220;1MM0M`, lone `;157;47M`) that are pure +// mouse-leak alphabet but too short to satisfy the ≥3-terminator whole-text +// rule. Swallow such a residue only when it is pure alphabet AND carries a +// digit AND at least one `M`/`m` — a prose gap like ` for details ` contains +// disqualifying letters and never matches. +// eslint-disable-next-line no-control-regex +const MOUSE_BURST_RESIDUE_RE = /^(?=[^\d]*\d)(?=[^Mm]*[Mm])[\d;<\[\]IMm \x1b]+$/ + function createPasteKey(content: string): ParsedKey { return { kind: 'key', @@ -268,6 +296,16 @@ export function parseMultipleKeypresses( } else if (token.type === 'text') { if (inPaste) { pasteBuffer += token.value + } else if (MOUSE_BURST_NOISE_RE.test(token.value)) { + // Fully degraded mouse-burst noise — a heavy render (e.g. a sudo / + // secret prompt repaint) blocked the event loop past App's 50ms flush + // watchdog, so a long burst of SGR mouse reports arrived as text with + // prefixes AND coordinate digits chewed off. Checked BEFORE fragment + // recovery: a noise blob can still contain a few intact `<b;c;r M` + // fragments, and parseTextWithSgrMouseFragments would then return + // non-null and emit a pile of recovered mouse events instead of + // dropping the blob wholesale. Swallow it here so it never leaks into + // the composer (and we skip the extra fragment-recovery work mid-stall). } else { const mouseFragments = parseTextWithSgrMouseFragments(token.value) @@ -674,7 +712,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null { } if (first.index! > cursor) { - parsed.push(parseKeypress(text.slice(cursor, first.index!))) + const gap = text.slice(cursor, first.index!) + // Skip pure mouse-leak residue between recovered fragments; only emit + // real text gaps as keypresses. + if (!MOUSE_BURST_RESIDUE_RE.test(gap)) { + parsed.push(parseKeypress(gap)) + } } for (const match of run) { @@ -690,7 +733,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null { } if (cursor < text.length) { - parsed.push(parseKeypress(text.slice(cursor))) + const tail = text.slice(cursor) + // Swallow a pure mouse-leak residue tail (the head fragments recovered, but + // the burst trailed off into chewed-up shards). Emit only real trailing text. + if (!MOUSE_BURST_RESIDUE_RE.test(tail)) { + parsed.push(parseKeypress(tail)) + } } return parsed
-
-
#42165 fix(tui): install the process.on('exit') terminal-mode backstop
files:
ui-tui/src/__tests__/terminalModes.test.ts,ui-tui/src/entry.tsxShow the diff
-
ui-tui/src/__tests__/terminalModes.test.ts@@ -36,4 +36,26 @@ describe('terminal mode reset', () => { expect(resetTerminalModes({ isTTY: false, write } as unknown as NodeJS.WriteStream)).toBe(false) expect(write).not.toHaveBeenCalled() }) + + // entry.tsx installs `process.on('exit', () => resetTerminalModes())` as the + // final backstop (#28419): /quit, Ctrl+C, Ctrl+D and any process.exit() path + // must disarm DEC mouse tracking so the parent shell / next TUI doesn't read + // leaked mouse reports as keystrokes. 'exit' handlers run synchronously only, + // so the reset must complete via a single synchronous write — verify that an + // exit-style invocation disables every SGR mouse mode that produced the + // reported `…;…M` garbage. + it('disarms mouse tracking from a synchronous exit-style handler', () => { + const write = vi.fn() + const stream = { isTTY: true, write } as unknown as NodeJS.WriteStream + + // Mirror entry.tsx's process.on('exit') callback. + const onExit = () => resetTerminalModes(stream) + onExit() + + expect(write).toHaveBeenCalledTimes(1) + const written = write.mock.calls[0]?.[0] as string + for (const mode of ['\x1b[?1006l', '\x1b[?1003l', '\x1b[?1002l', '\x1b[?1000l']) { + expect(written).toContain(mode) + } + }) }) -
ui-tui/src/entry.tsx@@ -23,6 +23,22 @@ if (!process.stdin.isTTY) { // terminal tab can still have mouse/focus/paste modes enabled. resetTerminalModes() +// Final backstop for terminal cleanup. setupGracefulExit() resets modes on +// signals/uncaught errors, and die()/dieWithCode() call process.exit() after +// Ink's unmount specifically so this handler can fire (see useMainApp.ts and +// #19194). But that handler was never actually installed — so /quit, Ctrl+C, +// Ctrl+D, and any process.exit() path left DEC mouse tracking (?1000/1002/ +// 1003/1006) armed in the parent shell. The terminal then keeps emitting mouse +// reports into whatever reads stdin next — the shell or a freshly relaunched +// TUI mid-init — which surface as `102;71M5;104;62M`-style garbage in the input +// box (#28419). 'exit' fires exactly once on real termination and only runs +// synchronous code; resetTerminalModes() writes via writeSync, so it completes +// before the process is gone. Idempotent and cheap, so layering it under the +// graceful-exit cleanups is safe. +process.on('exit', () => { + resetTerminalModes() +}) + // Desktop terminals benefit from a clean startup slate because the TUI usually // runs in AlternateScreen. On Termux we keep prior output intact so users can // review/copy earlier assistant replies after reopening the app.
-
2 PRs closed without merging
From the close comments: done elsewhere 2
Closed issues · 1
- Closed#28419[Bug]: TUI input box gets flooded with ANSI escape sequences after gateway pipe break
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab