SGR mouse escape sequences leaking into input/composer
NousResearch/hermes-agent · cluster 18658 · hermes cli
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, 2 issues 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
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple issues and proposed fixes centered on SGR mouse tracking codes leaking into the terminal input buffer or composer, covering detection, recovery, and prevention strategies.
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
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · stale or abandoned 1
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: 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 LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #35512
- Closed issues
- 2
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 31 May 2026