NousResearch/hermes-agent

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

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35512 fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer

    merged to main

    files: ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts, ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts

    Show 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

    merged to main

    files: ui-tui/src/__tests__/terminalModes.test.ts, ui-tui/src/entry.tsx

    Show 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

  • #28463 fix(tui): recover degraded focus burst input · done elsewhere → fd1e7c2, #28419, #42165
  • #29252 fix(tui): strip ANSI from input buffer after gateway pipe break (#28419) · done elsewhere → #35512, #36864
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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#35512, #42165
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
8 Jun 2026

For your agent

The same facts, typed.