Mouse tracking re-enabled from stale altScreenMouseTracking after terminal handoff — 1003 motion floods the composer and breaks arrow keys

Status Open
Reported on v2.1.220
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Aug 9, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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 the ESC[ prefix eaten and the tail inserted as text. The inconsistent mangling ([C once, bare C otherwise) 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) + …) }
  1. handoffAltScreen() clears altScreenActive but leaves altScreenMouseTracking at "full".
  2. When the alt-screen component later unmounts, setAltScreenActive(false) hits if (this.altScreenActive === e) return; — it is already false — so the tracking mode is never reset to "off". The unmount cleanup itself is correct and does write qpe, so the terminal is briefly consistent.
  3. The next restoreTerminalAfterHandoff() / exitAlternateScreen() / onStdinResume → reassertTerminalModes writes vFe("full"), re-enabling 1000+1002+1003+1006 with nothing mounted to consume the events.
  4. 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

  1. Run Claude Code in a terminal multiplexer pane (observed under herdr 0.8.0 on iTerm2).
  2. Open a full-screen UI that enables mouse tracking and exit it. /plugin is confirmed to be one — see the measurement below.
  3. Trigger a terminal handoff to a child process.
  4. 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[?1000l directly to the pane's tty does not hold — the app re-asserts vFe("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

View original on GitHub ↗

3 Comments

matt-royal · 2 months ago

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 navigation
  • n — did not open the documented notes editor
  • Esc — did not cancel the tool call

Meanwhile the process was S+ at 0.1% CPU: idle and sleeping, not spinning or wedged. Forcing a SIGWINCH (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

enterAlternateScreen()        { this.pause(); …; this.suspendStdin() }
prepareTerminalForHandoff()   { this.pause(); …; this.suspendStdin() }
exitAlternateScreen()         { this.resumeStdin(); … }
restoreTerminalAfterHandoff() { this.resumeStdin(); … }

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-asserts vFe("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:

zsh: command not found: 5M35
zsh: command not found: 4M35
zsh: command not found: 79

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. SIGCONT resumes the process into a background process group; its first tty read raises SIGTTIN and it stops again; the orphaned-process-group rule then delivers SIGHUP and the session dies. I lost the in-flight turn this way. Conversation history survived and claude --resume restored it, but the turn was gone.

Closing the pane and running claude --resume <session-id> remains the only reliable recovery for either failure mode.

bcherny collaborator · 1 month ago

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 /plugin open → Esc → /exit cycle 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;…M text 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

sergeiwallace · 18 days ago

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-altScreenMouseTracking state 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:

^[[<35;6;1M ^[[<35;9;5M ^[[<35;20;20M … ^[[<32;44;108M … ^[[<0;49;60M ^[[<0;49;60m

Same decode as the original report: Cb 35 = 32 (motion) + 3 (no button) is any-event motion, mode 1003; Cb 32 is left-drag; Cb 0 with M/m is press/release. The coordinates trace a real pointer path across the pane: y reaches 108 against a pane height of 108, x reaches 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:

  1. Press Left Arrow at the prompt, which opens the agent/session switcher view.
  2. Re-select the current session from that view.
  3. Claude Code reports that it needs to refresh the TUI.
  4. Move the pointer over the pane.

Observed: motion reports accumulate in the composer indefinitely.

Step 3 is the interesting one: that refresh is presumably the reassertTerminalModes / restoreTerminalAfterHandoff write 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

  • Product and version: Claude Code 2.1.263 (the analysis above was against the 2.1.220 bundle, so this has survived 43 releases)
  • OS and version: Ubuntu 24.04.4 LTS, kernel 6.12.100, x86_64 (container on a managed cloud notebook host)
  • Runtime context: tmux 3.7c pane (client and server both 3.7c), outer terminal is VS Code's integrated terminal, TERM=tmux-256color inside / xterm-256color outside. Resumed session, uptime 1d15h at failure.
  • Model / resolved model ID: N/A, because the defect is in terminal-mode handling, not model behavior
  • Authentication/provider path: enterprise cloud-provider gateway

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

  • Fresh process/session: does not reproduce. Killing the session and relaunching cleared it.
  • Last known good version: unknown, first observation on this machine.
  • Relevant control, and the strongest evidence here: six other Claude Code sessions were running concurrently on the same tmux server, attached to the same outer terminal, and none of them were affected. Reading tmux's per-pane mode flags across all seven at once:

| 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.

  • A size desync between the tmux client and the pane was considered and ruled out by measurement: every pane, healthy and broken alike, is exactly 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-pane dump 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-pane cannot 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 on altScreenActive === e, leaving altScreenMouseTracking at "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, resetting altScreenMouseTracking unconditionally 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).