[BUG] Scroll-wheel SGR mouse report split across stdin reads leaks into prompt input (50ms incomplete-escape flush timer force-emits the partial)
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:
- A wheel report
ESC [ < 65 ; 92 ; 34 Mis split at a stdin read boundary — read #1 ends at
ESC [ < 6. The tokenizer correctly buffers it (incomplete = "\e[<6").
- The remainder
5;92;34Mdoes not arrive within 50 ms — the event loop is busy rendering the
large prior output, or reports are arriving faster than they're drained.
incompleteEscapeTimerfires →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.
- The key parser runs it through its
xLr()fallback;ESC [ < 6matches no key/mouse pattern, so
it becomes key{ name:"", sequence:"\e[<6" } and is inserted into the input as literal text.
- Read #2 finally delivers
5;92;34M. The tokenizer is now back inground, so5;92;34Mis
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):
- macOS + Apple Terminal.app + Claude Code 2.1.207.
- Produce a large output so the conversation re-renders heavily, e.g.
!perl -e "print 'abc' x 100000". - Immediately scroll rapidly up through the conversation.
- 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. Whenincomplete is an in-progress CSI/SS3/OSC/DCS sequence (starts with ESC [, ESC O, ESC ],ESC P) rather than a lone ESC:
- 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.
- Only the bare-
ESCpath should synthesize an Escape key; for a partial CSI, drop it. - 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.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗