[BUG] Scroll-wheel SGR mouse report split across stdin reads leaks into prompt input (50ms incomplete-escape flush timer force-emits the partial)

Open 💬 1 comment Opened Jul 12, 2026 by KenMalloy

Summary

While scrolling the conversation with a mouse wheel / two-finger trackpad, fragments of the
terminal's SGR mouse reports get inserted into the prompt as literal text (e.g.
<65;92;34M5;92;34M;34M). It's intermittent, and it fires most often right after a large block of
output has been rendered.

Crucially, this is not the "mouse tracking left on in an environment that never consumes it"
family (#66289, #72269) or a tracking-mode cleanup problem. It reproduces in a terminal that
consumes mouse reports correctly (Apple Terminal.app) where scrolling normally works — the leak is
a timing race in the stdin key parser, not a capability/cleanup problem. CLAUDE_CODE_DISABLE_MOUSE=1
"fixes" it only by turning the feature off.

Environment

  • Claude Code 2.1.207 (native install)
  • macOS 15 (Darwin 25.5.0, arm64)
  • Apple Terminal.app, TERM=xterm-256color
  • Mouse reporting is enabled by Claude Code: CSI ? 1000 h, CSI ? 1006 h (SGR), CSI ? 1004 h (focus)

Root cause (traced in the 2.1.207 bundle)

SGR mouse mode means each wheel tick arrives as ESC [ < 65 ; col ; row M (button 65 = wheel-down).
The tokenizer buffers an incomplete escape sequence across stdin reads correctly — I verified
this by extracting the tokenizer verbatim and fuzzing it (split at every byte boundary, 1 byte at a
time: zero leaks). The bug is one level up, in the incomplete-escape flush timer on the input
component:

// InternalApp
keyParseState = r0c;          // r0c.incomplete = ""
NORMAL_TIMEOUT = 50;          // ms
flushIncomplete = () => {
  ...
  if (this.keyParseState.incomplete) {
    let t = NORMAL_TIMEOUT - (performance.now() - this.lastStdinTime);
    if (t > 0) { setTimeout(this.flushIncomplete, t); return }
  }
  this.processInput(null);    // null -> tokenizer.flush()
};

The 50 ms timeout exists to disambiguate a bare Esc keypress from the start of an escape
sequence. But it is applied to every incomplete sequence, including a mouse report that was
merely split across two stdin reads.

Sequence of events:

  1. A wheel report ESC [ < 65 ; 92 ; 34 M is split at a stdin read boundary — read #1 ends at

ESC [ < 6. The tokenizer correctly buffers it (incomplete = "\e[<6").

  1. The remainder 5;92;34M does not arrive within 50 ms — the event loop is busy rendering the

large prior output, or reports are arriving faster than they're drained.

  1. incompleteEscapeTimer fires → processInput(null)tokenizer.flush(). In the flush path the

tokenizer force-emits the buffered partial ESC [ < 6 as if it were a complete sequence token.

  1. The key parser runs it through its xLr() fallback; ESC [ < 6 matches no key/mouse pattern, so

it becomes key{ name:"", sequence:"\e[<6" } and is inserted into the input as literal text.

  1. Read #2 finally delivers 5;92;34M. The tokenizer is now back in ground, so 5;92;34M is

plain printable text → a text token → also inserted into the input.

Net result: <65;92;34M…-shaped garbage in the prompt. The leading <6 of each report is eaten by
the flush; the tails leak — exactly the fragmentation users report.

A complete SGR wheel report is handled fine (it becomes a wheelup/wheeldown key). The leak is
exclusively the split-then-flushed partial, which is why it's intermittent and load-correlated.

I suspect this same "flush force-emits a partial, tail leaks as text" mechanism underlies the
partial-truecolor-sequence leak in #76083 (aN;NaNm tails).

Reproduction

Manual (intermittent):

  1. macOS + Apple Terminal.app + Claude Code 2.1.207.
  2. Produce a large output so the conversation re-renders heavily, e.g. !perl -e "print 'abc' x 100000".
  3. Immediately scroll rapidly up through the conversation.
  4. Intermittently, <65;…M-shaped fragments appear in the prompt input line.

Mechanism proof (deterministic, no live session — I ran this):
Running the tokenizer extracted verbatim from the 2.1.207 binary:

feed("\e[<6")      -> (no tokens)         incomplete buffered = "\e[<6"
flush()            -> sequence:"\e[<6"    -> xLr() -> key{name:""} inserted as text   [leak #1]
feed("5;92;34M")   -> text:"5;92;34M"     -> inserted as text                          [leak #2]

The control (same tokenizer, no flush between the two feeds) reassembles \e[<65;92;34M cleanly —
confirming the flush is the sole cause.

Deterministic end-to-end recipe (forces the 50 ms race against the live binary):
Drive claude in a PTY and write the report in two halves with a >50 ms gap:

write(pty, "\e[<65;100;20")   # partial, no final 'M'
sleep(0.12)                    # > NORMAL_TIMEOUT (50 ms) -> flushIncomplete fires
write(pty, "M")                # remainder arrives after the flush
# the input line now contains "100;20" as text

Suggested fix

Don't treat a partial multi-byte escape sequence like a bare Esc in the flush timer. When
incomplete is an in-progress CSI/SS3/OSC/DCS sequence (starts with ESC [, ESC O, ESC ],
ESC P) rather than a lone ESC:

  1. Keep buffering — cross-feed reassembly already works (verified); the continuation completes

the report. Add a longer hard cap (a few hundred ms, or a max buffer length) after which the
partial is discarded, never echoed.

  1. Only the bare-ESC path should synthesize an Escape key; for a partial CSI, drop it.
  2. Defense in depth: the key parser's fallback should never insert an unrecognized ESC-prefixed

fragment into the input as literal text.

Lengthening NORMAL_TIMEOUT only for mouse would reduce the rate but not close the race.

Related (distinct root cause)

  • #66289, #72269 — mouse tracking firing / not cleaned up in environments that never consume reports (different: capability/cleanup, not a parser race).
  • #76083 — partial-sequence tail leak (aN;NaNm); likely the same flush-force-emit mechanism, different sequence/renderer.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗