Anatomy of a Keystroke
Terminals, from key switch to pixel

Anatomy of a keystroke

Your terminal emulator never talks to your shell. It talks to a kernel device that pretends to be a serial line from the 1970s, and that device decides what echo, Enter and Ctrl+C mean. Click the terminal below and type. Every key becomes a packet you can follow down the stack and back up.

The stack on the right gives each layer's job in a sentence. The sections below the simulator explain the parts that matter most:

bash · /dev/pts/356×14

        
click, then type
termios what?
foreground: bash
input bytesoutput bytessignalkernel
in ↓↑ out
trace
Terminal emulator · input encoding

Bytes, not keys

A PTY carries bytes, so the emulator has to flatten each key event into a byte string. The rules come from hardware terminals. Printable keys send their UTF-8. Ctrl clears bits 5 and 6 of the character, so Ctrl+C becomes 0x03. Keys with no ASCII equivalent send escape sequences that start with ESC (0x1b): the up arrow is ESC [ A, Ctrl+Up is ESC [ 1 ; 5 A.

The encoding loses information. Tab and Ctrl+I are both 0x09, Enter and Ctrl+M are both 0x0d, and Alt+b is the same as pressing Esc and then b. Esc is also the first byte of every arrow key, so a program that reads a lone 0x1b has to wait a few milliseconds to see whether more bytes follow. That wait is vim's ttimeoutlen and readline's keyseq-timeout.

Newer protocols fix this by encoding the key itself. In the kitty keyboard protocol, Ctrl+I arrives as CSI 105;5u. Emulators only send these after the application opts in with an escape sequence of its own, so legacy programs keep working.

Click here and press any key combination
Key
Ctrl+C
Legacy (xterm)
Kitty protocol
Collides with
Kernel · /dev/ptmx

The PTY pair

Hardware terminals used to be separate machines connected to the computer by a serial cable. A pseudoterminal (PTY) is the kernel faking that cable in software: two character devices joined inside the kernel, so whatever goes in one end comes out the other. The emulator keeps the master side. The shell gets the slave side as file descriptors 0, 1 and 2. From the shell's point of view the slave is a serial port with a terminal plugged into it: isatty(0) is true, it has termios settings and a window size, and it can be a session's controlling terminal.

After setup, the emulator runs one loop. It polls the master fd. When a key arrives it encodes the key and calls write() on the master. When the master is readable it reads whatever the child printed, parses it, and schedules a frame.

The slave name differs by platform (/dev/pts/N on Linux, /dev/ttysNNN on macOS), but the API is POSIX and the same on both.

int master = posix_openpt(O_RDWR | O_NOCTTY);
grantpt(master);
unlockpt(master);
char *path = ptsname(master);      // "/dev/pts/3"

struct winsize ws = { .ws_row = 14, .ws_col = 56 };
ioctl(master, TIOCSWINSZ, &ws);

if (fork() == 0) {
    setsid();                       // new session, no controlling tty
    int slave = open(path, O_RDWR);
    ioctl(slave, TIOCSCTTY, 0);      // becomes the controlling tty
    dup2(slave, 0); dup2(slave, 1); dup2(slave, 2);
    execlp("bash", "-bash", NULL);
}
// parent: poll(master) → read → parse → render
//         key event → encode → write(master)
Kernel · n_tty / termios

The line discipline

Between master and slave sits the line discipline, the code that makes a terminal behave like a terminal. On Linux it lives in drivers/tty/n_tty.c. Its behaviour is controlled by struct termios, which you change with stty or tcsetattr().

In the default canonical ("cooked") mode it collects a line, handles Backspace itself, echoes what you type and turns Ctrl+C into a signal. A program calling read() gets nothing until you press Enter.

Interactive programs switch most of this off. readline, vim and every TUI clear ICANON and ECHO, read each byte as it arrives and draw their own echo. You can see the difference in the simulator: when you type at the bash prompt, the output packet starts at the process. Run cat and it turns around inside the kernel.

who echoes?

Cooked mode: the kernel. The letter is on screen before the program has woken up, and a program that is busy or hung still shows your typing.

Raw mode: the program. If it stalls, typing appears to stop. Over ssh you wait a full network round trip for each character, because the remote readline does the echo.

cfmakeraw() clears all of these flags at once. ssh does that to your local terminal so every byte, including 0x03, travels to the remote line discipline untouched.

FlagWhat it doesExperiment
Terminal emulator · output parsing

Parsing the output

Output is one byte stream with commands mixed into the text. Most emulators parse it with a version of the state machine Paul Williams documented for DEC's VT500 series. It defines a transition for every byte in every state, so garbage input can't wedge the parser. In GROUND, a printable byte draws a cell and advances the cursor. ESC starts a sequence; the final byte of a CSI sequence selects the command, and the parameters collected on the way are its arguments.

ESC final [ ] 0–9 ; ? 0x20–2F final 0x40–7E 0x20–2F final final BEL / ST ESCAPEcollect 0x20–2F CSI_ENTRYclear params CSI_PARAM↻ param OSC_STRING↻ osc_put CSI_INTERMEDIATE↻ collect GROUNDprint 0x20–7E · execute C0 (CR, LF, BS, HT, BEL)
window title: (none)

          
Hover a cell to see what the grid stores for it.
Kernel · sessions, process groups, job control

Sessions and signals

Ctrl+C works because of bookkeeping the kernel keeps for each terminal. The emulator's child calls setsid() and becomes leader of a new session, and the slave becomes that session's controlling terminal. The shell puts each job in its own process group and tells the terminal which group is in the foreground with tcsetpgrp(). Signals generated by the line discipline go to that whole group. That is why Ctrl+C kills every process in a pipeline and leaves the shell alone.

Terminal emulatorholds the master fd · window 80×24
/dev/pts/3 + line disciplinecontrolling tty of session 4120 · foreground pgrp 4200
session 4120
pgrp 4120
bashpid 4120 · session leaderwaitpid() for job 1
pgrp 4200 · job 1: sleep 100 | grep x
sleeppid 4200running
greppid 4201running
Terminal emulator · rendering

From grid to glass

The emulator's model is a grid of cells: a codepoint plus foreground, background and flags, with a cursor and a scrollback ring. Parsing changes the grid. Drawing is a separate step that runs on the display's frame clock. cat on a big file can produce megabytes between two frames, so a good emulator parses everything that arrived and draws once.

  1. Drainread() the master in large chunks until it would block.
  2. ParseRun the VT state machine. Prints write cells; CSI and OSC change cursor, attributes or modes.
  3. Mark damageRecord which rows changed, so a one-character echo redraws one row.
  4. Wait for framevsync, a Wayland frame callback or CVDisplayLink. Many reads become one frame.
  5. Shape and rasterizeText runs become glyphs (HarfBuzz, CoreText), cached in a glyph atlas texture.
  6. DrawBackground quads, then instanced glyph quads, in a few GPU draw calls.
  7. PresentThe compositor combines windows; the display scans the frame out.

Applications that redraw a lot, such as editors and multiplexers, can bracket an update with CSI ? 2026 h and CSI ? 2026 l (synchronized output). The emulator holds rendering until the closing sequence, so you never see a half-drawn screen.

The table shows where key-to-photon time goes. The kernel and PTY are almost free. Most of the delay comes from polling and frame boundaries on either side of them.

StageRough range
Keyboard matrix scan and debounce1–10 ms
USB polling interval (125 Hz to 1 kHz)1–8 ms
Kernel input, compositor dispatch< 1 ms
Encode, write(), line discipline, read()µs
Application echo (readline, vim)µs – ms
Emulator waits for its next frame (60 Hz)0–16.7 ms
Compositor, scanout, panel response8–30 ms
Composition

Terminals all the way down

Every piece of this stack can be repeated. A program that owns a PTY master and renders what it reads is a terminal emulator, whether or not it draws pixels.

ssh

emulator⇄pty A (raw)⇄ssh⇄TCP, encrypted channel⇄sshd⇄pty B (cooked)⇄bash

ssh puts pty A in raw mode, so Ctrl+C crosses the network as the byte 0x03. pty B's line discipline on the server turns it into SIGINT. When your window changes size, ssh catches SIGWINCH and sends a window-change request; sshd applies it to pty B with TIOCSWINSZ.

tmux

emulator⇄pty A (raw)⇄tmux client⇄unix socket⇄tmux server: one grid per pane⇄pty per pane⇄shell, vim, …

The tmux server is a full emulator. It parses each pane's output into its own grid, then writes new escape sequences that draw the visible result on your outer terminal. A feature such as truecolor or the kitty keyboard protocol only works if both tmux and the outer emulator support it, which is why TERM inside tmux is tmux-256color and not your emulator's value.