// lesson: decoding-the-keyboard

Bytes In โ€” Decoding the Keyboard

With the terminal raw, input is a byte stream and nothing more. The read loop at the bottom of every terminal editor looks like this:

char c;
ssize_t n = read(STDIN_FILENO, &c, 1);
// n == 1: got a byte.  n == 0: VTIME expired with no input (our
// VMIN=0/VTIME=1 config) โ€” a "tick" you can use for housekeeping.
// n == -1 with errno == EINTR: a signal interrupted the read; retry.

What arrives in c? For the letter keys, exactly what you'd hope: j is 0x6A. The interesting keys are everything else, and their encodings are archaeology you have to know to write a decoder.

Control characters: the 5-bit connection

Hold Ctrl and press a letter, and the terminal sends the letter's ASCII code with bits 6 and 5 cleared: Ctrl-A is 0x01, Ctrl-B is 0x02, โ€ฆ, Ctrl-Z is 0x1A. That's not a lookup table, it's a circuit: on a teletype the Ctrl key literally grounded two bit lines. It survives in ASCII's layout โ€” 'A' is 0x41, and 0x41 & 0x1F == 0x01. The same masking explains some familiar aliases:

  • Ctrl-I is Tab (0x09), Ctrl-M is Enter (0x0D, carriage return โ€” with ICRNL off you finally see the real CR), Ctrl-J is newline (0x0A), Ctrl-[ is Escape (0x1B โ€” '[' & 0x1F). Old-school vi users really do type Ctrl-[ instead of reaching for Esc.
  • Backspace is its own mess: modern terminals send DEL, 0x7F for the Backspace key, while 0x08 (Ctrl-H, the ASCII "backspace" character) arrives if someone types Ctrl-H โ€” historically the same editing key, so editors treat both as backspace.

Escape sequences: why ESC [ ?

Arrow keys, Home, End, PageUp, Delete โ€” none of these have an ASCII code. When you press โ†‘, the terminal sends three bytes: ESC [ A (0x1B, 0x5B, 0x41). Why that shape? In the late 1970s DEC's VT100 adopted the ANSI X3.64 standard for terminal control: commands are introduced by ESC + [ โ€” the CSI, Control Sequence Introducer โ€” followed by optional numeric parameters and a final letter that names the command. The VT100 was so successful that its sequences became the lingua franca; "ANSI escape codes" and xterm's ctlseqs document descend directly from it. Your terminal emulator in 2026 is, protocol-wise, imitating a 1978 DEC terminal. The same CSI grammar drives output (next lesson you'll write ESC [ 2 J to clear the screen); on input the terminal uses it to encode special keys:

ESC [ A / B / C / D      arrows up / down / right / left
ESC [ H   and  ESC [ F   Home and End (one common encoding)
ESC [ 1 ~  or  ESC [ 7 ~ Home (other terminals' encoding)
ESC [ 4 ~  or  ESC [ 8 ~ End
ESC [ 3 ~                Delete
ESC [ 5 ~ / ESC [ 6 ~    PageUp / PageDown
ESC O A ... ESC O F      arrows/Home/End again โ€” SS3 form, sent by
                         terminals in "application keypad" mode

Yes: three different encodings for Home, from different terminal lineages, all still in the wild. A robust decoder accepts all of them. (The ~-form numbers come from the VT220's function-key scheme; the ESC O prefix is SS3, "single shift 3", from the VT100's application keypad.) The ctlseqs document in the extended reading is the closest thing to a complete map.

There's one genuinely nasty ambiguity: the user pressing the Esc key sends a lone 0x1B โ€” the same byte that starts every sequence. The only way to tell "Esc" from "the first byte of ESC [ A" is timing: after an ESC, if more bytes are already buffered (or arrive within a few milliseconds), it's a sequence; if the stream goes quiet, it was the Esc key. Our VMIN=0, VTIME=1 setting gives exactly that: after reading an ESC, one more read() either returns the next byte of a sequence or times out (~100 ms) and returns 0 โ€” verdict: lone Esc. This is why Esc in terminal vim can feel hesitant over a laggy ssh connection: the editor is waiting to see whether more bytes follow.

A Key type: sum types over flag soup

The decoder's output should say which key, in one honest type. The C tradition is an int with magic values (kilo uses enum { ARROW_LEFT = 1000, ... } above char range). Modern C++ has a better tool โ€” the sum type:

using Key = std::variant<char, CtrlKey, SpecialKey>;

A Key is exactly one of: a printable character, a Ctrl-chord, or a special key โ€” and the compiler knows which. std::holds_alternative<char>(k) asks; std::get<char>(k) extracts (throwing if you're wrong, unlike a union silently misreading); and std::visit dispatches over all cases, failing to compile if you forget one. enum class (rather than plain enum) keeps SpecialKey::Delete scoped โ€” no bare Delete leaking into the global namespace, no accidental conversion to int. The CtrlKey struct gets bool operator==(const CtrlKey&) const = default โ€” C++20's defaulted comparisons โ€” because std::variant's own == requires each alternative to be comparable; one defaulted line and Key{CtrlKey{'q'}} == key just works in tests and in your keymap.

Two challenges: first the single-byte classifier, then the full escape-sequence state machine. Together they are read_key(), the function your editor's main loop calls once per keystroke; here they're fed byte strings so they can be tested without a terminal.

โ€บ One Byte, One Key

10 pts

Implement decode_byte, mapping a single input byte to a Key:

  • 0x1B โ†’ SpecialKey::Escape; 0x0D and 0x0A โ†’ SpecialKey::Enter (CR is what Enter sends in raw mode; LF is Ctrl-J, which vi also treats as Enter); 0x09 โ†’ SpecialKey::Tab; 0x7F and 0x08 โ†’ SpecialKey::Backspace (DEL from the Backspace key, Ctrl-H from tradition).
  • Remaining bytes 0x01..0x1A โ†’ CtrlKey with the lowercase letter: 0x01 โ†’ {'a'}, 0x1A โ†’ {'z'}.
  • Everything else โ€” printable ASCII, and bytes โ‰ฅ 0x80 (UTF-8 continuation bytes pass through untouched; the buffer stores raw bytes) โ€” โ†’ char (cast the byte).

Note the parameter is std::uint8_t, not char: whether char is signed is platform-dependent, and a signed 0xE9 is โˆ’23 โ€” the same bug the hashmap course meets, and the reason careful byte code says "byte" with an unsigned type.

Log in to submit a solution and earn points.

โ€บ The Escape-Sequence Decoder

15 pts

Implement decode_input, which consumes a whole byte string (as read from the tty) and produces the decoded keys in order. In your editor this runs incrementally over the read buffer; given a complete capture it must produce exactly the keys a terminal user typed.

The grammar, in the order you should check it:

  • A byte other than 0x1B decodes via decode_byte from the previous challenge (provided again in the starter).
  • 0x1B at the end of input โ†’ SpecialKey::Escape (the lone-Esc timeout case).
  • 0x1B followed by [ โ€” a CSI sequence. Collect the parameter bytes (everything up to, not including, the final byte, the first byte in the range 0x40..0x7E). Then:
    • no parameters and final A/B/C/D โ†’ ArrowUp/Down/Right/Left (note: C is right, D is left);
    • no parameters and final H / F โ†’ Home / End;
    • all-digit parameters and final ~: 1 or 7 โ†’ Home, 4 or 8 โ†’ End, 3 โ†’ Delete, 5 โ†’ PageUp, 6 โ†’ PageDown; any other number โ†’ produce nothing (an unrecognized key โ€” swallow it, don't corrupt the stream);
    • anything else (a ; in the params, an unknown final byte) โ†’ produce nothing for the whole sequence;
    • input ends before a final byte arrives โ†’ produce nothing (a real editor would wait for more bytes).
  • 0x1B followed by O โ€” an SS3 sequence: one more byte, Aโ€“D, H, F, mapped exactly as CSI's letter finals; anything else (or end of input) โ†’ produce nothing.
  • 0x1B followed by any other byte โ†’ SpecialKey::Escape, then decode that byte normally (the user pressed Esc, then typed).

Swallowing unknown sequences whole is the important robustness property: if a terminal sends ESC [ 1 ; 5 C (Ctrl-Right โ€” the ;5 is a modifier parameter) and you only ate the ESC [ 1, the stray ; 5 C would type "; 5 C" into the buffer. Real bug, real editors have shipped it.

Log in to submit a solution and earn points.