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 None cached
Activity 2 comments · opened Aug 9, 2026

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 ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗