Termie is a GUI terminal emulator. In one sentence:
Termie is the puppeteer that holds a PTY master — it
forkptys a login shell onto the slave, then every egui frame it drains the master (shell output → screen) and feeds the master (keystrokes → shell input).
This doc is diagram-first and covers every module. Code anchors are written
as path::function so they survive line drift.
main.rs ─► gui/ ─────────────► terminal_emulator/ ───────► io/
(args, (egui app, frame (state machine that (TermIo backends:
replay) loop, input map) turns bytes into a screen) real pty / replay)
terminal_emulator/
mod.rs TerminalEmulator<Io> — owns everything, drives read/write
io/pty.rs PtyIo — forkpty, master fd, TIOCSWINSZ ── Part 2
io/mod.rs TermIo trait — read / write / set_win_size ── Part 7
ansi.rs AnsiParser — bytes → TerminalOutput ops ── Part 5
buffer.rs TerminalBuffer — the on-screen character grid ── Part 6
format_tracker.rs FormatTracker — color/bold spans over the grid ── Part 6
recording.rs Recorder / Recording — snapshot + byte log → JSON ── Part 7
replay.rs ReplayIo / ReplayControl — play a recording back ── Part 7
error.rs, log.rs backtraced_err, log! — error-chain fmt + TERMIE_LOG
Parts: 1 PTY mental model · 2 building the PTY · 3 runtime loop · 4 curses & terminfo · 5 the ANSI parser · 6 the screen model · 7 record & replay.
A "terminal" used to be physical hardware: a keyboard + screen on a serial
cable. The kernel's terminal driver (the line discipline) sat in the
middle doing echo, line editing, Ctrl-C → SIGINT, and window size.
PHYSICAL WORLD INSIDE THE KERNEL PROGRAM
┌──────────────┐ ┌────────────────────────┐ ┌─────────┐
│ TERMINAL │ │ TERMINAL DRIVER │ │ shell │
│ ┌──────────┐ │ cable │ (the "tty") │ │ │
│ │ keyboard ─┼─┼────────┼──► • echo what's typed │────►│ read() │
│ │ screen ◄─┼─┼────────┼─── • backspace / ctrl-U │◄────┤ write() │
│ └──────────┘ │ │ • ctrl-C → SIGINT │ │ │
└──────────────┘ │ • line buffering │ └─────────┘
hardware │ • window size, raw │
└────────────────────────┘
A pseudo-terminal (PTY) is that exact driver with the hardware sawn off and a file descriptor screwed onto the stump. The driver is unchanged; only the far "keyboard + screen" end is replaced by a program (here, termie).
┌─────────────┐ ┌──────────────────────┐ ┌─────────┐
│ TERMIE │ │ TERMINAL DRIVER │ │ shell │
│ (the GUI) │ ════► │ (still 100% real) │ ════► │ read() │
│ plays the │ MASTER │ echo, ctrl-C, winsz │ SLAVE │ write() │
│ keyboard + │ end │ │ end │ │
│ screen │ ◄════ │ │ ◄════ │ │
└─────────────┘ └──────────────────────┘ └─────────┘
PtyIo.fd ◄── termie holds THIS shell's fd 0/1/2 ──┘
| End | Old physical role | Who plugs in (termie) | Looks like |
|---|---|---|---|
slave /dev/pts/N |
hardware port the shell talked through | the $SHELL -l child |
a real terminal — isatty = true |
master /dev/ptmx |
the keyboard + screen on the cable | termie's PtyIo.fd |
a raw byte fd you read/write |
The data flow is crossed, because the master impersonates a human:
MASTER (termie) TERMINAL DRIVER SLAVE (shell)
write("ls\n") ──────────────► treated as ──► read() gets "ls\n"
"I am typing" KEYSTROKES "someone typed!"
read() gets "file1\n" ◄───── treated as ◄── write("file1\n")
"I am the screen" SCREEN OUTPUT "printing output"
A plain pipe won't do: it is a dumb byte tube with no echo, no Ctrl-C, no
window size, so isatty is false and curses programs break. The PTY gives termie
the semantics of a terminal, not just the bytes.
All of this lives in terminal_emulator/io/pty.rs.
nix::pty::forkpty(None, None) ← pty::spawn_shell()
│
│ forkpty bundles the entire recipe:
│ ┌─────────────────────────────────────────────────┐
│ │ open /dev/ptmx → grantpt → unlockpt → ptsname │
│ │ fork() │
│ │ child: setsid() │
│ │ open(/dev/pts/N) ⇒ controlling tty │
│ │ dup2 slave → fd 0,1,2 │
│ └─────────────────────────────────────────────────┘
▼
┌──────────────────────────────┬───────────────────────────────┐
│ PARENT (termie) │ CHILD (becomes the shell) │
├──────────────────────────────┼───────────────────────────────┤
│ ForkResult::Parent │ ForkResult::Child │
│ return res.master ──► PtyIo │ set TERM=termie │
│ │ set TERMINFO=<tempdir> │
│ │ execvp($SHELL, [SHELL, "-l"]) │
│ │ └► login shell, fd0/1/2=slave│
└──────────────────────────────┴───────────────────────────────┘
The parent keeps res.master and wraps it in PtyIo { fd, _terminfo_dir }
(pty::PtyIo::new). The child never returns — it execvps the shell and from
that moment its stdin/stdout/stderr are the slave terminal.
set_nonblock(&fd) ← fcntl O_NONBLOCK on the master
│
▼
PtyIo::read → nix::read(master)
├─ Ok(n) → ReadResponse::Success(n)
└─ Err(EAGAIN) → ReadResponse::Empty ← "nothing right now,
paint the frame anyway"
A blocking read would freeze egui between keystrokes. Non-blocking turns "no data
yet" into ReadResponse::Empty, which the frame loop treats as done draining.
set_window_size_ioctl (ioctl_write_ptr_bad! macro, TIOCSWINSZ)
▲
PtyIo::set_win_size(width, height)
▲
TerminalEmulator::set_win_size ─ only fires the ioctl when size changed
▲
gui: each frame measures the char grid from the egui panel
forkpty is called with None winsize; the real size is pushed right after, in
TerminalEmulator::<PtyIo>::new (initial 50×16), and again on every resize.
The third spawn detail — bundling
TERM=termieterminfo — needs its own section, because it is why terminal apps can drive termie at all. See Part 4.
┌──────────────────────────── TERMIE PROCESS ───────────────────────────┐
│ │
│ eframe/egui event loop terminal_emulator │
│ ┌────────────────────┐ ┌──────────────────────────────────┐ │
│ │ TermieGui::update │ │ TerminalEmulator<PtyIo> │ │
│ │ (gui/mod.rs) │ │ │ │
│ │ │ set_win │ parser: AnsiParser │ │
│ │ measure grid ──────┼─────────►│ terminal_buffer: TerminalBuffer │ │
│ │ │ _size │ format_tracker: FormatTracker │ │
│ │ TerminalWidget │ │ cursor_state / decckm_mode │ │
│ │ ::show ───────────┼─ read() ►│ recorder: Recorder │ │
│ │ (gui/terminal.rs) │ │ io: PtyIo ───────┐ │ │
│ │ egui InputEvent │ └────────────────────┼────────────┘ │
│ │ └─► write(in) ┼─ write() ──────────────────────┘ │ │
│ └────────────────────┘ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ PTY master │ │
│ └──────┬──────┘ │
└──────────────────────────────────────────────────────── │ ───────────┘
PTY pair │
┌──────────────────────────────────────────────────────────▼───────────┐
│ CHILD: $SHELL -l (fd 0/1/2 = /dev/pts/N slave) │
└────────────────────────────────────────────────────────────────────────┘
Every egui repaint runs this. Output drains first, then input is sent, then the buffer is painted.
TermieGui::update each frame
│
├─ measure char grid ─► TerminalEmulator::set_win_size ─► (TIOCSWINSZ if changed)
│
└─ TerminalWidget::show
│
├─ 1. terminal_emulator.read() ── DRAIN OUTPUT
│ loop io.read(4096) until ReadResponse::Empty
│ → recorder.write(bytes)
│ → handle_incoming_data(bytes) (see Part 5)
│
├─ 2. paint terminal_buffer + format tags to the egui canvas
│
└─ 3. for each egui InputEvent ── SEND INPUT
map → TerminalInput (Ascii/Ctrl/Enter/Arrows/…)
terminal_emulator.write(input)
→ to_payload(decckm_mode) (arrows depend on DECCKM)
→ io.write(bytes) → PTY master
gui/terminal.rs::TerminalWidget owns the keyboard map (egui Key →
TerminalInput) and the painting; mod.rs::TerminalEmulator::write turns a
TerminalInput into the actual escape bytes via to_payload.
This is the question "what does curses do and why does everything use it." The short version: terminals disagree on their escape codes, so programs delegate the byte-level dialect to a library + a database.
Historically every terminal model (VT100, VT220, xterm, …) used different byte sequences for the same action. "Move cursor to row 3 col 5", "clear the screen", "turn on bold" — each was a different escape string per terminal. A program that hard-coded VT100 codes would render garbage on something else.
┌──────────────┐ high-level calls ┌──────────────┐ reads ┌─────────────┐
│ TUI program │ move(y,x)/addstr() │ ncurses │ $TERM │ terminfo │
│ vim,htop,top │ ───────────────────► │ (library) │ ──────────► │ database │
└──────────────┘ attron(BOLD) └──────┬───────┘ └─────────────┘
│ emits the EXACT bytes
│ for THIS terminal
▼
escape sequence stream ─► the terminal
- terminfo — a database, one entry per terminal type, keyed by the
$TERMenv var. Each entry lists capabilities: booleans (am= auto-margins), numbers (colors#8), and string recipes (cup=\E[%i%p1%d;%p2%dH= the parameterized bytes to position the cursor). - curses / ncurses — a library. The program calls
move,addstr,attron(A_BOLD),refresh(); ncurses looks up$TERM, finds the right recipe for this terminal, and emits those bytes. It also keeps a model of the screen and emits only the cells that changed.
- Portability — write once against curses; the terminfo entry handles the per-terminal dialect.
- Minimal redraw — curses diffs the screen and sends the smallest update, which mattered enormously over slow links and still saves work.
- Primitives — windows, color pairs, raw/cooked input, and keypad decoding
(turning an incoming
\EOAback into "the Up arrow was pressed").
Termie is a terminal whose "dialect" is exactly what ansi.rs can parse. So it
ships its own terminfo entry named termie (res/termie.ti), compiled by
tic at build time, tarred into the binary, and unpacked per-process:
build.rs: tic res/termie.ti ─► $OUT_DIR/terminfo ─► terminfo.tar (include_bytes!)
│
PtyIo::new ─► extract_terminfo() unpack ─► <tempdir>/terminfo/...
│
child env: TERM=termie TERMINFO=<tempdir>
│
▼
ncurses in the shell reads termie's capabilities and emits ONLY
sequences termie understands.
The terminfo file and the parser are two halves of one contract:
res/termie.ti (what termie ADVERTISES) ansi.rs (what termie PARSES)
───────────────────────────────────────── ───────────────────────────────────
cup=\E[%i%p1%d;%p2%dH position cursor ◄─► CSI 'H' → SetCursorPos
cuu/cud/cuf/cub=\E[%p1%d A/B/C/D ◄─► CSI A/B/C/D → SetCursorPosRel
ed=\E[J el=\E[K E3=\E[3J clear* ◄─► CSI 'J'/'K' → ClearForwards/All/Line
ich=\E[%p1%d@ insert spaces ◄─► CSI '@' → InsertSpaces
il=\E[%p1%dL dch=\E[%p1%dP ◄─► CSI 'L'/'P' → InsertLines/Delete
colors#8 (8 ANSI colors) ◄─► CSI 'm' → Sgr(color/bold)
smkx=\E[?1h rmkx=\E[?1l (DECCKM) ◄─► CSI '?1' h/l → Set/ResetMode(Decckm)
kcuu1=\EOA … (arrows in application mode) ◄─► to_payload emits \EOA when decckm
The last two rows are the elegant bit. When an app turns on cursor-key mode it
sends smkx (\E[?1h); ansi.rs parses ?1 h → SetMode(Decckm) and termie
flips decckm_mode = true. From then on termie encodes its own arrow keys as
\EOA/\EOB/… (mod.rs::to_payload) — exactly the kcuu1/kcud1 strings the
same terminfo advertises. Output parsing and input encoding stay consistent
because both read from one definition.
termie.ti is derived from dumb plus a hand-added CSI subset — deliberately
small, matching the parser's coverage. Anything outside the table, ansi.rs
logs as "Unhandled" and emits TerminalOutput::Invalid.
AnsiParser::push(&[u8]) is a byte-at-a-time state machine. It returns a
Vec<TerminalOutput> — structured ops the emulator can apply. Printable runs are
coalesced into TerminalOutput::Data.
bytes from the shell
│
┌──────────────────────▼─────────────────────────┐
│ AnsiParserInner::Empty │
│ printable ─► push into data_output (Data) │
│ \r ─► CarriageReturn \n ─► Newline │
│ 0x08 ─► Backspace │
│ 0x1B (ESC) ─────────────────────────┐ │
└─────────────────────────────────────── │ ───────┘
▼
┌── AnsiParserInner::Escape ──┐
'[' │ │ ']'
┌───────────────┤ else ─► warn, back to Empty├──────────────┐
▼ └─────────────────────────────┘ ▼
┌─ Csi(CsiParser) ───────────────────────────┐ ┌─ Osc ──────────────┐
│ Params collect 0x30–0x3f (digits;?) │ │ swallow until BEL │
│ │ 0x20–2f │ │ (0x07) or ESC \ │
│ ▼ │ │ ─► back to Empty │
│ Intermediates collect 0x20–0x2f │ └────────────────────┘
│ │ 0x40–7e (final byte) │
│ ▼ │
│ Finished(b) ─► emit op, back to Empty │
│ Invalid ─► consume to terminator, Invalid op │
└──────────────────────────────────────────────┘
│
final byte → op:
'H'/'G' → SetCursorPos 'A/B/C/D' → SetCursorPosRel
'J' → ClearForwards/ClearAll 'K' → ClearLineForwards
'm' → Sgr(color/bold, incl. 256 & rgb) '@' → InsertSpaces
'L' → InsertLines 'P' → Delete 'h'/'l' → Set/ResetMode
anything else → Invalid (logged)
Then mod.rs::handle_incoming_data walks the ops and mutates the three pieces of
state — TerminalBuffer, FormatTracker, CursorState:
AnsiParser::push ─► [TerminalOutput]
│
├─ Data(bytes) → buffer.insert_data (+ format range adjust)
├─ SetCursorPos/Rel → cursor_state.pos
├─ ClearForwards/All → buffer.clear_* + format_tracker
├─ Newline/CR/Backsp → cursor_state.pos
├─ Sgr(...) → cursor_state.color / bold (or Decckm via SetMode)
├─ InsertLines/Spaces → buffer.* + format range adjust
└─ Delete → buffer.delete_forwards + format_tracker.delete_range
(SetMode(Decckm)/ResetMode(Decckm) toggle decckm_mode, which later changes
how arrow-key input is encoded — see Part 4.)
The screen is not a 2-D grid. It is a single Vec<u8>; line boundaries are
derived by calc_line_ranges(buf, width), which splits on \n or at width
(soft wrap). "Visible" is the bottom height lines; everything above is
scrollback.
buf: "ls\n0123456789abc\n$ " width = 5 height = 3
│ calc_line_ranges(width=5)
▼
line 0 "ls" 0..2 ┐
line 1 "01234" 3..8 │ scrollback (above the bottom `height`)
line 2 "56789" 8..13 ┘
line 3 "abc" 13..16 ┐
line 4 "$ " 17..19 ┘ visible = bottom 3 lines
│
data() ─► { scrollback: &buf[0..start], visible: &buf[start..] }
cursor (x,y) ──► buf offset = visible_line_ranges[y].start + x (and back)
Writes go through pad_buffer_for_write: it appends \n until the cursor's row
exists and inserts spaces until the cursor's column exists, then overwrites.
It returns the inserted_padding range so the FormatTracker can shift its
spans by the same amount. set_win_size re-derives the cursor by mapping its old
buffer offset under the new width — so text stays put relative to the cursor on
resize.
Color/bold are stored as a sorted list of non-overlapping FormatTag { start, end, color, bold } over buffer offsets, seeded with one default tag
0..usize::MAX. Writing color carves a new span in; the existing spans split or
trim around it.
seed: [0 ───────────────────────────── MAX) Default
push_range(Yellow, 3..10):
[0──3)Default │ [3────────10)Yellow │ [10────MAX)Default
push_range(Blue, 5..7):
[0─3)Def │ [3─5)Yel │ [5─7)Blue │ [7─10)Yel │ [10──MAX)Def
└ the Yellow span is split around the new Blue ┘
push_range(cursor, range)— carve in the cursor's current color/bold (adjust_existing_format_rangeshandles the 4 overlap cases).push_range_adjustment(range)— when the buffer grows (inserted padding/lines) slide every later span by the inserted length, so spans keep tracking the same characters.delete_range(range)— remove/shrink/shift spans when characters are deleted.- At paint time,
mod.rs::split_format_data_for_scrollbackcuts the tag list at the scrollback/visible boundary so each region paints with correct colors.
TerminalEmulator<Io: TermIo> is generic over its I/O backend; the parser /
buffer / render pipeline never knows whether bytes come from a live shell or a
saved file.
trait TermIo (io/mod.rs)
read() · write() · set_win_size()
▲ ▲
┌────────────┘ └─────────────┐
┌──────────────┐ ┌───────────────────┐
│ PtyIo │ │ ReplayIo │
│ (io/pty.rs) │ │ (replay.rs) │
│ live forkpty │ │ recorded bytes via │
│ + master fd │ │ an mpsc channel │
└──────────────┘ └───────────────────┘
▲ ▲
TerminalEmulator<PtyIo>::new TerminalEmulator<ReplayIo>::from_snapshot
▲ ▲
gui::run (main, no --replay) gui::run_replay (main, --replay <file>)
A Recording (serialized to recordings/N.json via SnapshotItem + tinyjson):
Recording {
initial_state: snapshot of parser + buffer + format_tracker + cursor
at the moment recording started (so replay can fast-start)
items: [ Write { data: [bytes] }, ◄─ every chunk the shell emitted
SetWinSize { width, height }, ◄─ every resize
Write { ... }, ... ]
}
SnapshotItem (recording.rs) is a tiny JSON-shaped tree (Bool/Int/String/ Array/Map) that every stateful struct implements snapshot() / from_snapshot()
against — that is the …snapshot machinery scattered through ansi.rs,
buffer.rs, format_tracker.rs, and mod.rs.
RECORD (live) REPLAY (--replay N.json)
───────────────────────── ─────────────────────────────
shell ─► PtyIo.read Recording::load(N.json)
│ │
TerminalEmulator::read ReplayControl::new
├─ recorder.write(bytes) ─┐ │ io_handle() ─► ReplayIo
└─ handle_incoming_data │ from_snapshot(initial_state)
▼ │
Recorder (Weak handle, step ─► ReplayControl::next:
coalesces consecutive Writes) Write{data} ─► mpsc.send ─► ReplayIo.read
│ SetWinSize ─► set_win_size
on handle drop: │
Recording::to_json ─► recordings/N.json SAME parser→buffer→FormatTracker
→egui pipeline, driven by a slider
The replay GUI (gui/mod.rs::ReplayTermieGui) adds a seek slider: stepping
forward calls ReplayControl::next (feeding more recorded bytes through
ReplayIo); seeking backward rebuilds from initial_state and replays N steps.
Because both modes run the identical pipeline, a replay is byte-for-byte what the
live session showed.
| Concern | Location |
|---|---|
Build the PTY (forkpty, exec shell) |
terminal_emulator/io/pty.rs::spawn_shell |
Non-blocking master, read/write, TIOCSWINSZ |
terminal_emulator/io/pty.rs (PtyIo) |
Bundle/extract terminfo, TERM=termie |
pty.rs::extract_terminfo + build.rs + res/termie.ti |
| I/O backend abstraction | terminal_emulator/io/mod.rs (TermIo, ReadResponse) |
| Emulator core (state + ops) | terminal_emulator/mod.rs (TerminalEmulator) |
| Byte stream → structured ops | terminal_emulator/ansi.rs (AnsiParser, CsiParser) |
| Character grid + scrollback + wrap | terminal_emulator/buffer.rs (TerminalBuffer) |
| Color/bold spans | terminal_emulator/format_tracker.rs (FormatTracker) |
| Snapshot tree + JSON record | terminal_emulator/recording.rs (SnapshotItem, Recorder) |
| Replay backend + seek control | terminal_emulator/replay.rs (ReplayIo, ReplayControl) |
| GUI shell, frame loop, resize, debug panel | gui/mod.rs (TermieGui::update) |
| Replay GUI + slider | gui/mod.rs (ReplayTermieGui) |
| Input mapping, drain+paint | gui/terminal.rs (TerminalWidget::show) |
Entry point, --replay / --recording-path |
main.rs |
| Error-chain formatting | error.rs (backtraced_err) |
Logging + TERMIE_LOG filtering |
log.rs (debug!/info!/warn!/error!) |