Key Encoding Table
The specification for terminal-input and for
Lab 13. Every row should be a unit test.
Bytes are shown in hex and as escapes.
CSI=1b 5b·SS3=1b 4f.Modifier parameter =
1 + shift(1) + alt(2) + ctrl(4) + super(8).
Ordinary Characters
| Key | Bytes | Source |
|---|---|---|
a | 61 | the text event — handles layouts, dead keys, IME |
A (Shift+a) | 41 | text |
é (dead key or IME) | c3 a9 | text |
日 (IME) | e6 97 a5 | text |
| Space | 20 | text |
Use the text event for these. Physical keys break on Dvorak and AZERTY; text handles composition and IME correctly.
Named Keys, Unmodified
| Key | Bytes | Escape | Note |
|---|---|---|---|
| Enter | 0d | \r | CR, not LF. ICRNL converts it kernel-side. |
| Tab | 09 | \t | |
| Shift+Tab | 1b 5b 5a | CSI Z | CBT (back-tab) |
| Backspace | 7f | DEL | Not 08. DEC terminals sent DEL. |
| Ctrl+Backspace | 08 | BS | Configurable; some setups expect the reverse |
| Alt+Backspace | 1b 7f | ESC DEL | |
| Escape | 1b | ESC | Ambiguous with sequence starts |
| Delete | 1b 5b 33 7e | CSI 3~ | |
| Insert | 1b 5b 32 7e | CSI 2~ | |
| Home | 1b 5b 48 | CSI H | xterm form |
| End | 1b 5b 46 | CSI F | xterm form |
| Page Up | 1b 5b 35 7e | CSI 5~ | |
| Page Down | 1b 5b 36 7e | CSI 6~ |
Arrow Keys (Mode-Dependent)
| Key | DECCKM reset (normal) | DECCKM set (application) |
|---|---|---|
| Up | 1b 5b 41 CSI A | 1b 4f 41 SS3 A |
| Down | 1b 5b 42 CSI B | 1b 4f 42 SS3 B |
| Right | 1b 5b 43 CSI C | 1b 4f 43 SS3 C |
| Left | 1b 5b 44 CSI D | 1b 4f 44 SS3 D |
Modified arrows always use the CSI form, even when DECCKM is set. That asymmetry is xterm's behavior, and programs depend on it:
| Key | Bytes | Escape |
|---|---|---|
| Shift+Up | 1b 5b 31 3b 32 41 | CSI 1;2 A |
| Alt+Up | 1b 5b 31 3b 33 41 | CSI 1;3 A |
| Shift+Alt+Up | ...3b 34 41 | CSI 1;4 A |
| Ctrl+Up | ...3b 35 41 | CSI 1;5 A |
| Ctrl+Shift+Up | ...3b 36 41 | CSI 1;6 A |
| Ctrl+Alt+Up | ...3b 37 41 | CSI 1;7 A |
| Ctrl+Alt+Shift+Up | ...3b 38 41 | CSI 1;8 A |
Home and End take the same form: CSI 1;5 H for Ctrl+Home.
Function Keys
| Key | Bytes | Escape | Lineage |
|---|---|---|---|
| F1 | 1b 4f 50 | SS3 P | VT100 |
| F2 | 1b 4f 51 | SS3 Q | VT100 |
| F3 | 1b 4f 52 | SS3 R | VT100 |
| F4 | 1b 4f 53 | SS3 S | VT100 |
| F5 | 1b 5b 31 35 7e | CSI 15~ | VT220 |
| F6 | 1b 5b 31 37 7e | CSI 17~ | VT220 |
| F7 | 1b 5b 31 38 7e | CSI 18~ | |
| F8 | 1b 5b 31 39 7e | CSI 19~ | |
| F9 | 1b 5b 32 30 7e | CSI 20~ | |
| F10 | 1b 5b 32 31 7e | CSI 21~ | |
| F11 | 1b 5b 32 33 7e | CSI 23~ | |
| F12 | 1b 5b 32 34 7e | CSI 24~ |
Note the gaps: there is no 16~ and no 22~. They are historical, and getting them wrong shifts
every subsequent function key.
Modified: the modifier goes after the number — CSI 15;2~ for Shift+F5, CSI 1;5 P for Ctrl+F1.
Control Combinations
The rule: Ctrl + char = uppercase(char) & 0x1F.
| Key | Byte | Name | Note |
|---|---|---|---|
| Ctrl+Space, Ctrl+@ | 00 | NUL | |
| Ctrl+A | 01 | SOH | |
| Ctrl+B | 02 | STX | tmux's default prefix |
| Ctrl+C | 03 | ETX | → SIGINT when ISIG |
| Ctrl+D | 04 | EOT | VEOF |
| Ctrl+G | 07 | BEL | |
| Ctrl+H | 08 | BS | |
| Ctrl+I | 09 | HT | This IS Tab |
| Ctrl+J | 0a | LF | Enter when ICRNL is off |
| Ctrl+M | 0d | CR | This IS Enter |
| Ctrl+Q | 11 | DC1 | VSTART |
| Ctrl+S | 13 | DC3 | VSTOP |
| Ctrl+U | 15 | NAK | VKILL |
| Ctrl+V | 16 | SYN | VLNEXT |
| Ctrl+W | 17 | ETB | VWERASE |
| Ctrl+Z | 1a | SUB | → SIGTSTP |
| Ctrl+[ | 1b | ESC | This IS Escape |
| Ctrl+\ | 1c | FS | → SIGQUIT |
| Ctrl+] | 1d | GS | |
| Ctrl+^ | 1e | RS | |
| Ctrl+_ | 1f | US | |
| Ctrl+? | 7f | DEL |
Ctrl+M, Ctrl+I, and Ctrl+[ are not analogous to Enter, Tab, and Escape — they are the same
bytes. A program cannot distinguish them, which is why vim cannot bind Ctrl+M separately from
Enter.
Ctrl with a digit or punctuation (Ctrl+1, Ctrl+5) has no legacy encoding. Send nothing, or use
modifyOtherKeys. Sending a wrong byte is worse than sending none.
Alt / Meta
The rule: ESC prefix, then the key's normal bytes.
| Key | Bytes | Escape |
|---|---|---|
| Alt+a | 1b 61 | ESC a |
| Alt+Shift+A | 1b 41 | ESC A |
| Alt+Enter | 1b 0d | ESC CR |
| Ctrl+Alt+c | 1b 03 | ESC ETX |
Never use 8-bit meta (0x62 | 0x80 = 0xe2) — it is a valid UTF-8 lead byte and collides with
text.
macOS: Option is a composing modifier by default (Option+B gives
∫). Provide an "Option as Meta" setting, default it on for the left Option key, and use the logical key rather than the text.
Keypad (DECKPAM / DECKPNM)
| Key | Numeric mode (ESC >) | Application mode (ESC =) |
|---|---|---|
| Keypad Enter | 0d | 1b 4f 4d SS3 M |
Keypad + | 2b | 1b 4f 6b SS3 k |
Keypad - | 2d | 1b 4f 6d SS3 m |
Keypad * | 2a | 1b 4f 6a SS3 j |
Keypad / | 2f | 1b 4f 6f SS3 o |
| Keypad 0–9 | 30–39 | SS3 p … SS3 y |
Keypad . | 2e | 1b 4f 6e SS3 n |
Paste
| Condition | Bytes |
|---|---|
?2004 set | 1b 5b 32 30 30 7e + text + 1b 5b 32 30 31 7e (CSI 200~ … CSI 201~) |
?2004 reset | The text alone |
Always:
- Normalize
\r\nand\nto\r— the keyboard sends CR, andICRNLconverts. A pasted\nbypasses that path. - Strip
CSI 201~from the payload. Otherwise a crafted clipboard escapes the envelope and the remainder runs as typed input. This is a real, exploited attack. - Consider warning on a multi-line paste when the mode is off — it executes immediately.
Mouse
Only sent when a mouse mode (?1000/?1002/?1003) is enabled. Shift always bypasses reporting
so the user can select text.
SGR encoding (?1006) — use this
Press: CSI < b ; x ; y M (uppercase M)
Release: CSI < b ; x ; y m (lowercase m)
b = button + modifiers:
0 = left 1 = middle 2 = right
+4 shift +8 alt +16 ctrl
+32 motion
64 = wheel up 65 = wheel down
x, y = 1-BASED cell coordinates. Decimal, so no limit.
| Event | Bytes |
|---|---|
| Left press at (10, 5) | 1b 5b 3c 30 3b 31 30 3b 35 4d = CSI <0;10;5M |
| Left release | CSI <0;10;5m |
| Right press | CSI <2;10;5M |
| Wheel up | CSI <64;10;5M |
| Drag with left held | CSI <32;11;5M |
| Ctrl+left press | CSI <16;10;5M |
Legacy X10 (the default without ?1006)
CSI M Cb Cx Cy each byte = value + 32
FATAL LIMIT: one byte per coordinate, so columns/rows above 223 CANNOT be
encoded. This is why ?1006 exists. Suppress the event rather than emit
garbage.
Focus (?1004)
| Event | Bytes |
|---|---|
| Focus gained | 1b 5b 49 CSI I |
| Focus lost | 1b 5b 4f CSI O |
Used by vim (reload changed files) and tmux. Two lines to implement.
Beyond the Legacy Protocol
modifyOtherKeys (xterm)
Enable: CSI > 4 ; 2 m Disable: CSI > 4 ; 0 m
Keys with no legacy encoding become:
CSI 27 ; <modifier> ; <codepoint> ~
Ctrl+1 → CSI 27;5;49~
Ctrl+Shift+A → CSI 27;6;65~
The kitty keyboard protocol
CSI > <flags> u push CSI < u pop CSI ? u query
flags: 1 = disambiguate escape codes (fixes the Escape ambiguity)
2 = report event types (press/repeat/release)
4 = report alternate keys
8 = report all keys as escape codes
16 = report associated text
Keys become: CSI <unicode-key> [; <mods> [: <event>]] u
Recommended scope: the legacy table above, plus modifyOtherKeys, plus kitty flag 1 if you have
the appetite. Full kitty support is a stretch goal.
The Escape Ambiguity
Escape: 1b
Up arrow: 1b 5b 41
^^ identical first byte
A program reading bytes cannot distinguish them without a timeout (25–50 ms is the modern recommendation; tmux's 500 ms default is why "vim feels laggy in tmux").
Your GUI does not have this problem — it receives real key events. A terminal-based mux client does. That is a genuine architectural advantage of a graphical client.
The Test
#![allow(unused)] fn main() { #[test] fn key_encoding_table() { let d = TerminalModes::default(); let mut app = d; app.insert(Mode::APP_CURSOR_KEYS); let cases: &[(Key, Modifiers, &TerminalModes, &[u8])] = &[ (Named(Enter), NONE, &d, b"\r"), (Named(Tab), NONE, &d, b"\t"), (Named(Tab), SHIFT, &d, b"\x1b[Z"), (Named(Backspace), NONE, &d, b"\x7f"), (Named(Backspace), CTRL, &d, b"\x08"), (Named(Escape), NONE, &d, b"\x1b"), (Named(ArrowUp), NONE, &d, b"\x1b[A"), (Named(ArrowUp), NONE, &app, b"\x1bOA"), (Named(ArrowUp), CTRL, &d, b"\x1b[1;5A"), (Named(ArrowUp), CTRL, &app, b"\x1b[1;5A"), // CSI even under DECCKM (Named(Home), NONE, &d, b"\x1b[H"), (Named(End), NONE, &d, b"\x1b[F"), (Named(Delete), NONE, &d, b"\x1b[3~"), (Named(Delete), CTRL, &d, b"\x1b[3;5~"), (Named(F1), NONE, &d, b"\x1bOP"), (Named(F5), NONE, &d, b"\x1b[15~"), (Named(F12), NONE, &d, b"\x1b[24~"), (Named(F5), SHIFT, &d, b"\x1b[15;2~"), (Char('c'), CTRL, &d, b"\x03"), (Char('['), CTRL, &d, b"\x1b"), (Named(Space), CTRL, &d, b"\x00"), (Char('b'), ALT, &d, b"\x1bb"), (Char('c'), CTRL_ALT, &d, b"\x1b\x03"), ]; for (key, mods, modes, want) in cases { assert_eq!(encode_key(*key, *mods, modes).as_deref(), Some(*want), "key={key:?} mods={mods:?}"); } } }
Verifying Against a Real Terminal
# Run your Lab 1 inspector in xterm and in your terminal; press the same keys
# in the same order; diff.
cargo run -p raw-inspector | tee /tmp/reference.txt # in xterm
cargo run -p raw-inspector | tee /tmp/mine.txt # inside your terminal
diff /tmp/reference.txt /tmp/mine.txt
Every difference is a bug or a deliberate decision. Write the table: key, reference bytes, your bytes, verdict.
See also: Input Encoding · Trace a Keystroke · Escape Sequence Cheat Sheet