{"repo":"NousResearch/hermes-agent","public_id":"18658","display_label":"SGR mouse escape sequences leaking into input/composer","summary":"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.","subsystem":"hermes_cli","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35512},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-31T03:27:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":18658,"pr_number":35512,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":28419,"pr_number":35512,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":18658,"title":"fix(tui): SGR mouse sequence leakage into composer under heavy render in tmux","url":null,"state":"closed"},{"kind":"issue","number":29557,"title":"[Bug]: Mouse tracking escape sequences leak into input buffer (raw SGR mouse codes appear in prompt input)","url":null,"state":"closed"},{"kind":"pr","number":18695,"title":"fix(tui): drop corrupted SGR mouse fragments that escape existing recovery","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":34317,"title":"fix(tui): drop corrupted SGR mouse garbage that leaked into composer","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35512","number":35512},{"kind":"pr","value":"#36864","number":36864}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35512,"title":"fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts","ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts"],"fix_hunks":[{"file":"ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts","hunks":["@@ -133,4 +133,35 @@ describe('fragmented SGR mouse recovery', () => {\n \n     expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' })\n   })\n+\n+  it('swallows a fully degraded mouse-burst noise blob without leaking prompt text', () => {\n+    // Captured from Windows Terminal during a heavy tool-call render: the event\n+    // loop blocked past App's 50ms flush timer, so a long burst of SGR mouse\n+    // reports (mode 1003 any-motion) arrived as text with prefixes AND\n+    // too degraded for SGR_MOUSE_FRAGMENT_RE (1- and 2-param remnants, a\n+    // stray focus-in `[I`), so without the whole-text noise fast path the entire\n+    // blob types into the composer and locks the user out.\n+    const blob =\n+      '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'\n+    const [events] = parseMultipleKeypresses(INITIAL_STATE, blob)\n+\n+    expect(events).toEqual([])\n+  })\n+\n+  it('keeps plain prose that only contains scattered M and m letters', () => {\n+    const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'Mmm MMM mmm yummy')\n+\n+    expect(key).toMatchObject({ kind: 'key', sequence: 'Mmm MMM mmm yummy' })\n+  })\n+\n+  it('swallows noise wholesale even when it contains intact recoverable fragments', () => {\n+    // A noise blob can carry a few intact `<b;c;r M` fragments amid the chewed\n+    // shards. The whole-text noise check must run BEFORE fragment recovery —\n+    // otherwise parseTextWithSgrMouseFragments returns non-null and emits a\n+    // pile of recovered mouse events instead of dropping the blob wholesale.\n+    const blob = '<35;159;11M;44M20;43M0M7M<35;124;26M;47;40M9;15;32M5M2M'\n+    const [events] = parseMultipleKeypresses(INITIAL_STATE, blob)\n+\n+    expect(events).toEqual([])\n+  })\n })"]},{"file":"ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts","hunks":["@@ -65,6 +65,34 @@ const XTVERSION_RE = /^\\x1bP>\\|(.*?)(?:\\x07|\\x1b\\\\)$/s\n const SGR_MOUSE_RE = /^\\x1b\\[<(\\d+);(\\d+);(\\d+)([Mm])$/\n 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\n \n+// Whole-text mouse-burst noise fast path. When a heavy render blocks the event\n+// loop past App's 50ms flush watchdog, a long burst of SGR mouse reports (mode\n+// 1003 any-motion / 1006 SGR) can arrive as a single text token with prefixes\n+// AND coordinate digits chewed off across many partial reads. The surviving\n+// shards (1- and 2-param remnants, stray focus-in `[I`, lone `M`/`m`\n+// terminators) are too degraded for SGR_MOUSE_FRAGMENT_RE, so the leftover\n+// tail leaks into the composer and locks the user out (they can't type or exit).\n+//\n+// If the ENTIRE text token is drawn only from the mouse-leak alphabet\n+// (`[ ] < ; I M m`, digits, and the stray spaces a burst can carry) AND it\n+// carries the structural signature of mouse coordinates — ≥3 `M`/`m`\n+// terminators, at least one digit, and at least one `;` separator — swallow it\n+// wholesale. All three constraints together preserve real prose: `Mmm MMM mmm`\n+// has no digit and no `;`, `see 1;2;3M for details` contains disqualifying\n+// letters, and `1234;56;78M9;10;11M` has only two terminators.\n+// eslint-disable-next-line no-control-regex\n+const MOUSE_BURST_NOISE_RE = /^(?=[\\s\\S]*\\d)(?=[\\s\\S]*;)(?=(?:[^Mm]*[Mm]){3})[\\d;<\\[\\]IMm \\x1b]+$/\n+\n+// Residual-shard variant for the gaps BETWEEN / AFTER recovered fragments\n+// inside parseTextWithSgrMouseFragments. A real recovery run leaves degraded\n+// remnants (e.g. `M6M`, `7M;220;1MM0M`, lone `;157;47M`) that are pure\n+// mouse-leak alphabet but too short to satisfy the ≥3-terminator whole-text\n+// rule. Swallow such a residue only when it is pure alphabet AND carries a\n+// digit AND at least one `M`/`m` — a prose gap like ` for details ` contains\n+// disqualifying letters and never matches.\n+// eslint-disable-next-line no-control-regex\n+const MOUSE_BURST_RESIDUE_RE = /^(?=[^\\d]*\\d)(?=[^Mm]*[Mm])[\\d;<\\[\\]IMm \\x1b]+$/\n+\n function createPasteKey(content: string): ParsedKey {\n   return {\n     kind: 'key',","@@ -268,6 +296,16 @@ export function parseMultipleKeypresses(\n     } else if (token.type === 'text') {\n       if (inPaste) {\n         pasteBuffer += token.value\n+      } else if (MOUSE_BURST_NOISE_RE.test(token.value)) {\n+        // Fully degraded mouse-burst noise — a heavy render (e.g. a sudo /\n+        // secret prompt repaint) blocked the event loop past App's 50ms flush\n+        // watchdog, so a long burst of SGR mouse reports arrived as text with\n+        // prefixes AND coordinate digits chewed off. Checked BEFORE fragment\n+        // recovery: a noise blob can still contain a few intact `<b;c;r M`\n+        // fragments, and parseTextWithSgrMouseFragments would then return\n+        // non-null and emit a pile of recovered mouse events instead of\n+        // dropping the blob wholesale. Swallow it here so it never leaks into\n+        // the composer (and we skip the extra fragment-recovery work mid-stall).\n       } else {\n         const mouseFragments = parseTextWithSgrMouseFragments(token.value)\n ","@@ -674,7 +712,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {\n     }\n \n     if (first.index! > cursor) {\n-      parsed.push(parseKeypress(text.slice(cursor, first.index!)))\n+      const gap = text.slice(cursor, first.index!)\n+      // Skip pure mouse-leak residue between recovered fragments; only emit\n+      // real text gaps as keypresses.\n+      if (!MOUSE_BURST_RESIDUE_RE.test(gap)) {\n+        parsed.push(parseKeypress(gap))\n+      }\n     }\n \n     for (const match of run) {","@@ -690,7 +733,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {\n   }\n \n   if (cursor < text.length) {\n-    parsed.push(parseKeypress(text.slice(cursor)))\n+    const tail = text.slice(cursor)\n+    // Swallow a pure mouse-leak residue tail (the head fragments recovered, but\n+    // the burst trailed off into chewed-up shards). Emit only real trailing text.\n+    if (!MOUSE_BURST_RESIDUE_RE.test(tail)) {\n+      parsed.push(parseKeypress(tail))\n+    }\n   }\n \n   return parsed\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}