From 7dfb4ff60c016e1c230d342a51a15d58d1b21ce9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 30 May 2026 14:50:59 -0500 Subject: [PATCH 1/6] fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Node event loop blocks during a heavy render/tool-call burst, stdin stops being drained. Mode-1003 any-motion mouse reports pile up in the kernel buffer, get partially read, and arrive as text with the `\x1b[<` prefix AND coordinate digits chewed off across many partial reads. The existing fragment recovery (SGR_MOUSE_FRAGMENT_RE) only handles clean `button;col;row[Mm]` triples, so the degraded shards leak into the composer as typed text — the user can no longer type or exit until the stall clears. Captured leak (Windows Terminal, during tool calls): M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M 48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22M M18M99;26M32MM38M63;44M47MM1;51M M4M54M Add two recovery layers in parseTextWithSgrMouseFragments / the text-token path: - MOUSE_BURST_NOISE_RE: whole-text fast path. If a text token is drawn only from the mouse-leak alphabet (`[ ] < ; I M m`, digits, spaces) AND carries the structural signature of mouse coordinates (>=3 M/m terminators, a digit, and a `;`), swallow it wholesale. - MOUSE_BURST_RESIDUE_RE: swallows pure-noise residue in the gaps between and after recovered fragments, so a partially-recovered burst doesn't trail a chewed-up tail into the prompt. All three constraints together preserve real prose: `Mmm MMM mmm yummy` has no digit/`;`, `see 1;2;3M for details` has disqualifying letters, and `1234;56;78M9;10;11M` has only two terminators — none are swallowed. This is defense-in-depth: it stops the leak/lockout regardless of what blocks the loop. The underlying event-loop stall during streaming is a separate, still-open issue that needs live-turn instrumentation to root-cause. --- .../hermes-ink/src/ink/parse-keypress.test.ts | 21 ++++++++ .../hermes-ink/src/ink/parse-keypress.ts | 49 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts index cee7ab39ddc2f..88dd3d10581d1 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -133,4 +133,25 @@ 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 + // coordinate digits chewed off across many partial reads. The shards are + // 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.filter(e => e.kind === 'key' && !(e as { isPasted?: boolean }).isPasted)).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' }) + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index a92a72b5c43a5..ef09a58caf28c 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/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 = /(? 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 +730,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 From cc3c3740e82348c0910ef6bc9621690dc16288bf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 30 May 2026 14:57:32 -0500 Subject: [PATCH 2/6] fix(tui): check mouse-burst noise before fragment recovery; drop test cast Copilot review on #35512: - MOUSE_BURST_NOISE_RE was only evaluated when parseTextWithSgrMouseFragments returned null. A noise blob that contains any intact ` { '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.filter(e => e.kind === 'key' && !(e as { isPasted?: boolean }).isPasted)).toEqual([]) + expect(events.filter(e => e.kind === 'key' && !e.isPasted)).toEqual([]) }) it('keeps plain prose that only contains scattered M and m letters', () => { @@ -154,4 +154,15 @@ describe('fragmented SGR mouse recovery', () => { 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 ` Date: Sat, 30 May 2026 15:02:23 -0500 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts index d5af5aa5cdc5e..ad8856594bcfa 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -146,7 +146,7 @@ describe('fragmented SGR mouse recovery', () => { '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.filter(e => e.kind === 'key' && !e.isPasted)).toEqual([]) + expect(events).toEqual([]) }) it('keeps plain prose that only contains scattered M and m letters', () => { From 88391adf18cf7dba7531bedc79805347b9302b6e Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 30 May 2026 15:08:15 -0500 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index 66981b847c45d..aaa5eb1b5f7d7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -91,7 +91,7 @@ const MOUSE_BURST_NOISE_RE = /^(?=[^]*\d)(?=[^]*;)(?=(?:[^]*[Mm]){3})[\d;<\[\]IM // 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)(?=[^]*[Mm])[\d;<\[\]IMm \x1b]+$/ +const MOUSE_BURST_RESIDUE_RE = /^(?=[^\d]*\d)(?=[^Mm]*[Mm])[\d;<\[\]IMm \x1b]+$/ function createPasteKey(content: string): ParsedKey { return { From 878246320bf9228892862926a22d8bf54191e4bd Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 30 May 2026 15:08:18 -0500 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index aaa5eb1b5f7d7..8f7cceb1b332d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -81,7 +81,7 @@ const SGR_MOUSE_FRAGMENT_RE = /(? Date: Sat, 30 May 2026 15:08:23 -0500 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts index ad8856594bcfa..2905c53a2ba26 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -138,9 +138,8 @@ describe('fragmented SGR mouse recovery', () => { // 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 - // coordinate digits chewed off across many partial reads. The shards are // 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 + // 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'