Description
Where did this happen?
Desktop app (macOS)
Version
Source build, main at 19db630
What happened?
The leaked-input-mode reclaimer decides what to disarm on a microtask, before xterm has parsed the output queued behind the shell-ready marker. If a new TUI arms kitty keyboard mode in that queued output, the disarm is applied after the arm. The new TUI then loses Shift+Enter and other modified keys (for example in Claude Code).
Expected: a mode armed after the marker stays armed.
Steps to reproduce
- Run a TUI that arms kitty keyboard mode and kill it while it prints a lot of output.
- Start another TUI that arms kitty mode at once.
- See Shift+Enter stop working in the new TUI.
Logs, screenshots, or recordings
Found by model checking every order of events, then reproduced as a failing test against the real code. A fix with that regression test is ready.
Implementation notes
Files
apps/desktop/src/renderer/lib/terminal/terminalInputModeReclaimer.ts: the disarm is scheduled withqueueMicrotask.
Approach
Schedule it with terminal.write("", callback) so the decision happens in parser order.
Gotchas
The v1 path writes every event directly and is the most exposed. In v2 the write coalescer usually keeps one batch queued.
Related
Same symptom family as #2970 and #4008 (Shift+Enter), but a different cause.
Activity
github-actions commentedon Nov 7, 2026
I've read the reclaimer, its tests, and the shared decision module. The report holds up, so here is the triage comment.
Summary
When a TUI that arms the kitty keyboard protocol is killed, the renderer-side reclaimer waits for the shell-ready marker (OSC 777) and then writes a disarm sequence. The bug: it decides what to disarm in a microtask, but xterm may not have parsed the rest of the output yet. If a new TUI arms kitty mode in bytes already queued behind the marker, the reclaimer misses that arm and still sends the disarm. The disarm bytes go to the end of xterm's write queue, behind the new arm, so they switch off the new TUI's kitty mode. Shift+Enter and other modified keys then stop working (for example in Claude Code). From reading the code, this looks real.
Affected code
apps/desktop/src/renderer/lib/terminal/terminalInputModeReclaimer.ts:87-101: the OSC 777 handler callsreclaimer.noteShellReady()and then schedulescollectDisarm()withqueueMicrotask.packages/shared/src/leaked-input-mode-reclaim.ts:123-154: the decision logic.noteShellReady()marks kitty aspending. Only a laternoteArm("kitty", true)clearspending, andcollectDisarm()returns the disarm string for anything still pending.apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts:175(v1) andapps/desktop/src/renderer/lib/terminal/terminal-runtime.ts:116(v2).Suspected root cause
xterm parses its write buffer in time-limited slices and continues on a later macrotask when a slice runs over its budget. A microtask queued from inside a parser handler therefore runs when the current slice ends, not when the queue is drained. If that slice ends between the marker and the new TUI's
CSI > flags u:noteShellReady()sets kittypending = true.pendingis still true andcollectDisarm()returns the kitty disarm.terminal.write(disarm)appends the disarm after the queued arm bytes.The docblock (lines 22-24) says the microtask is there so "a new TUI racing the prompt keeps its modes." That only works when the marker and the arm are parsed in the same slice. As the reporter says, v1 is the most exposed because each PTY event becomes its own
terminal.write. The v2 write coalescer usually keeps one batch queued, which makes the gap smaller but doesn't close it.Reproduction
Manual:
bun:testsketch: extend the fake terminal interminalInputModeReclaimer.test.ts:78sowrite(data, cb?)queues data and callbacks in order, and add adrain()that runs the parse in steps:On current
mainthis fails, because the disarm is decided and written before the second arm is seen.Fix direction
The reporter's suggestion matches the code: replace
queueMicrotaskwithterminal.write("", () => { ... }). xterm runs a write callback only after all data queued before it has been parsed, socollectDisarm()would see any re-arm that arrived before the disarm was scheduled. Thescheduledanddisposedguards can stay as they are. Two things to check:writemust accept and call the callback, and the existing tests at lines 108-126 need updating to match.This is a different cause from the earlier Shift+Enter reports (#2970, #4008), so it shouldn't be closed as a duplicate.