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:
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.
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)
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.
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.
| Flag | What it does | Experiment |
|---|
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.
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.
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.
- Drainread() the master in large chunks until it would block.
- ParseRun the VT state machine. Prints write cells; CSI and OSC change cursor, attributes or modes.
- Mark damageRecord which rows changed, so a one-character echo redraws one row.
- Wait for framevsync, a Wayland frame callback or CVDisplayLink. Many reads become one frame.
- Shape and rasterizeText runs become glyphs (HarfBuzz, CoreText), cached in a glyph atlas texture.
- DrawBackground quads, then instanced glyph quads, in a few GPU draw calls.
- 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.
| Stage | Rough range |
|---|---|
| Keyboard matrix scan and debounce | 1–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 response | 8–30 ms |
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
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
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.