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

Status Closed — not planned
Reported on v2.1.207
Maintainer reply ✓ Yes — bcherny
Activity 5 comments · opened Jul 12, 2026 · closed Sep 15, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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 ↗

5 Comments

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/58653
  2. https://github.com/anthropics/claude-code/issues/66289
  3. https://github.com/anthropics/claude-code/issues/50032

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

bcherny collaborator · 2 months ago

Confirmed / reproduced on 2.1.233 (macOS). Thanks for the very thorough analysis — it matches what we see.

What I tried: ran Claude Code 2.1.233 in a terminal and fed it an SGR wheel report split into two writes with a ~150 ms gap between them (ESC [ < 6 then 5;92;34M). The prompt input ended up containing 5;92;34M as literal text. Splitting after ESC [ < 65;100;20 and sending M later left a stray M in the prompt. Sending the same bytes in one write, or split with a ~20 ms gap, was handled correctly (a scroll event, nothing leaked).

So the incomplete-escape flush does force-emit a partial mouse report after the timeout and the remainder leaks as text. This isn't a regression in a specific version as far as we can tell — the timing window has been there for a while — but it's a real bug and we'll look at keeping partial CSI sequences buffered (and discarding them if they never complete) instead of flushing them into the input.

🤖 Generated with Claude Code

github-actions[bot] · 1 month ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

Shinigami92 · 1 month ago

Are you serious?! Why does this get closed automatically? It is not solved at all 👎

marcindulak · 1 month ago

This issue was incorrectly closed by the github-actions bot, despite having reproduction steps and Claude version. This bot behavior is tracked in https://github.com/anthropics/claude-code/issues/87647. Consider upvoting that issue for visibilty, maybe the bot behavior gets fixed.