Skip to content

Terminal turns off kitty keyboard mode for a TUI started right after a killed one #9455

Open

Description

@Bruno-rose

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

  1. Run a TUI that arms kitty keyboard mode and kill it while it prints a lot of output.
  2. Start another TUI that arms kitty mode at once.
  3. 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 with queueMicrotask.

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

  1. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    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 calls reclaimer.noteShellReady() and then schedules collectDisarm() with queueMicrotask.
    • packages/shared/src/leaked-input-mode-reclaim.ts:123-154: the decision logic. noteShellReady() marks kitty as pending. Only a later noteArm("kitty", true) clears pending, and collectDisarm() returns the disarm string for anything still pending.
    • Callers: apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts:175 (v1) and apps/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:

    1. noteShellReady() sets kitty pending = true.
    2. The microtask runs. The arm hasn't been parsed, so pending is still true and collectDisarm() returns the kitty disarm.
    3. terminal.write(disarm) appends the disarm after the queued arm bytes.
    4. xterm parses the arm, then the disarm, and the new TUI loses kitty mode.

    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:

    1. Run a TUI that arms kitty mode and prints a lot of output, then kill it while it is printing.
    2. Start another kitty-arming TUI at once (e.g. Claude Code), so its arm lands in the same burst as the reprompt.
    3. Shift+Enter no longer works in the new TUI.

    bun:test sketch: extend the fake terminal in terminalInputModeReclaimer.test.ts:78 so write(data, cb?) queues data and callbacks in order, and add a drain() that runs the parse in steps:

    installInputModeReclaimer(t.terminal);
    t.marker();            // first prompt
    t.csi(">u", [7]);      // old TUI arms
    t.marker();            // reprompt after kill (end of parse slice)
    await flush();         // microtask runs here: decision is made too early
    t.csi(">u", [7]);      // new TUI's arm, parsed in the next slice
    // expect: no KITTY_KEYBOARD_DISARM_SEQUENCE after the second arm

    On current main this fails, because the disarm is decided and written before the second arm is seen.

    Fix direction

    The reporter's suggestion matches the code: replace queueMicrotask with terminal.write("", () => { ... }). xterm runs a write callback only after all data queued before it has been parsed, so collectDisarm() would see any re-arm that arrived before the disarm was scheduled. The scheduled and disposed guards can stay as they are. Two things to check:

    • The fake terminal's write must accept and call the callback, and the existing tests at lines 108-126 need updating to match.
    • A smaller race remains for bytes that arrive after the empty write's callback. That is the normal "a TUI arms after the shell is idle" case, and the reclaimer can't tell it apart without more state. It's worth noting in the PR, but it shouldn't block this fix.

    This is a different cause from the earlier Shift+Enter reports (#2970, #4008), so it shouldn't be closed as a duplicate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions