Mouse tracking re-enabled from stale altScreenMouseTracking after terminal handoff — 1003 motion floods the composer and breaks arrow keys
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
After Claude Code hands the terminal to a child process, altScreenMouseTracking can be left at "full" while no alt-screen component is mounted. Subsequent mode re-assertions then re-enable any-event mouse tracking (DECSET 1003) with nothing consuming the events.
Every pointer movement over the terminal then emits an SGR report that lands in the composer as literal text. The sustained input rate also severs ordinary key sequences across read boundaries, so arrow keys stop working. A session sitting at an AskUserQuestion prompt becomes completely unusable — neither arrows nor clicks select an option — and has to be killed and --resumed.
The composer of the affected session contained:
BAADD[CC<35;20;18M<35;21;15M…<0;35;41M<0;35;41m…
B A A D D [C C— arrow keys (ESC[B/ESC[A/ESC[D/ESC[C) with theESC[prefix eaten and the tail inserted as text. The inconsistent mangling ([Conce, bareCotherwise) is the signature of read-boundary severing rather than fixed-length framing.<35;…M— SGR reports, button code 35 = 32 (motion bit) + 3 (no button) = any-event motion tracking, i.e. mode 1003.
Root cause
From the 2.1.220 bundle:
ew = { …, MOUSE_NORMAL:1000, MOUSE_BUTTON:1002, MOUSE_ANY:1003, MOUSE_SGR:1006, … }
ec_ = mY(1000)+mY(1002)+mY(1003)+mY(1006) // "full" (enable bundle)
qpe = KSe(1006)+KSe(1003)+KSe(1002)+KSe(1000) // (disable bundle)
vFe(e){ case "full": return ec_; case "scroll": return tc_; case "off": return "" }
handoffAltScreen() { this.isPaused = true; this.altScreenActive = false }
setAltScreenActive(e, t="off"){ if (this.altScreenActive === e) return;
this.altScreenActive = e;
this.altScreenMouseTracking = e ? t : "off"; … }
restoreTerminalAfterHandoff() { this.resumeStdin();
write(vFe(this.altScreenMouseTracking) + SXr); this.resume() }
exitAlternateScreen() { … write(… + vFe(this.altScreenMouseTracking) + …) }
handoffAltScreen()clearsaltScreenActivebut leavesaltScreenMouseTrackingat"full".- When the alt-screen component later unmounts,
setAltScreenActive(false)hitsif (this.altScreenActive === e) return;— it is alreadyfalse— so the tracking mode is never reset to"off". The unmount cleanup itself is correct and does writeqpe, so the terminal is briefly consistent. - The next
restoreTerminalAfterHandoff()/exitAlternateScreen()/onStdinResume → reassertTerminalModeswritesvFe("full"), re-enabling 1000+1002+1003+1006 with nothing mounted to consume the events. - Mode 1003 emits on every pointer movement, so the composer fills with report text and genuine key sequences get severed.
What Should Happen?
Mouse tracking should not be re-enabled when no component is consuming it.
Suggested fix: reset altScreenMouseTracking in handoffAltScreen(), or clear it unconditionally during teardown rather than behind the altScreenActive === e early return.
Steps to Reproduce
- Run Claude Code in a terminal multiplexer pane (observed under
herdr0.8.0 on iTerm2). - Open a full-screen UI that enables mouse tracking and exit it.
/pluginis confirmed to be one — see the measurement below. - Trigger a terminal handoff to a child process.
- Move the mouse over the pane.
Expected: nothing. Actual: <35;…M motion reports accumulate in the composer, and arrow keys begin inserting bare letters.
Confirming /plugin uses mode full. Running script -q log claude, opening /plugin, pressing Esc, then /exit, and counting DEC private mode sequences in Claude Code's own output:
mouse-mode sequences, in order:
1000h 1002h 1003h 1006h × 5 (the "full" enable bundle, ec_)
1006l 1003l 1002l 1000l × 3 (the disable bundle, qpe)
Two things follow. First, /plugin does enable 1003 any-event motion tracking, so it is a valid trigger for the state above. Second, the enable bundle is written 5 times against only 3 disables in a single open → Esc → exit cycle — the pairing is not 1:1, which is consistent with the re-assert path in the root cause. This particular clean run happened to end on a disable, so the terminal was not left poisoned; the poisoning case additionally requires the handoff ordering.
I have not reduced the full failure to a deterministic minimal repro — the handoff ordering is what matters and I observed it only after the fact — but the state machine above accounts for every byte observed, and the enable/disable imbalance is reproducible on demand.
Relationship to existing issues
- #78855 (input parser types the tail of SGR mouse reports split across starved reads) is the downstream half of this. That report needs host load or a synthetic 200-report burst to fire, and its author notes they could not reproduce at human wheel rates (~10–30 reports/s). Leftover 1003 motion tracking supplies exactly that rate from ordinary pointer movement, which makes the parser fragility fire constantly rather than intermittently.
- #77752 (mouse tracking not disabled on Ctrl-Z suspend) and #72648 (modes left enabled after session exit) are the same pause/teardown family, but they leak outward to the shell. This one re-enables tracking inward and poisons Claude Code's own input.
- #78306 (AskUserQuestion unresponsive to mouse and keyboard input) looked like a duplicate from its title, but on reading it appears unrelated — it reports a picker that accepts no input, with no escape-sequence text in the composer, in a two-column preview layout. That looks closer to #81755.
Ruled out: not a multiplexer bug
A raw-stdin dumper requesting exactly ESC[?1000h + ESC[?1006h was run in a fresh multiplexer pane while the pointer was moved, scrolled and clicked over it:
| Test | Pane focused | Reports | Codes seen | Motion (32+) |
|------|--------------|---------|------------|--------------|
| 1 | no | 318 | wheel-up 198, wheel-down 80, click 40 | 0 |
| 2 | yes | 464 | wheel-up 298, wheel-down 154, click 12 | 0 |
The multiplexer delivers exactly the mode set the pane requests, focused or not, and never synthesizes motion events.
Workarounds
CLAUDE_CODE_DISABLE_MOUSE_CLICKS=1→ selects"scroll"(1000+1006, no motion): keeps wheel scrolling and removes the flood.CLAUDE_CODE_DISABLE_MOUSE=1→ selects"off": no mouse modes emitted at all.- Writing
ESC[?1003l ESC[?1002l ESC[?1000ldirectly to the pane's tty does not hold — the app re-assertsvFe("full")within minutes. Verified: motion reports in the composer went from 26 to 42 after the reset, with new wheel reports appearing. - Reliable recovery once a pane is poisoned is to close it and
claude --resume.
Claude Code Version
2.1.220 (Claude Code), Homebrew cask
Platform
Claude subscription
Operating System
macOS (Darwin 25.6.0)
Terminal/Shell
iTerm2 + zsh, inside the herdr 0.8.0 terminal multiplexer
3 Comments
A second failure mode from the same handoff state machine, observed on the same setup (2.1.220, macOS, iTerm2, multiplexer pane) the day after the original report. Different leaked resource: stdin left suspended rather than mouse modes left enabled.
Symptom
An AskUserQuestion dialog in the two-column preview layout rendered correctly but accepted no input at all. Not a mouse problem this time — the composer had zero mouse-report text in it.
Evidence that input, not rendering, is dead
I injected keys directly into the pane's PTY, bypassing the terminal and any multiplexer input path. Every one was ignored, with the selection marker never moving off option 1:
Down,Ctrl+N,Tab— no navigationn— did not open the documented notes editorEsc— did not cancel the tool callMeanwhile the process was
S+at 0.1% CPU: idle and sleeping, not spinning or wedged. Forcing aSIGWINCH(resizing the pane) re-rendered the UI correctly but did not restore input. So the render loop is alive and the stdin path is not.Suspected cause
Both suspend paths pair with exactly one resume path each. If a handoff or alt-screen entry suspends stdin and the matching restore never runs — the same asymmetry as the mouse-mode case in the original report — the app renders normally and silently ignores all input.
This looks like a plausible mechanism for #78306 ("rendered but accepted no input via mouse or keyboard; had to reject the tool call to proceed"), which I had previously ruled out as unrelated to the mouse-mode bug. It is unrelated to that, but it appears to share this issue's root state machine.
Two corrections to the original report
1. The tty-level mouse reset does hold — if the process is stopped. The original report says writing
ESC[?1003l …to the pane's tty never works. More precisely: it fails while Claude Code is running, because it re-assertsvFe("full"). With the process stopped (SIGTSTP), nothing re-asserts and the reset sticks.2. Mouse tracking was leaked here too, invisibly. The composer looked clean only because the app was deaf — the reports were landing in its ignored stdin. The moment the process stopped, they fell through to the shell, which tried to execute them:
That is #72648's symptom, reached from a running session rather than an exited one. So both leaks can be present on the same pane simultaneously, with the stdin one masking the mouse one.
Warning for anyone trying to recover in place
Do not try
SIGTSTP+SIGCONT.SIGCONTresumes the process into a background process group; its first tty read raisesSIGTTINand it stops again; the orphaned-process-group rule then deliversSIGHUPand the session dies. I lost the in-flight turn this way. Conversation history survived andclaude --resumerestored it, but the turn was gone.Closing the pane and running
claude --resume <session-id>remains the only reliable recovery for either failure mode.Thanks for the unusually thorough analysis — the enable/disable imbalance you measured reproduces exactly on the current release.
On 2.1.233 (Linux, xterm-256color), capturing Claude Code's own output with
script -q, a single/pluginopen → Esc →/exitcycle wrote the mouse-tracking enable bundle (?1000h ?1002h ?1003h ?1006h) 5 times against 3 disable bundles — the same 5:3 ratio you counted on 2.1.220. The repeated enables arrive with no paired disables, consistent with the re-assertion path you describe. Like you, we couldn't make the full handoff-ordering failure (tracking left on with nothing consuming it) fire deterministically, but the measurable half of the report stands.One related note: a recent release (2.1.239) fixed mouse reports that arrive split across reads being typed into the prompt as literal text, which should reduce the worst downstream symptom (the
<35;…Mtext and mangled arrow keys). The unbalanced re-enable behavior reported here is upstream of that fix and still present, so we're keeping this open and marking it reproduced.🤖 Generated with Claude Code
Independent corroboration of this on a different OS, terminal stack, and multiplexer, still present in 2.1.263, and with a trigger that is much easier to hit by accident than
/plugin.Summary
Bug: the same stale-
altScreenMouseTrackingstate described above is reachable through the agent/session switcher view, not just a full-screen slash command. A user entered it by pressing Left Arrow by accident, re-selected their session, was told the TUI needed to refresh, and from that moment every pointer movement over the pane typed SGR motion reports into the composer. The session had to be killed.Actual behavior
The composer filled continuously with, verbatim:
Same decode as the original report:
Cb35 = 32 (motion) + 3 (no button) is any-event motion, mode 1003;Cb32 is left-drag;Cb0 withM/mis press/release. The coordinates trace a real pointer path across the pane:yreaches 108 against a pane height of 108,xreaches 133 against a width of 167, so the reports are well-formed and correctly addressed; nothing is consuming them.Clearing the line does not help: the next pointer movement refills it. The user could not tell whether a prompt was even submittable.
Minimal reproduction
Not reduced to a deterministic repro, same caveat as the original report. The user's account, given unprompted and with the note that they never normally use this key:
Observed: motion reports accumulate in the composer indefinitely.
Step 3 is the interesting one: that refresh is presumably the
reassertTerminalModes/restoreTerminalAfterHandoffwrite named in the root-cause analysis above, and here it is reached without any child-process handoff and without a full-screen slash command. If that holds, the exposed surface is wider than/plugin: an accidental arrow key is enough.Environment
TERM=tmux-256colorinside /xterm-256coloroutside. Resumed session, uptime 1d15h at failure.Worth noting the original report was macOS + iTerm2 +
herdr. This one is Linux + VS Code + tmux. The defect is not terminal-specific.Regression and controls
| Pane |
mouse_any_flag|mouse_sgr_flag| State ||---|---|---|---|
| affected | 1 | 1 | broken |
| other 6 | 1 | 1 | fine |
Every pane had requested 1003 + 1006. So "mouse tracking got enabled" cannot be the fault; it is the normal state for all of them. Only the affected session had stopped consuming what it had asked for, which is exactly the one-sided desync the root cause above predicts.
client_height - 1. The broken pane's unusual 108 rows was just a tall window, and a healthy session had the same 108.Diagnostics
A character-class census over a
tmux capture-panedump of the affected pane found zero C0 controls, zero C1 controls, and zero replacement characters, and every non-ASCII byte was ordinary box drawing. The stream is not corrupted; it is well-formed input delivered to the wrong consumer.One instrument note that may help others reproduce and capture this:
tmux capture-panecannot see tmux's own overlays. A screenshot of the affected pane additionally showed tmux status/chooser rows rendered mid-screen and repeated ~30 times, none of which appears in any pane capture, plain or-e. If you are collecting evidence for this bug, take a screenshot as well as a capture, because a clean capture is not evidence of a clean screen.Hypothesis
Reporter analysis, kept separate from the observations above: the agent/session-switcher view is an alt-screen component, so selecting a session from it plausibly runs the same
setAltScreenActive(false)path that returns early onaltScreenActive === e, leavingaltScreenMouseTrackingat"full"; the "needs to refresh the TUI" step is then the mode re-assertion that writes the enable bundle with nothing mounted. If that is right, the suggested fix already in this issue, resettingaltScreenMouseTrackingunconditionally during teardown rather than behind the early return, covers this trigger too, and no separate fix is needed.Two things not established: whether the repeated tmux overlay is a second symptom of the same refresh or an independent event, and whether the switcher view is genuinely alt-screen-mounted (that is read off behavior, not the bundle).