A terminal app doesn’t get “keys”, it gets bytes.
- Normal characters like
aarrive as the literal byte fora. - Special keys (arrows, Home/End, function keys, etc.) usually arrive as escape sequences: short strings that start with the ESC character (
\x1b).- In
cat -v, ESC shows up as^[. - So
^[OHmeans the three bytes:ESCthenOthenH.
- In
There are (at least) two worlds you’re supporting:
- Kitty keyboard protocol (when
_kittyProtocolActive === true): the terminal sends structured sequences that explicitly include “which key” and “which modifiers” (Ctrl/Alt/Shift). This is the most reliable. - Legacy mode (when
_kittyProtocolActive === false): terminals vary a lot, so you have to accept multiple possible escape sequences for the same key.
With that in mind, here’s what each change is doing and why it exists.
Change: You added a comment linking to OpenTUI’s parse.keypress.ts.
What case it covers: This isn’t a runtime behavior change; it’s a “paper trail”. It tells future readers “this file is trying to be compatible with the same terminal sequences that OpenTUI supports”.
First-principles take: Good idea. Terminal key parsing is mostly archaeology; having a known-good reference helps prevent regressions.
Change: SpecialKey and the Key helper now include:
insertclearf1…f12
What case it covers: This lets the rest of the app name these keys in a typed way (e.g. Key.f1) and lets matchesKey() / parseKey() return and accept these identifiers.
First-principles take: This is the right layer to add them. If the app can’t express “f5” as a key id, you can’t write bindings/tests for it.
Change: FUNCTIONAL_CODEPOINTS now includes insert.
What case it covers: In Kitty protocol mode, your code represents “non-character keys” using special numeric identifiers (negative numbers like -11 for Insert). This lets the Kitty parser and the rest of the system agree on what “insert” means.
First-principles take: Consistent with the existing scheme for delete/home/end/pageUp/pageDown.
Change: You added:
LEGACY_KEY_SEQUENCES(base sequences)LEGACY_SHIFT_SEQUENCES(shift-modified legacy sequences)LEGACY_CTRL_SEQUENCES(ctrl-modified legacy sequences)LEGACY_SEQUENCE_KEY_IDS(a direct “sequence → KeyId” map forparseKey())- helper functions
matchesLegacySequence()andmatchesLegacyModifierSequence()
What case it covers: Legacy mode is messy: the same physical key can come in multiple spellings depending on terminal, tmux, keypad mode, etc. Tables make it explicit and easy to extend.
First-principles take: This is a quality improvement.
matchesKey()wants “does this key id match this raw string?”, so it benefits from the grouped tables.parseKey()wants “what key id is this raw string?”, so it benefits from the direct map.
One small tradeoff: you now have two ways to recognize some keys (the new map + the old if (data === ...) tail). That’s not wrong, just something to keep tidy over time.
Change: You now accept SS3 forms:
- arrows:
\x1bOA/OB/OC/OD - home/end:
\x1bOH/\x1bOF
What case it covers: Some environments (notably tmux setups) send Home/End as ^[OH / ^[OF instead of ^[ [ H / ^[ [ F.
This is exactly what you saw:
^[OH=ESC+O+H→ Home^[OF=ESC+O+F→ End
First-principles take: This is the core fix for issue #745.
Why this happens: terminals have multiple “modes” for cursor keys.
- CSI style:
ESC [... (e.g.ESC [ Afor Up) - SS3 style:
ESC O... (e.g.ESC O Afor Up)
Apps that only accept CSI break under SS3.
Change: pageUp and pageDown now also accept:
\x1b[[5~\x1b[[6~
What case it covers: Some terminals/clients (Putty is the classic example) use an extra [.
First-principles take: Low risk, high compatibility. These sequences are very “specific looking”, so it’s unlikely you’ll misinterpret normal typing as pageUp/pageDown.
Change: You map clear to:
\x1b[E(CSI form)\x1bOE(SS3-ish form)
and support rxvt modifier variants:
\x1b[e(shift+clear)\x1bOe(ctrl+clear)
What case it covers: “Clear” is a real key on some keyboards (often the keypad). Some terminals encode it like a cousin of arrows/home/end.
First-principles take: Fine addition. clear is niche, but adding it doesn’t destabilize other parsing.
Change: You support Insert as:
- base:
\x1b[2~ - shift:
\x1b[2$ - ctrl:
\x1b[2^
What case it covers: Insert is another “functional key” like Delete/Home.
First-principles take: Makes sense. Using the same modifier strategy you already use elsewhere keeps the mental model consistent.
Change: You added handling for rxvt-style sequences like:
\x1b[a=shift+up\x1bOa=ctrl+up
and the similar patterns for other arrow keys and for Home/End/PageUp/PageDown.
What case it covers: Some terminals don’t use the “xterm modifier” style (ESC [ 1 ; 2 A etc) for modified arrows. Instead they use different letters (a..e) and different prefixes ([ for shift, O for ctrl).
First-principles take: This is exactly the kind of thing that causes “Shift+Up works in terminal X but not terminal Y”. Adding these variants is pragmatic.
Change: You now recognize common function-key encodings:
- SS3:
\x1bOP..\x1bOS(F1–F4) - xterm/rxvt tilde forms:
\x1b[11~..\x1b[24~ - libuv/cygwin “double bracket” forms:
\x1b[[A..\x1b[[E
What case it covers: Different terminals (and libraries) disagree on how to encode F-keys.
First-principles take: Good compatibility improvement.
One important note from first principles: when Kitty protocol is active, some terminals will encode function keys via Kitty-specific sequences (not these legacy forms). Your current matchesKey() implementation for f1–f12 only checks legacy sequences, and parseKey() doesn’t map Kitty function-key codepoints to f1..f12.
So this change improves legacy mode a lot, but function keys might still be missing under Kitty mode (depending on how the terminal emits them).
Change: In legacy mode:
\x00→ctrl+space
What case it covers: ASCII “NUL” (byte zero) is what you get if you hold Ctrl and press Space in many terminals.
First-principles take: Reasonable.
Tradeoff: you can’t distinguish “the user typed an actual NUL byte” from “ctrl+space”. But typing a literal NUL is extremely rare in interactive TUIs.
Change: In legacy mode:
\x1b→alt+space
What case it covers: Legacy Alt/Meta is often encoded as “prefix ESC then send the underlying key”. So alt+<key> becomes ESC + <key>.
First-principles take: Correct, and you wisely scope it to !_kittyProtocolActive so it doesn’t override Kitty’s richer encoding.
Change: In legacy mode, you accept:
\x1b\x7f(ESC + DEL) and now also\x1b\b(ESC + BS)
What case it covers: Different terminals disagree whether Backspace is DEL (0x7f) or BS (0x08). Once you add an Alt prefix, that disagreement shows up as two possible sequences.
First-principles take: This is exactly the kind of “works on Linux but not macOS” / “works in terminal A but not terminal B” fix that improves UX.
Change: In legacy mode, you match/parse sequences like:
\x1b\x03→ctrl+alt+c
Explanation for outsiders:
ctrl+cby itself is the single byte0x03.altin legacy often means “prefix ESC”.- So
ctrl+alt+cbecomesESC+0x03.
First-principles take: Correct and useful. Also good that you only do this in legacy mode; Kitty mode already has an unambiguous representation.
Change: When kitty is inactive, you treat \n as enter.
What case it covers: Some terminals/configs send LF (linefeed, \n, byte 10) when you press Enter.
First-principles take: This is a pragmatic UX decision.
Key detail: in ASCII, ctrl+j is also byte 10. In pure legacy mode, you cannot tell whether the user meant “Enter” or “Ctrl+J” if all you see is \n.
So choosing “\n means Enter” improves the “Enter doesn’t work” class of bug reports, at the cost of making ctrl+j impossible to bind distinctly in legacy mode.
Your behavior split is sensible:
- Kitty active:
\ncan be treated as a deliberate custom mapping (you already treat it asshift+enter). - Kitty inactive:
\nbecomesenterfor compatibility.
Change: You added:
\x1bp→alt+up\x1bn→alt+down
What case it covers: Some environments encode “modified arrow” movements using letters rather than the ESC [ 1 ; 3 A style.
First-principles take: This improves compatibility, but it’s inherently a little risky because ESC p is also exactly what you’d get from someone pressing alt+p in a basic “alt prefixes ESC” terminal.
So this is another ambiguity tradeoff: you’re deciding that in your app, the legacy sequence ESC p is more likely to mean “Alt+Up” than “Alt+P”. That may be correct for TUIs that primarily use Alt+arrows for navigation.
Change: In legacy mode:
\x1bB→alt+left\x1bF→alt+right
What case it covers: Some old styles send uppercase letters for these movements.
First-principles take: Since you gate it behind !_kittyProtocolActive, the blast radius is limited. The remaining ambiguity is: ESC B could also mean alt+shift+b in a “ESC prefixes alt” terminal.
If you don’t care about binding alt+shift+b specifically in legacy mode, this is a fair trade.