# NousResearch/hermes-agent · TUI input corruption after gateway pipe break

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #35512. Verify it addresses your case.
- **Recommended:** read PR #35512 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-42165`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #28419: \[Bug\]: TUI input box gets flooded with ANSI escape sequences after gateway pipe break

## Prior & competing PRs

- **#35512: fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts`, `ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts`
  - fix hunks:
  - `ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts`

    ```diff
    @@ -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`

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `ui-tui/src/__tests__/terminalModes.test.ts`, `ui-tui/src/entry.tsx`
  - fix hunks:
  - `ui-tui/src/__tests__/terminalModes.test.ts`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```
- **#28463: fix(tui): recover degraded focus burst input**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `fd1e7c2bc356d773589320051853f8e058ca636e`, `#28419`, `#42165`
- **#29252: fix(tui): strip ANSI from input buffer after gateway pipe break (#28419)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#35512`, `#36864`

## Fix status

- Merged to main in PR #35512 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-08`.
- Subsystem: `gateway`
