Terminals, PTYs & Multiplexers: Build a Terminal Emulator and Multiplexer in Rust
Welcome to the Terminal Systems Curriculum — a project-based engineering apprenticeship in which
you build a small terminal emulator and a small terminal multiplexer in Rust, from /dev/ptmx
upward, in order to understand how terminals actually work.
The primary goal is not a polished terminal application. The primary goal is that you can open any terminal codebase — Ghostty, Alacritty, WezTerm, foot, kitty, tmux, zellij — and read it without hitting a layer you do not understand. You get there by implementing the important concepts yourself, incrementally, with instrumentation at every layer.
What This Curriculum Is
This is a build-it curriculum, and it differs from the other curricula in this book. Apache Tez, OpenSearch, and Firecracker teach you to contribute to an existing project. This one teaches you a domain by making you build a simplified system inspired by:
- Ghostty and other modern terminal emulators
- libghostty and the idea of a reusable terminal-core library
- tmux-style terminal multiplexers
- Unix PTYs
- Shell process management
- ANSI and VT-style terminal protocols
- Terminal screen buffers
- Keyboard and mouse input encoding
- Terminal rendering
You will finish with a Rust workspace called mini-terminal that contains a PTY layer, a terminal
core library, a headless emulator, a windowed GUI frontend, a multiplexer server and client, and a
debugger. Every piece is small enough to hold in your head.
The system is deliberately kept small. We do not hide important behavior behind large frameworks
or terminal libraries during the early stages. You will call ioctl(TIOCSCTTY) before you are
allowed to use a PTY crate. You will write a VT parser state machine before you are allowed to look
at vte. That ordering is the entire pedagogy.
This curriculum will not hold your hand. Where it names a function, a struct, or an escape sequence,
it also gives you the command that shows it to you on your machine — because manual pages differ
between Linux and macOS, crates change APIs between versions, and an engineer who quotes remembered
constants instead of running stty -a is already wrong.
Learning Priorities
These are the outcomes, in priority order. Everything in the curriculum exists to serve one of them. When you must choose between finishing a feature and understanding a layer, choose understanding.
| # | Outcome |
|---|---|
| 1 | Understand exactly what happens when a terminal starts a shell. |
| 2 | Understand the difference between a terminal emulator, shell, TTY, PTY, terminal driver, and multiplexer. |
| 3 | Understand the data flow between keyboard input, terminal emulator, PTY, shell, and child processes. |
| 4 | Understand how ANSI escape sequences modify terminal state. |
| 5 | Understand how a terminal maintains its logical screen. |
| 6 | Understand how terminal output becomes rendered pixels or text cells. |
| 7 | Understand process groups, sessions, controlling terminals, signals, and job control. |
| 8 | Understand how a multiplexer can host multiple shell sessions independently of a UI. |
| 9 | Understand how a reusable terminal library such as libghostty separates terminal state, platform integration, rendering, and application concerns. |
| 10 | Build enough of each part manually that you can explain it without relying on abstractions you do not understand. |
Do not optimize for the final output alone. Optimize for insight, experimentation, debugging, and the ability to inspect every layer.
1. The Complete Terminal Stack
Here is the whole system. Read it top to bottom; every box is a chapter of this curriculum. The dashed line is the user space / kernel space boundary — the single most important line on the diagram, because almost every confusion about terminals comes from not knowing which side of it a behavior lives on.
┌───────────────────────────────────────────────────────────────────────┐
│ HUMAN │
│ fingers on keys ──▶ physical key events ──▶ text input │
└───────────────────────────────────────────────────────────────────────┘
│
══════════════════════════════════│═════════════════════════ USER SPACE ═══════
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ TERMINAL EMULATOR (your `mini-terminal` GUI; Ghostty; Alacritty) │
│ ┌───────────────────┐ ┌────────────────────┐ ┌──────────────────┐ │
│ │ windowing / event │ │ input encoder │ │ renderer │ │
│ │ loop (winit) │──▶│ key → bytes │ │ cells → pixels │ │
│ └───────────────────┘ └─────────┬──────────┘ └────────▲─────────┘ │
│ │ │ │
│ ┌─────────▼───────────────────────┴─────────┐ │
│ │ TERMINAL CORE (`terminal-core`) │ │
│ │ UTF-8 decoder → VT parser → screen grid │ │
│ │ cursor, modes, scrollback, alt screen │ │
│ └─────────▲───────────────────────┬─────────┘ │
│ │ output bytes │ input bytes│
└────────────────────────────────────│───────────────────────│────────────┘
│ ▼
read(pty_master) write(pty_master)
══════════════════════════════════════════════════════════════ KERNEL ════════
┌─────────────────────────────────────────────────────────────────────────┐
│ PTY PAIR │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ MASTER side │ ◀── output buffer ─────── │ LINE DISCIPLINE │ │
│ │ /dev/ptmx fd │ ──▶ input buffer ───────▶ │ (terminal driver)│ │
│ └──────────────┘ │ • echo │ │
│ │ • canonical mode│ │
│ │ • ^C → SIGINT │ │
│ │ • CR → NL │ │
│ └─────────┬─────────┘ │
│ │ │
│ ┌─────────────────▼───────────┐ │
│ │ SLAVE side /dev/pts/N │ │
│ │ = the "controlling terminal"│ │
│ │ of a SESSION │ │
│ └─────────────────┬───────────┘ │
│ signals: SIGINT SIGTSTP SIGQUIT SIGWINCH SIGHUP ───────┤ │
│ delivered to the FOREGROUND PROCESS GROUP │ │
└──────────────────────────────────────────────────────────│──────────────┘
══════════════════════════════════════════════════════════════ USER SPACE ════
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ SESSION (session leader = the shell) │
│ ┌───────────────────────┐ ┌────────────────────────────────────┐ │
│ │ pgrp 4242 (foreground)│ │ pgrp 4250 (background) │ │
│ │ bash fd 0,1,2 ─────┼──▶│ sleep 100 & │ │
│ │ └─ ls (child) │ │ │ │
│ └───────────────────────┘ └────────────────────────────────────┘ │
│ job control: shell calls tcsetpgrp() to hand the terminal over │
└─────────────────────────────────────────────────────────────────────────┘
And the multiplexer, which is a separate layer that slots between the emulator and the PTYs:
TERMINAL EMULATOR (Ghostty / your GUI) ← owns pixels, fonts, the window
│ PTY pair A
▼
MUX CLIENT (tmux attached) ← owns nothing durable; draws + forwards keys
│ Unix domain socket
▼
MUX SERVER (tmux server, a daemon) ← owns the PTY masters and the terminal state
│ PTY pair B, C, D …
▼
shells / vim / top ← keep running when the client detaches
Note: There are two terminal emulators in that picture. The mux server contains a terminal emulator per pane (it must, to answer "what is on the screen" after a detach), and the GUI contains one for its own window. Understanding why both exist is the point of Section 4.
2. Emulator vs. Shell vs. TTY vs. PTY vs. Terminal Driver vs. Multiplexer
These six words are used interchangeably by almost everyone, including in documentation. They are six different things. Learn these precisely now; every later confusion traces back to blurring them.
| Thing | What it is | Where it lives | Owns |
|---|---|---|---|
| TTY | Historically a teletypewriter: real hardware on a serial line. Today, a kernel character device that behaves like one. /dev/ttyS0, /dev/tty1 are real ones. | Kernel driver + hardware | Wire-level I/O |
| Terminal driver (line discipline) | The kernel code between a terminal device and the process reading it. Implements echo, line editing, ^C → SIGINT, CR→NL translation, flow control. Configured through termios. On Linux this is N_TTY. | Kernel | Input buffering, echo, signal generation |
| PTY | Pseudo-terminal: a kernel-provided pair of devices that looks like a TTY to the process on one end, and like a pipe-with-superpowers to the program on the other. Master + slave. | Kernel | The illusion that a program is talking to hardware |
| Terminal emulator | A user-space program that owns the PTY master, turns key presses into bytes, parses the bytes coming back, maintains a screen grid, and draws it. Ghostty, Alacritty, xterm, Terminal.app, your mini-terminal. | User space | Pixels, fonts, the window, the screen grid, escape-sequence interpretation |
| Shell | An ordinary user-space program (bash, zsh, fish) that reads command lines, forks and execs programs, and implements job control. It has no idea it is inside a terminal emulator; it only sees fds 0/1/2 attached to a TTY. | User space | Command parsing, process/job management |
| Multiplexer | A user-space program that owns many PTY masters, runs a terminal emulator per pane, composites them into one logical screen, and can survive its UI disconnecting. tmux, screen, zellij. | User space | Session lifetime, pane layout, input routing |
The three sentences that clear up most confusion:
- The shell does not draw anything. It writes bytes. Something else decides what those bytes look like.
- The kernel does not know what a screen is. The line discipline knows about lines and signals, not cursors, colors, or scrollback. The screen is entirely the emulator's invention.
- A PTY is a kernel object, not a program. Both an emulator and a multiplexer are just user-space processes holding the master end of one.
Checkpoint question (answer before you continue): Who owns the terminal screen — the shell, the kernel, or the emulator? Write your answer down. You will be asked again at the end of Section 2.
The full definitions, with the system calls attached, are in The Terminal Mental Model.
3. What Happens When You Open a Terminal and Type ls
This is the trace the whole curriculum is built around. Read it once now — it will not fully land. Read it again after Lab 2, when you have written most of it yourself.
Phase A — the emulator starts a shell
1. You launch the emulator (a normal user-space process).
2. It opens a PTY multiplexer device and gets a MASTER fd.
Linux/macOS: posix_openpt(O_RDWR|O_NOCTTY) → fd on /dev/ptmx
then grantpt(fd); unlockpt(fd); ptsname(fd) → "/dev/pts/7"
3. It sets the initial window size on the master:
ioctl(master, TIOCSWINSZ, &winsize{ ws_row: 24, ws_col: 80, ... })
4. It fork()s. In the CHILD:
a. setsid() → new SESSION; child becomes SESSION LEADER,
and drops any inherited controlling terminal
b. open("/dev/pts/7") → SLAVE fd
c. ioctl(slave, TIOCSCTTY,0) → the slave becomes this session's CONTROLLING TERMINAL
d. dup2(slave,0); dup2(slave,1); dup2(slave,2)
e. close(slave); close(master) ← the child must not hold the master
f. setenv("TERM", "xterm-256color")
g. execve("/bin/bash", ...)
5. In the PARENT (the emulator): close the slave fd, keep the master, start the event loop.
At the end of Phase A: bash is running with fds 0/1/2 on /dev/pts/7, it is a session leader, its
controlling terminal is the PTY slave, and its process group is the foreground process group of
that terminal.
Phase B — the shell prints a prompt
6. bash writes "user@host:~$ " → write(1, ...) on the slave.
7. The line discipline applies OUTPUT processing (OPOST/ONLCR: "\n" → "\r\n").
8. Bytes land in the PTY's output buffer; the master fd becomes readable.
9. The emulator's poll()/epoll()/kqueue() wakes up → read(master, buf, N).
10. The bytes go through: UTF-8 decoder → VT parser → screen-grid mutation → dirty rows.
11. The renderer draws the dirty rows as glyphs. You see a prompt.
Phase C — you type ls and press Enter
12. Key press 'l':
windowing layer gives you a key event (physical key + modifiers + text)
input encoder decides the bytes: plain 'l' → 0x6C
write(master, [0x6C], 1)
13. The LINE DISCIPLINE, in the KERNEL, now does two things because the terminal is in
CANONICAL mode with ECHO on (the default a shell sets up for itself... see note):
a. ECHO: it writes 0x6C back out toward the master → the emulator reads it and
draws an 'l'. *The shell never saw this byte yet.*
b. It appends 0x6C to the current LINE BUFFER. read() on the slave does NOT return.
14. Same for 's'.
15. Enter: the emulator sends 0x0D (CR). ICRNL translates CR → 0x0A (NL) on input.
NL is the canonical-mode line terminator → the line "ls\n" is released:
bash's blocking read(0, ...) finally returns "ls\n".
Note: Interactive shells like
bashandzshactually put the terminal into raw-ish mode themselves so that readline/ZLE can do their own line editing and history. The canonical-mode description above is exactly what happens for a plaincatorread, and it is the behavior the kernel provides by default. You will observe both in Lab 4. Do not skip this distinction — it is the single most common misunderstanding about terminals.
Phase D — the shell runs ls
16. bash parses "ls", fork()s.
17. In the child: setpgid(0,0) → a NEW PROCESS GROUP for the job.
18. bash calls tcsetpgrp(tty_fd, child_pgid) → hands the terminal's FOREGROUND
PROCESS GROUP to the job. (This is job control. It is why ^C goes to `ls`.)
19. child: execve("/bin/ls", ...).
20. ls writes its output → write(1, ...) → line discipline → PTY output buffer → master.
21. Emulator reads, parses (including any SGR color sequences ls emitted), mutates the grid,
renders.
22. ls exits → SIGCHLD to bash → bash waitpid()s it, calls tcsetpgrp(tty_fd, bash_pgid)
to take the terminal back, prints a new prompt.
Phase E — signals, the part everyone gets wrong
If you press Ctrl+C during step 20:
emulator writes byte 0x03 to the master
→ line discipline sees VINTR (0x03) with ISIG set
→ the KERNEL raises SIGINT and delivers it to the FOREGROUND PROCESS GROUP
(which is `ls`'s group, NOT bash's, because of step 18)
→ the byte 0x03 is CONSUMED; `ls` never reads it as data.
If the emulator's window is resized:
emulator: ioctl(master, TIOCSWINSZ, &new_winsize)
→ the KERNEL sends SIGWINCH to the FOREGROUND PROCESS GROUP of that terminal
→ a full-screen program (vim, top) catches it, calls ioctl(0, TIOCGWINSZ, &ws),
and redraws at the new size.
If the emulator closes the master fd:
→ the KERNEL sends SIGHUP to the session leader (bash)
→ bash HUPs its jobs and exits. ("nohup" and tmux exist because of this line.)
That is the whole system. Every remaining page of this curriculum is one of those numbered steps in detail, with code you write and an experiment that proves it.
4. The Proposed Rust Workspace
You build one Cargo workspace. Crates appear as the milestones need them — you do not create all of these on day one.
mini-terminal/
├── Cargo.toml # [workspace] members = [...]
└── crates/
├── terminal-protocol/ # bytes → parsed actions. NO screen, NO PTY, NO I/O.
├── terminal-core/ # actions → screen state. Grid, cursor, modes, scrollback.
├── terminal-input/ # key/mouse events → bytes. Mode-aware. No windowing types.
├── terminal-pty/ # PTY creation, process spawn, resize, child reaping. Unix-only.
├── terminal-render-model/ # a renderer-independent, snapshot-able view of the screen.
├── terminal-gui/ # winit + softbuffer/wgpu. The only crate that knows about pixels.
├── terminal-mux/ # sessions, windows, panes, client/server protocol.
├── terminal-debugger/ # hex dumps, parser tracing, record/replay, snapshot diffs.
└── terminal-cli/ # headless binary: run a command, print a screen snapshot.
The dependency rule you will enforce with cargo tree and CI:
terminal-protocol ──▶ (nothing but std + unicode crates)
terminal-core ──▶ terminal-protocol
terminal-input ──▶ terminal-core (for mode flags only)
terminal-pty ──▶ (nix/rustix + std) ← knows nothing about escape sequences
terminal-render-model ──▶ terminal-core
terminal-gui ──▶ render-model, input, core, pty
terminal-mux ──▶ core, pty, protocol ← NEVER depends on terminal-gui
terminal-cli ──▶ core, pty, debugger
Warning: The single most important arrow in that graph is the one that does not exist:
terminal-muxmust never depend onterminal-gui. The moment it does, you have lost the ability to run a session without a display, and you have lost detach/attach. This is the same boundary libghostty draws, and it is the subject of Section 5.
Each crate's responsibility, what it must not know about, its public API, internal state, test strategy, and platform-independence are specified in Workspace Design.
5. The First Three Milestones
The full 15-milestone sequence with completion criteria is in The Roadmap. Here are the first three, which are all of Section 1.
| Milestone | Goal | Done when |
|---|---|---|
| M0 — Terminal mental model | Explain the stack without code. | You can answer all 12 questions in Section 7 below, out loud, without notes. |
| M1 — Raw byte inspector | Put your own terminal into raw mode and print the exact bytes every key produces. | Arrow keys show 1b 5b 41, Ctrl+C shows 03 (and does not kill your program), and you restore the terminal cleanly on exit — including on panic. |
| M2 — PTY shell runner | Launch a shell through a PTY and relay bytes both ways. | cargo run gives you a working shell inside your program; ps -o pid,pgid,sid,tpgid,tty inside it shows a /dev/pts/N you can identify; the program exits cleanly when the shell exits. |
| M3 — PTY event loop | Handle input, output, resize, signals, and child termination properly. | Resizing your outer terminal resizes the inner one (vim redraws correctly); Ctrl+C interrupts the inner foreground job and not your relay; the child's exit is detected via SIGCHLD/waitpid, not by guessing. |
6. The First Hands-On Exercise
Start here: Lab 1 — The Raw Keyboard Byte Inspector.
It is deliberately tiny: about 120 lines of Rust, no PTY yet, no crates beyond libc. It puts
your own terminal into raw mode, reads stdin one chunk at a time, and prints every byte in hex,
with the ANSI escape sequences decoded. It is heavily instrumented, because the point is not the
program — the point is that you see, with your own eyes, that the arrow key is three bytes, that
Ctrl+C is one byte, and that the terminal you have used for years has been lying to you about what a
"character" is.
Before writing it, you will be asked to predict the output. Do the prediction in writing. The gap between your prediction and the result is the actual lesson.
7. Questions to Answer Before the PTY Implementation
You are ready to move from M1 to M2 when you can answer all of these without looking anything up. They are re-asked as the validation gate at the end of Lab 1.
- What is the difference between a TTY and a PTY, and what problem does the pseudo part solve?
- Which end of a PTY pair does a terminal emulator hold, and which end does the shell hold? Why can they not be swapped?
- What is the line discipline, which side of the user/kernel boundary is it on, and name three things it does.
- In canonical mode, why does a program's
read()not return when you press a letter key? - What is echo, and which component performs it by default — the shell, the emulator, or the kernel?
- What does
setsid()do, and why must the child call it beforeTIOCSCTTY? - What is a controlling terminal, what owns one (a process, a process group, or a session), and how is one acquired?
- What is a process group, what is the foreground process group, and which system call changes it?
- When you press Ctrl+C, which component turns the byte
0x03into a signal, and which processes receive that signal? - What is
SIGWINCH, who sends it, who receives it, and what system call must the receiver make afterwards to be useful? - What happens to a shell if you connect it to a pipe instead of a PTY? Name three concrete behavioral differences.
- Who owns the terminal screen — the shell, the kernel, or the emulator?
Tip: Write your answers into a file called
answers-m0.mdin your workspace and commit it. At the end of the curriculum you will re-answer them and diff. That diff is your progress report.
Who This Is For
This curriculum is designed for engineers who:
- Have solid systems fundamentals: processes, file descriptors, signals,
fork/exec, blocking vs. non-blocking I/O. If any of those words are fuzzy, you will still make it, but slow down in Section 1. - Are comfortable in Rust — ownership, traits, enums with data,
unsafeblocks and why they are scary. You do not need to be an expert; you do need to be able to read a compiler error and fix it without cargo-culting. - Are on Linux or macOS. Windows is discussed architecturally (ConPTY) but is not required for any implementation.
- Want to be able to explain a system, not just ship one.
You do not need prior knowledge of VT100, ANSI escape sequences, or terminal internals. That is what you are here to build.
Restrictions (Read These; They Are the Discipline)
These constraints are what make the curriculum work. They are enforced by the ordering of the material, and breaking them will produce a working program you cannot explain.
- Do not begin with a production-sized codebase.
- Do not hide the PTY behind a large abstraction before you have explained the system calls.
(
portable-ptyis introduced only after you have written the raw version.) - Do not use an existing full terminal emulator core (
vte,alacritty_terminal,termwiz) during the initial implementation. They appear in differential testing and nowhere earlier. - Do not implement every ANSI or VT feature immediately. Version 1 of the parser handles seven things.
- Do not introduce GPU rendering too early. CPU rendering first;
wgpuis an optional optimization in Section 3. - Do not conflate the terminal emulator with the shell.
- Do not conflate the terminal emulator with the multiplexer.
- Do not skip Unix process and signal semantics. They are Section 1 for a reason.
- Do not optimize prematurely.
- Do not write code without tests and experiments.
- Do not move to the next phase until the current behavior can be inspected and explained.
Crates such as nix, rustix, portable-pty, mio, and tokio may be introduced — but every time
one is, this book first shows you the underlying Unix API it wraps, then the Rust abstraction. That
ordering is never reversed.
What You Will Be Able to Do
| Capability | Description |
|---|---|
| Drive a PTY by hand | posix_openpt, grantpt, unlockpt, ptsname, setsid, TIOCSCTTY, dup2, execve — from scratch, no crates |
| Explain job control | Sessions, process groups, foreground groups, tcsetpgrp, SIGTTIN/SIGTTOU, orphaned groups, SIGHUP |
| Configure a terminal driver | Read and write termios, switch canonical/raw, control echo, set VMIN/VTIME, restore reliably |
| Write a VT parser | A real state machine over ESC/CSI/OSC/DCS, with UTF-8 decoding layered correctly |
| Maintain a screen | Grid, cursor, pending wrap, scroll regions, alternate screen, scrollback, wide characters |
| Encode input | Arrows, function keys, modifiers, application cursor keys, bracketed paste, SGR mouse reporting |
| Render text | Font metrics, baselines, glyph rasterization, an atlas, damage tracking, frame timing |
| Build a multiplexer | Sessions/windows/panes, a Unix-socket client/server protocol, detach/attach, resize negotiation |
| Design a reusable core | Draw the libghostty-style boundaries and defend them; expose a C ABI when the Rust API is stable |
| Debug any terminal | Hex-dump, decode, record, replay, step a parser, diff two screen snapshots |
How This Curriculum Is Organized
| Part | What it covers |
|---|---|
| Overview | Prerequisites, the history, the warm-up, the weekly plan, the mental model, the workspace design, the 15-milestone roadmap, the teaching method |
| Section 1: PTY Laboratory | Unix process/terminal semantics. Six concept chapters, five labs. Milestones 1–3. |
| Section 2: Minimal Terminal Emulator | The parser, the screen model, escape sequences, modes. Eight chapters, six labs. Milestones 4–6. |
| Section 3: Graphical Frontend | Windowing, input encoding, fonts, rendering. Milestones 7–8. |
| Section 4: Multiplexer | Sessions, panes, the server, detach/attach. Milestones 9–12. |
| Section 5: Reusable Architecture | The libghostty-style study: crate boundaries, embedding, FFI. Milestone 13. |
| Testing Strategy | Unit, golden, PTY integration, differential, interactive. |
| Observability | The terminal-debugger tool: record, replay, step, diff. |
| Platform Notes | Linux PTYs vs. macOS PTYs vs. Windows ConPTY. |
| Capstone | Everything combined, with an evaluation rubric. Milestone 14. |
| Capstone Portfolio | Eight larger, self-directed projects — several of them plausible upstream contributions |
| Appendices | Glossary, escape-sequence and termios cheat sheets, the key-encoding table, primary sources, and the ecosystem. |
The concept chapters are not optional background — they are where the depth lives. A lab says
"set the window size"; the window size chapter
is where you learn why a program that never receives SIGWINCH renders garbage forever. Treat the
labs as the spine and the concept chapters as the muscle.
Begin with the Overview & Prerequisites. Then read
The Hitchhiker's Guide for why terminals are the way they are, spend
an evening on The Warm-Up, and work through
The Terminal Mental Model — that is Milestone 0 — before starting
Section 1. A starter workspace
with the crate skeletons and their failing test suites is waiting in book/projects/mini-terminal/.