Forty years of history arrive on stdin
May 25, 2026 · 12 min read
Press the up arrow in a terminal application and ask what the application receives. There is no key event. No keycode, no key-down and key-up, no modifier flags — none of the things every other UI platform has handed developers since the 1980s. What arrives on stdin is three bytes: 1B 5B 41, better known as ESC [ A. Press Ctrl+C and one byte arrives: 03. Press Esc and one byte arrives: 1B — which happens to be the first byte of the arrow sequence you just read.
You don't read keys from stdin. You read bytes with forty years of history encoded in them — VT100 conventions from 1978, xterm's extensions, rxvt's disagreements with xterm, Cygwin's disagreements with everyone — and it's your job to run the archaeology backwards. Previous essays here read the output side of a terminal UI: how many cells a string paints, how to wrap it, what to strip before printing it. This one turns around and faces the keyboard. The code is the input-parser and parse-keypress crates in inkrs, a Rust port of the Ink terminal-UI framework, plus the small kitty-keyboard crate that holds the constants for the one protocol that finally fixes this.
Cooked and raw
One paragraph of setup. By default a Unix terminal is in cooked mode: the tty driver buffers input a line at a time, handles backspace and Ctrl+U itself, and hands your program a finished line when the user presses Enter. A TUI can't live like that — it needs every keystroke immediately, unechoed — so it switches the terminal to raw mode. From that moment the driver does nothing for you: every byte lands on stdin the instant it exists, echo is off, line editing is gone, and everything the driver used to do is now your problem. Including the decoding problem this essay is about.
The encoding, such as it is
Printable characters arrive as themselves: press a, receive a, with UTF-8 doing its usual work for anything beyond ASCII. Everything else is convention stacked on convention.
Control characters are the oldest layer. Holding Ctrl clears the high bits of the letter: Ctrl+letter = letter & 0x1F. Ctrl+A is 0x01, Ctrl+Z is 0x1A. This is elegant, 1963-vintage, and produces three famous collisions:
- Ctrl+I is
0x09— which is Tab. - Ctrl+M is
0x0D— which is Enter's carriage return. - Ctrl+[ is
0x1B— which is Esc.
Not "similar to". The same byte. The distinction between Ctrl+I and Tab never existed on the wire, so no parser can recover it. In parse-keypress, the decode is the encoding run backwards — and the order of the early returns is the collision policy:
if s == "\t"
// …
if s.len ==
0x09 is claimed by the "\t" check before the control-byte branch ever sees it. In this parser, ctrl+i is not a key that can be reported — Tab wins, by decree, because something has to.
Above the control bytes sits the escape-sequence layer: arrows, function keys, Home and End arrive as multi-byte sequences opened by ESC. Up is ESC [ A. But which sequence a key produces depends on which terminal lineage you're talking to, and parse-keypress carries the whole family tree in one lookup table:
That's an excerpt; the real table is 72 entries. F1 alone has four spellings — ESC O P, ESC [ P, ESC [ 11 ~, ESC [ [ A — depending on whether your terminal thinks it's an xterm, a VT220, or a Cygwin console. Modifiers were bolted on decades later as a parameter: Ctrl+Up is ESC [ 1 ; 5 A, where the 5 is a modifier bitmask plus one. The table just grew.
Esc is ambiguous by design
The byte 0x1B has three futures. It can be the Esc key, pressed alone. It can open an escape sequence: every arrow, every function key starts with it. And it can be an Alt prefix: most terminals send Alt+X as ESC x, two bytes. Same first byte, three meanings, and the terminal gives you no framing whatsoever to tell you which one you're holding.
When more bytes follow in the same buffer, lookahead settles it: ESC [ A is an arrow, ESC x is Alt+X. The undecidable case is ESC as the last byte of what you've read so far. Is it the Esc key, or the first byte of an arrow whose remainder is still in flight? The input-parser crate refuses to guess:
Pending means: hold the tail, emit nothing, wait for more bytes. The parser exposes has_pending_escape() and flush_pending_escape(), and the policy — how long to wait — deliberately lives one layer up, in the runtime:
/// Maximum time we wait for continuation bytes before a lone `\x1b`
/// is flushed as the Escape key. Matches upstream Ink's
/// `pendingInputFlushDelayMilliseconds` (20ms).
const ESCAPE_FLUSH_DELAY_DEFAULT: Duration = from_millis;
So the honest answer to "timeout or lookahead?" is: both, layered. Lookahead whenever there are bytes to look at; a timeout only for a trailing ESC, and the timeout lives in the runtime's config — set_escape_flush_delay exists because 20ms is fine locally and optimistic over a jittery SSH link. Two details are easy to miss. Every new chunk restarts the clock, so the timer only fires in genuine silence. And the flushed ESC is dispatched directly rather than pushed back through the parser: feed a lone ESC to the parser again and it would, correctly, mark it pending again, forever.
This is still a heuristic. If the terminal splits an arrow key across a 25ms network stall, the user gets an Esc followed by garbage. There is no fix inside the legacy encoding; the ambiguity is in the bytes themselves. Hold that thought until the kitty section.
Chunks lie
Raw mode has a second, quieter problem: read() boundaries mean nothing. The kernel, the pty, and any SSH hop in between decide how input is chunked, and none of them care that ESC [ 1 ; 5 A is six bytes that belong together. A burst of mouse-wheel events can be split mid-sequence. A parser that assumes "one read = whole sequences" works on a quiet local terminal and shreds input everywhere else.
So the parser is incremental, and the entire mechanism is one field and one method:
Whatever couldn't be completed — from the unfinished ESC onward — comes back as pending and is glued in front of the next chunk, which is then parsed from scratch. No resumable state machine, no suspended parser stack: pending is at most one incomplete sequence, so the re-work is a handful of bytes. The test suite drives this to the pathological extreme, one byte per read:
Five pushes of silence, then the sixth byte releases one complete Ctrl+Up. Note how this interlocks with the Esc timeout: after that first push("\u{001B}") the parser reports a pending escape and the 20ms clock starts; the [ arriving resets it. Timeout and incrementality aren't two features — they're one mechanism seen at two timescales.
Bracketed paste
Now paste forty lines of shell script into a TUI. Without help from the terminal, that paste is indistinguishable from very fast typing: forty newlines become forty Enter presses, each submitted against whatever the UI happened to be showing at that moment. If the pasted text contains an escape sequence — and pasted logs do, as the previous essay on ANSI injection explored from the output side — it will be executed as keystrokes. Paste-as-typing is a protocol-level disaster, which is why modern terminals offer bracketed paste mode: when enabled, a paste arrives wrapped in markers.
const PASTE_START: &str = "\u{001B}[200~";
const PASTE_END: &str = "\u{001B}[201~";
Recognition in input-parser is exactly what you'd hope: when a completed sequence turns out to be the start marker, everything up to the end marker becomes a single event of a different type:
if sequence == PASTE_START
The content is delivered verbatim: the test paste_content_with_escapes_delivered_verbatim pastes hello\x1b[Aworld and gets it back untouched as one Paste event. Inside the brackets, an arrow sequence is data, not a key. And if the end marker hasn't arrived yet (a large paste easily spans many chunks), the whole tail goes back to pending and the parser waits.
Which creates a trap: a paste is supposed to linger in pending, but we just built a 20ms timer that flushes lingering escapes as the Esc key. The fix is that has_pending_escape — the predicate the timer consults — knows which pending states are none of the timer's business:
That oddly specific "\u{001B}[200" is the start marker split one byte before its ~, a real chunk boundary the tests pin down. The last two lines exclude OSC and DCS strings: replies from the terminal itself (color queries, version strings) that may also straddle chunks and must never be flushed mid-assembly and dispatched as a keypress. Every line of this predicate is a chunk-boundary bug that can't happen anymore.
The kitty keyboard protocol
Everything above is coping. The kitty keyboard protocol — from the kitty terminal, since adopted by a growing set of others — is the actual repair. It gives every key one unambiguous shape:
ESC [ code-point ; modifiers : event-type ; text u
Measured against the mess we just waded through, it fixes each problem by construction. Esc stops being ambiguous: it arrives as ESC [ 27 u, a complete, self-terminating sequence: no timer, no heuristic. Ctrl+I stops being Tab: modifiers travel in their own field, so the i key with Ctrl held is ESC [ 105 ; 5 u while Tab stays Tab. And key release and repeat finally exist, in the event-type subfield — something the legacy encoding cannot express at all.
It's also negotiated, not assumed: an application requests the enhancements it wants from the terminal as a bitmask of progressive-enhancement flags. The kitty-keyboard crate in inkrs is, almost in its entirety, the vocabulary for that negotiation:
pub const
The decoding lives in parse-keypress, and — verified against the source, not the spec — inkrs implements the receive side of the protocol's core. parse_kitty_keypress handles the CSI-u form: modifiers arrive one-higher than the bitmask (kitty counts from 1, saturating_sub(1) undoes it), covering Shift through NumLock including Super and Hyper, which legacy encodings never had room for. Event types map through a three-way match:
The optional text field carries shifted text as code points — press Shift+A and the key is a with text: "A". Functional keys live in a Unicode private-use block: kitty_codepoint_name maps 57358–57454 to names like capslock, f13 through f35, the whole keypad, and media keys. Arrows and F-keys in kitty's enhanced form (ESC [ 1 ; modifiers : event-type A) go through parse_kitty_special_key. Even the protocol's status reply is handled: match_terminal_response recognizes ESC [ ? flags u — the answer to a CSI ? u query — where the ? is all that distinguishes a response from a keypress with code point equal to your flags. One part of the spec is knowingly absent: alternate key reporting puts colon-separated extra code points inside the key field itself (ESC [ 97:65 ; 2 u), and match_kitty_key's all-digits check rejects that form — the ReportAlternateKeys flag constant exists, but the shifted-key data rides in the text field instead.
Two structural notes. parse_keypress tries the kitty parsers first and falls back to the legacy tables, so a kitty terminal and a 1985-vintage one flow through the same function. And the byte-level tokenizer needed zero changes for kitty: a CSI-u sequence is a well-formed CSI sequence with final byte u, so input-parser was already assembling it correctly across chunk boundaries — there's a test, parses_kitty_protocol_sequence_as_one_key_event, that exists mostly to document the non-event. Designing your extension to be old-grammar-compatible is what a well-behaved forty-first year looks like.
Two crates, one lesson
Step back and look at the layering, because it's the actual answer to "how do you tame forty years of legacy."
input-parser is a no_std tokenizer that knows shapes, not meanings: CSI, SS3, OSC/DCS strings, paste markers. It answers one question — is this token complete, or do I hold it? — and everything subtle in it (pending buffers, flush predicates, the X10-mouse byte-counting) exists to answer that question correctly across arbitrary chunk boundaries. parse-keypress assigns meaning: names, modifiers, event types, mouse coordinates, terminal responses. It never sees a chunk boundary in its life; by the time it runs, tokens are whole. And kitty-keyboard is the shared constants both ends of a negotiation agree on.
None of these layers could absorb the others without getting worse. A tokenizer that knows key names starts making meaning-level decisions with incomplete bytes; an interpreter that handles chunking re-litigates buffer state in every match arm. Split apart, each dialect xterm ever spawned is one more table row, and a whole new protocol is one more branch tried first — while the timer, the paste brackets, and the byte-counting stay written exactly once.
Forty years of history will keep arriving on stdin, three bytes at a time. You can't refactor the history. You can decide that only one layer of your code is allowed to know it's there.
Thoughts? Find me on LinkedIn.