// 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
ICRNLoff 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 ptsImplement decode_byte, mapping a single input byte to a Key:
0x1BโSpecialKey::Escape;0x0Dand0x0AโSpecialKey::Enter(CR is what Enter sends in raw mode; LF is Ctrl-J, which vi also treats as Enter);0x09โSpecialKey::Tab;0x7Fand0x08โSpecialKey::Backspace(DEL from the Backspace key, Ctrl-H from tradition).- Remaining bytes
0x01..0x1AโCtrlKeywith 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 ptsImplement 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
0x1Bdecodes viadecode_bytefrom the previous challenge (provided again in the starter). 0x1Bat the end of input โSpecialKey::Escape(the lone-Esc timeout case).0x1Bfollowed by[โ a CSI sequence. Collect the parameter bytes (everything up to, not including, the final byte, the first byte in the range0x40..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
~:1or7โ Home,4or8โ 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).
- no parameters and final
0x1Bfollowed byOโ 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.0x1Bfollowed 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.