Skip to content

Instantly share code, notes, and snippets.

@richardgill
Created January 15, 2026 16:57
Show Gist options
  • Select an option

  • Save richardgill/c93915144f09c2d274af58ecc7d2ad3c to your computer and use it in GitHub Desktop.

Select an option

Save richardgill/c93915144f09c2d274af58ecc7d2ad3c to your computer and use it in GitHub Desktop.
Thinking about pi-mono terminal key handling changes

Thinking about the key-handling changes (plain-English)

Mental model (so the rest makes sense)

A terminal app doesn’t get “keys”, it gets bytes.

  • Normal characters like a arrive as the literal byte for a.
  • 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 ^[OH means the three bytes: ESC then O then H.

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.


1) Add an OpenTUI reference link

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.


2) Expand the key vocabulary (insert, clear, f1f12)

Change: SpecialKey and the Key helper now include:

  • insert
  • clear
  • f1f12

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.


3) Add FUNCTIONAL_CODEPOINTS.insert for Kitty parsing

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.


4) Centralize legacy escape sequences in lookup tables

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 for parseKey())
  • helper functions matchesLegacySequence() and matchesLegacyModifierSequence()

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.


5) Fix tmux Home/End (and SS3 arrows) by supporting ESC O ...

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 [ A for Up)
  • SS3 style: ESC O ... (e.g. ESC O A for Up)

Apps that only accept CSI break under SS3.


6) Add Putty page up/down variants (ESC [[5~ / ESC [[6~)

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.


7) Add the clear key (and its legacy variants)

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.


8) Add insert key, including rxvt modifier variants ($ and ^)

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.


9) Add rxvt-style modified arrows/home/end/page keys

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.


10) Add legacy function keys f1f12

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 f1f12 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).


11) Treat \x00 as ctrl+space

Change: In legacy mode:

  • \x00ctrl+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.


12) Support alt+space as ESC + space

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.


13) Support an extra alt+backspace spelling (ESC + \b)

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.


14) Add legacy ctrl+alt+letter as ESC + Ctrl-letter byte

Change: In legacy mode, you match/parse sequences like:

  • \x1b\x03ctrl+alt+c

Explanation for outsiders:

  • ctrl+c by itself is the single byte 0x03.
  • alt in legacy often means “prefix ESC”.
  • So ctrl+alt+c becomes ESC + 0x03.

First-principles take: Correct and useful. Also good that you only do this in legacy mode; Kitty mode already has an unambiguous representation.


15) Normalize linefeed (\n) to Enter in legacy mode

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: \n can be treated as a deliberate custom mapping (you already treat it as shift+enter).
  • Kitty inactive: \n becomes enter for compatibility.

16) Add alt+up / alt+down for the ESC p / ESC n style

Change: You added:

  • \x1bpalt+up
  • \x1bnalt+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.


17) Add uppercase ESC B / ESC F as alt+left / alt+right (legacy-only)

Change: In legacy mode:

  • \x1bBalt+left
  • \x1bFalt+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.

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