Skip to content

fix: mirror keyboard protocol state onto the real terminal and decode encoded C-a everywhere#53

Merged
kylecarbs merged 3 commits into
mainfrom
fix/ui-kitty-keyboard-mirror
Jun 13, 2026
Merged

fix: mirror keyboard protocol state onto the real terminal and decode encoded C-a everywhere#53
kylecarbs merged 3 commits into
mainfrom
fix/ui-kitty-keyboard-mirror

Conversation

@kylecarbs

@kylecarbs kylecarbs commented Jun 12, 2026

Copy link
Copy Markdown
Member

Problem

In boo ui, with a kitty-protocol application focused (Claude Code and friends):

  • Shift+Enter does not insert a newline. The terminal sends a plain \r, indistinguishable from Enter, so the app submits instead.
  • Esc only registers on the second press. The app receives a bare 0x1b and, running in kitty mode, waits for the rest of a sequence that never comes; the next Esc flushes the first.

Both work in boo at. Two adjacent holes surfaced during review and are fixed here too:

  • boo at's C-a prefix silently breaks under modifyOtherKeys. The attach repaint has always replayed an app's CSI > 4;2m (vim-class apps) onto the client's real terminal, but keys.zig never decoded the resulting CSI 27;5;97~ encoding of Ctrl+A that xterm-faithful terminals then send.
  • C-a does not work on non-Latin keyboard layouts under kitty. A Cyrillic layout reports Ctrl+ф (CSI 1092:1060:97;5u); the parsers ignored the base-layout-key subfield.

Root cause (boo ui)

boo ui composites sessions through a local ghostty-vt terminal. When the focused app enables a keyboard protocol, the enable sequence is consumed by that local terminal and never reaches the user's real terminal, which keeps sending legacy encodings. boo attach does not have this problem because the daemon repaint replays keyboard state onto the client's real terminal (window.zig repaint with formatter.extra.keyboard).

Fix

  • Mirror keyboard state: Ui.syncKeyboard writes the focused view's kitty flags (CSI = flags ; 1 u) and modifyOtherKeys state (CSI > 4 ; 2/0 m) to the real terminal whenever they change, matching what an attach repaint replays. Both are suspended while a UI prompt or key-driven mode reads byte-oriented input, restored after, and re-synced immediately after input handling.
  • Parse encoded input: a shared keys.Protocols struct ({kitty, modify}) gates decoding in both parsers (keys.Parser for the daemon, InputParser for the UI):
    • kitty CSI-u: CSI 97;5u arms the prefix (releases, repeats, modifier keys handled per parser; in the UI a second discrete press dispatches the C-a C-a focus-last binding like a second raw 0x01), the command key decodes, everything else (Shift+Enter CSI 13;2u) passes through verbatim.
    • modifyOtherKeys: new keys.parseModify decodes CSI 27;mods;cp~ for the prefix and command keys; the protocol has no event types, so a repeated prefix stays armed in boo at and dispatches C-a C-a in boo ui, matching each parser's raw-byte semantics. Paste markers and F-keys share the ~ final but never the 27 marker.
    • non-Latin layouts: parseKitty now reads the cp:shifted:base subfields and keys.effectiveCp matches the prefix and command keys through the base-layout key when the primary codepoint is non-ASCII (Ctrl+ф = C-a on Russian layouts). ASCII primaries always win, so AZERTY-style Latin layouts keep their legacy typed-character semantics.
  • Canonical Esc event (UI): the Esc key (kitty CSI 27u press, or a lone 0x1b after the 50ms flush) is a .esc event carrying the original bytes; it drives the existing cancel behaviors and otherwise forwards exactly as encoded. modifyOtherKeys never encodes an unmodified Esc, so no extra handling is needed there.
  • Quit drain (UI): quitting restores the terminal through client.restoreTty (shared with plain attach), draining held-key repeats so a still-held C-a C-d cannot EOF the shell underneath.
  • The UI hold grammar is active regardless of protocol state (decoding is not), so sequences in flight across a mirror change replay whole, and legacy F-keys no longer leak 15~ into prompts as text.

Legacy terminals are unaffected: without mirrored state the parsers decode nothing new, and terminals without protocol support ignore the mirror sequences.

Testing

  • 33 parser unit tests across keys.zig and ui.zig: kitty prefix arming/release/repeat, double C-a press, encoded command keys, Esc press/release/modified, split-Esc disarm, modify prefix/command/repeat/passthrough/off/split, dual-protocol decode, Cyrillic prefix+command, AZERTY non-match, paste protection, mid-hold mirror flips, single-chunk F-key replay.
  • 7 PTY integration tests: kitty flags and modifyOtherKeys mirror on/off in boo ui, Shift+Enter and Esc verbatim passthrough, kitty- and modify-encoded C-a intercepted without leaking (both boo ui and boo at), prompts suspend and restore both mirrors, boo at reattach repaint replays >4;2m.
  • zig build test-all (113 unit + 66 integration) passes, integration re-run 3x for flake checking; zig fmt --check clean.
Decision log
  • Mirroring over translation: translating legacy input to protocol encodings UI-side cannot work; the terminal never encodes Shift distinctly for Enter in legacy mode, so the information is lost before boo ui sees a byte. Mirroring reproduces exactly the contract boo at provides.
  • Suspending mirrors during prompts instead of teaching every prompt the encodings: under kitty report-all-keys even plain printable keys arrive encoded, which would break rename/goto/confirm-kill byte handling.
  • modifyOtherKeys mirroring was removed mid-review, then restored once both parsers decode CSI 27;mods;cp~: mirroring it without the decoder would have broken the C-a prefix whenever vim-class apps were focused. ghostty itself keeps Ctrl+letter legacy even in mode 2, but xterm encodes everything modified, so the decoder handles both worlds.
  • Base-layout matching is gated on a non-ASCII primary: kitty reports cp:shifted:base, and matching base unconditionally would turn AZERTY's Ctrl+Q (cp q, base a) into the prefix — surprising, and different from its legacy 0x11 byte. Non-Latin layouts (the actual target) always have non-ASCII primaries.
  • .esc carries the original bytes so an unconsumed Esc forwards in whatever encoding the terminal used.
  • Consumed-press releases pass through by design: kitty apps must tolerate unmatched release events, so no swallow state is kept.
  • No version bump: releases are separate chore: release PRs in this repo.

This PR was generated by Coder Agents on behalf of @kylecarbs.

boo ui composites sessions through a local ghostty-vt terminal, so an
application's kitty keyboard enable never reached the user's real
terminal (unlike boo attach, whose repaint replays it). The terminal
kept legacy encodings: Shift+Enter was indistinguishable from Enter,
and a kitty-mode application sat on a bare Esc waiting for the rest
of a sequence that never came, so Esc only registered on the second
press.

The UI now mirrors the focused view's kitty flags (and
modifyOtherKeys) onto the real terminal, the way an attach repaint
does, and drops them while a UI prompt or key-driven mode reads
byte-oriented input. The input parser learns the CSI-u encodings the
mirrored terminal then produces, reusing the keys.zig kitty helpers:
Ctrl+A arms the prefix, the command key after it decodes, the Esc key
becomes a canonical esc event that drives the existing cancel
behaviors (prompts, browse, resize, scroll snap-back) and otherwise
forwards verbatim, and every other CSI-u key passes through to the
session untouched.
…n quit

Review follow-ups:

- A second discrete kitty-encoded C-a press now dispatches the
  C-a C-a focus-last binding instead of staying armed; only key
  repeats (event 2) and releases keep the prefix armed, matching the
  raw-byte path.
- modifyOtherKeys is no longer mirrored onto the real terminal: under
  it the terminal encodes C-a as CSI 27;5;97~, which the UI parser
  does not decode, so the prefix would stop working whenever vim and
  friends were focused. CSI-u decode support for that encoding is a
  possible follow-up.
- The CSI hold grammar no longer depends on the kitty state: a CSI-u
  key split across reads while the mirror flips (prompt opening)
  replays whole instead of being mangled, and legacy F-keys
  (ESC [ 15 ~) replay as one chunk instead of leaking "15~" into
  prompts. Decoding still only happens while kitty is active.
- The keyboard mirror re-syncs right after input handling, shrinking
  the window where a just-opened prompt still receives kitty-encoded
  keys.
- Quitting the UI drains pending terminal input via the same restore
  helper a plain attach uses, so a still-held C-a C-d cannot leak an
  EOF into the shell underneath.
- Scroll_Lock joins the kitty modifier-key set in keys.zig.
- Merged duplicate prompt cancel arms, doc comments for KittyKey and
  InputParser.feed, and integration-test formatting cleanups.
…sers

The boo at repaint has always replayed an application's
modifyOtherKeys mode 2 onto the client's real terminal (the
formatter's keyboard extra emits CSI > 4;2m), but neither parser
decoded the resulting CSI 27;5;97~ encoding of Ctrl+A, so the prefix
silently stopped working with vim-class apps on xterm-faithful
terminals. Both deferred follow-ups land:

- keys.zig grows a Protocols struct ({kitty, modify}) replacing the
  bool feed parameter in both parsers; the daemon feeds the window's
  modify_other_keys_2 alongside the kitty flags, and boo ui mirrors
  modifyOtherKeys onto the real terminal again (suspended during
  prompts like the kitty flags) now that the parser understands it.
- parseModify decodes CSI 27;mods;cp~ (no event types, no release
  handling; a repeated prefix stays armed in boo at and dispatches
  C-a C-a in boo ui, matching each parser's raw-byte semantics).
- parseKitty reads the base-layout-key subfield (cp:shifted:base) and
  effectiveCp matches the prefix and command keys through it when the
  primary codepoint is non-ASCII, so C-a works on Cyrillic and other
  non-Latin layouts under the kitty alternate-keys flag. ASCII
  primaries always win, keeping AZERTY-style Latin layouts on their
  legacy typed-character semantics.
- keys.zig's hold grammar loosens to any-digit codepoints (required
  for base matching) with finals gated per protocol; divergence still
  replays held bytes verbatim.

16 new unit tests across both parsers (modify prefix/command/repeat/
passthrough/off/split, dual-protocol decode, Cyrillic prefix+command,
AZERTY non-match, paste protection) and 3 integration tests: boo at
detach via CSI 27;5;97~ with repaint replay, boo ui modify mirror
with prompt suspension, and boo ui modify-encoded quit without leak.
@kylecarbs kylecarbs changed the title fix: mirror kitty keyboard state onto the real terminal in boo ui fix: mirror keyboard protocol state onto the real terminal and decode encoded C-a everywhere Jun 13, 2026
@kylecarbs kylecarbs merged commit b0d6e87 into main Jun 13, 2026
5 checks passed
@kylecarbs kylecarbs mentioned this pull request Jun 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant