DESIGN.md@docs · git:20260908.fc3f54e · 2026-09-08 · sha256 799449071fc5475a
DESIGN.md@docs git:20260908.fc3f54eA
Immutable. This exact content is served forever at /api/v1/blob/799449071fc5475a.
# termlens — Design
Condensed architecture notes. This document is the contract for anything
touching wait semantics, the snapshot format, or the emulator boundary —
change those only with a matching change here.
## 1. Four layers
```mermaid
flowchart TB
test["test code"]
l4["4 · assertions<br/>wait_until / wait_frame / wait_idle / wait_exit · Screen queries · insta snapshots"]
l3["3 · Screen<br/>immutable Arc-backed grid snapshots · Cell · Style · cursor · title & modes — termlens's own types"]
l2["2 · emulator<br/>internal trait: process · snapshot · mid_sequence · in_sync_update · input_modes · mode_state · set_size<br/>backend: vt100"]
l1["1 · PTY<br/>portable-pty · reader thread · resize TIOCSWINSZ → SIGWINCH · lifecycle lock"]
app["child app<br/>spawned in the PTY, unmodified"]
app -->|"escape-sequence bytes"| l1
l1 -->|"reader thread · mutate under lock, notify waiters"| l2
l2 -->|"snapshot on demand"| l3
l3 -->|"predicates · dumps embedded in every timeout"| l4
l4 --> test
test -.->|"send · click · drag · paste · resize · signal"| l1
l1 -.->|"stdin bytes · SIGWINCH"| app
classDef ours fill:#2563eb,color:#ffffff,stroke:#1d4ed8,stroke-width:1px;
class l1,l2,l3,l4 ours
```
Data flows up (solid): child writes → PTY master → reader thread →
emulator → screen snapshots → assertions. Input flows down (dashed),
encoded to match the modes the application enabled (§6) → PTY master →
child.
**The reader thread** is the linchpin. It drains the PTY *continuously*, not
just inside `wait_*` calls, so a chatty program can never fill the kernel
PTY buffer and deadlock, and no output is lost "between" waits. It owns
nothing but a reader handle and the shared `Monitor<EmuState>` (mutex +
condvar): every chunk is fed to the emulator under the lock, then waiters
are notified. On EOF (child exited and released the terminal — reported as
0-read on macOS, EIO on Linux) it sets the `eof` flag and exits. It is
never joined: a grandchild holding the PTY open must not hang `Drop`.
### The reader answers queries
Applications ask their terminal questions — DSR `CSI 6 n` (cursor
position), DA1/DA2 (device attributes), `CSI 18 t` (text-area size),
`OSC 10/11 ; ?` (colors) — and block on the reply. A mute harness turns
every capability probe into a hang. termlens therefore answers, by
default, exactly what a real terminal answers and nothing more: the DA1
identity is VT220-with-color (`?62;22c`), claiming no feature the
emulator cannot render, and kitty's `CSI ? u` probe is deliberately left
unanswered because its protocol resolves via the DA1 reply, exactly as
on a real non-kitty terminal.
`DECRQM` ("is private mode *n* set?") is answered too, and answering it
is what lets an application that *probes* before using synchronized
output turn it on against termlens — so `wait_frame` can work against a
program nobody modified for us. Every reply is truthful or absent:
modes whose state the emulator holds exactly report set/reset, and
anything else reports "not recognized" rather than a guess.
The mouse tracking modes used to show where that line falls. The
backend collapses `9`/`1000`/`1002`/`1003` into one mutually exclusive
value — rightly, for the *input* path: a terminal reports in exactly one
protocol, the last mode enabled wins, and disabling any of them turns
reporting off, which is what xterm does and what `click` must encode
for. But that value cannot say which members of the group an
application *asked for*; crossterm's `EnableMouseCapture` sends three at
once and only the last survives, so a probe for `1002` while `1003` was
also on could only be answered "not recognized" — honest, and provoked
on every run. The sequence tracker now keeps the requested set, beside
the focus flag and the window title that live there for the same reason
(the backend does not model them), so `DECRQM` answers each tracking
mode on its own evidence and `Screen::mouse_modes` reports the set,
while `Screen::mouse_mode` and the input path keep the backend's
one-protocol answer. Both are facts; each is reported where it belongs.
With no tracking mode active — the state every application probes from
at startup — every tracking mode reports reset, which is what lets
capability detection succeed: answering "not recognized" there would
close a loop on itself, where the application concludes the terminal has
no mouse, never enables tracking, and `click` then refuses, blaming the
application for a decision we caused.
Two answers are **declared rather than derived**, and they are the exception
that shows where the honesty rule actually points. A cell size in pixels
(`cell_size`, answering `CSI 14 t` and `CSI 16 t`, and filling the PTY's
pixel fields so `TIOCGWINSZ` agrees) and inline-graphics support
(`graphics`, adding `4` to DA1 or answering kitty's `a=q`) are facts about a
terminal, not about an emulator, so there is nothing for us to be truthful
*about* until a test says which terminal it is simulating. Both default to
claiming nothing, which keeps every existing suite where it was.
What makes the declaration legitimate rather than a lie is the failure it
removes. An application that **asks before drawing** is told no, correctly
takes its text path, and its pixel branch then never executes — so the branch
is not unasserted, it is unreachable, and no test can say anything about code
that does not run. That is the same shape as the `?2026` finding: one
unrecognized query was the difference between an unmodified binary being
untestable-by-frame and fully frame-testable. Declining by default is right;
having no way to decline differently was not.
`OSC 52` is the one sequence we **capture** rather than answer. A write
(`OSC 52 ; targets ; base64`) is not a question, and the only evidence a
test could otherwise see is the application's own toast — which proves the
code path ran and nothing about the payload, when the payload is the
behaviour under test. So the decoded most-recent write lands on the
snapshot (`Screen::clipboard`), with the target selections exactly as the
application named them, since writing to the wrong one is a real bug. A
payload we cannot decode — bad base64, not UTF-8, or past the capture
bound — reports as `None` and never as `Some("")`: a test asserting an
empty clipboard must not pass on something we failed to read. Clipboard
*reads* (`OSC 52 ; … ; ?`) are questions, and stay named-but-unanswered
like the rest.
Precision: the emulator stops consuming at each query byte (the same
mechanism as frame boundaries), so a cursor-position report reflects the
cursor *at the query*, not after later output in the same chunk moved
it. Replies are built under the state lock but written after it is
released — the state lock and the writer lock are never held together.
**Nothing writes to the child on a thread that cannot afford to wait.**
Every write — query replies and typed input alike — is handed to a
dedicated writer thread over a bounded queue. Typed input carries an
acknowledgement channel, so the test thread applies the terminal's
deadline and returns `Error::Write` with the screen ("the application is
not reading its input") instead of blocking forever; there is no portable
way to ask whether a PTY write *would* block, since `POLLOUT` on a macOS
master reports writable and then blocks anyway.
Which failures are errors and which are panics is a deliberate split, not
an accident of history. `send`/`send_str`/`paste`/`click`/`drag`/`scroll`
all return `Result`, because *whether the child is still there to receive
input* is a fact about the run, discovered at runtime, and a test may
legitimately want to handle it. `Key::F(13)` still panics, because there
is no thirteenth function key on any terminal and never will be: that is a
mistake in the test's own source, and the same category as indexing a
slice out of bounds. Environmental failures are errors; impossible
arguments are panics.
Replies go to that same thread, fire-and-forget: writing them from the
reader thread would block whenever the application stopped reading its
input; the drain would stop, the child would then block writing into a
full output buffer, and the harness would deadlock itself with no test
input involved.
**The queue is unbounded, capped on undelivered reply *bytes*, and the writer
coalesces everything queued into one `write(2)`.** That shape is the third
attempt, and the two it replaced are worth recording, because each was a
plausible invariant that turned out to be the wrong one.
One slot per *reply* filled at 64 answers. A startup batch of 200 probes lost
27 with nothing blocked anywhere — confirmed by spacing the same queries a
millisecond apart, which answered all 200. So the source comment claiming a
full queue meant the application had stopped reading was simply false; the
reader was outrunning our own writer.
One slot per *read* fixed that on a fast machine, where a batch arrives in a
single read. On a slow one the application's writes dribble out, the same 400
queries arrive in hundreds of reads, and 64 slots ran out again — 235 of 400
on a loaded macOS runner, at the same number twice, which is what a fixed
ceiling looks like rather than a race.
Slots were never the thing worth bounding. What must be bounded is memory, so
that is what is: an unbounded channel with a 1 MiB ceiling on queued reply
bytes. The reader therefore can never block on the hand-off — which is the
constraint that rules out backpressuring it at all, since a blocked reader
stops the drain and deadlocks the harness — a real application is never
shorted, and a hostile one still cannot grow memory without limit.
A discard is now rare, which moves the diagnosis: an application that
genuinely never reads leaves its answers *stuck in a blocked write* rather
than dropped, so both are counted — a lock-free pending count the writer
clears only once bytes are actually out — and the next wait's error names the
total.
**How much of that is knowable differs by platform, and the honest answer is
that Linux hides it.** A write into a full terminal input queue blocks on
macOS, so the stuck replies are visible to us and the count is exact. Linux's
`n_tty` driver instead *discards* input once its buffer (`N_TTY_BUF_SIZE`, 4
KB) is full: the write succeeds, the kernel throws the bytes away, and nothing
distinguishes that from delivery. We cannot report what we were never told, so
on Linux an application that never reads produces a timeout carrying the
screen and no reply count. The same kernel limit caps a legitimate batch too —
past roughly 680 replies the application has to read as it asks, exactly as it
would against a real terminal.
`XTGETTCAP` is answered from a table of capabilities the crate actually
implements, and every entry was checked against the code that implements it
rather than copied from a terminfo file — the key capabilities are literally
the bytes `Key::encode` produces, so an application that reads them and then
matches incoming input against them matches what we send. Anything else gets
the explicit "unsupported" reply, which is the half that matters: a
capability we guessed at would be believed, while a refusal lets the
application decide instead of wait.
Whatever remains unanswered (DECRQSS, the non-pixel `CSI t` reports, …) is
recorded, and the next wait timeout names it: "the application queried
the terminal (`^[[14t`) and received no answer" — a hang becomes a
diagnosis. `answer_queries(false)` mutes the responder for tests that
need a silent terminal; the diagnosis still works.
### Windows: ConPTY renders, and that decides what is claimed
`portable-pty` gives the same four layers a ConPTY backend, and the crate
builds and runs its suite on `windows-latest`. But ConPTY is a terminal
emulator of its own standing between the child and layer 1: it renders the
child's bytes into a screen and re-emits *its* rendering, and `portable-pty`
creates it without `PSEUDOCONSOLE_PASSTHROUGH_MODE`. Measured with
`tests/conpty_probe.rs` (twenty-one sequences in, six out verbatim):
- **Eaten:** DA1, OSC 10/11, XTGETTCAP, DECRQM, mouse and focus mode sets,
kitty and sixel payloads, HTS/TBC. The responder never sees the question,
so the answers it is configured to give — `Graphics`, `background_rgb`,
`cell_size` — cannot reach the child.
- **Reordered:** a DEC 2026 update arrives as `?2026h ?2026l` *then* the
content. A frame is the bracket, so `wait_frame` sees frames that never
contain what was drawn. Screen assertions yes, frame assertions no.
- **Rewritten but equivalent:** SGR split and reordered, OSC 2 as OSC 0, DEC
Special Graphics already translated to UTF-8, a tab as `CUF`, an OSC 8
link with an id ConPTY chose.
- **A preamble every child gets:** `CSI 6 n`, `?9001h`, `?1004h`, a title of
the executable path, `?25h`. Two of those bind the harness: the console
does **not start the child until the `6n` is answered** — the responder
answers it as it would any query, which is why the harness runs there at
all — and `?1004h` means `focus_events()` is true from the first byte.
- **Child exit is not EOF.** The output pipe stays open until the console is
closed, whoever has exited. Every liveness decision reads EOF off the
master on Unix, so on Windows the reaped child plus the drain grace is the
signal instead (`EXIT_CLOSES_THE_TERMINAL`, `ExitWatch`).
Each test a row above makes impossible is `#[cfg_attr(windows, ignore)]`
with that row as its reason; the README carries the user-facing list. The
`windows` workflow re-runs the probe and the whole suite on demand, and the
`windows-check` gate keeps the build compiling from a Linux runner.
## 2. Wait semantics
Every wait runs under the terminal's **default deadline** (builder
`timeout`, 5s default) or a per-call one: each `wait_*` has a `_for` twin
(`wait_until_for`, `wait_frame_for`, `wait_idle_for`, `wait_exit_for`), so
one known-slow step does not force its deadline on the whole suite. There
is deliberately no unbounded wait: a hung TUI in CI must produce a readable
failure, not a 6-hour job timeout. On expiry the error **embeds the full
screen dump** — a CI log alone answers "what was the app showing?".
- `wait_until(pred)` — re-evaluates `pred` on a fresh snapshot whenever the
reader delivers bytes (condvar notification), with a 50ms poll cap as a
missed-wakeup backstop. Fails fast with `Error::Eof` the moment the PTY
closes while `pred` is false: more waiting can never succeed. When rows
have scrolled into history, both errors say how many and point at
`full_text` — a content predicate cannot see them and neither can the
embedded screen, so text printed and then scrolled away in one burst
otherwise reads as text the application never printed. The note is
conditional on purpose: the predicate is an arbitrary closure, so the
wait cannot know what it was looking for, only what is true.
- `wait_frame(pred)` — evaluates `pred` **only on complete frames**. The
sequence tracker recognizes DEC private mode 2026 (`CSI ?2026 h/l`,
including multi-mode lists); the reader splits each chunk at every
frame end and snapshots the screen *at that instant*, so the predicate
sees exactly the frame as the app finished it — even when the same read
already carries the next frame's opening bytes. It **returns the frame
it matched**, so the assertion lands on the instant the predicate saw
rather than on a `screen()` taken afterwards, which can already be a
newer state.
The last **8** completed frames are retained, and each call scans —
oldest first — only those *newer than the frame it last returned*. That
one cursor gives both properties that matter: a burst arriving in one
read is assertable step by step in the order the application drew it
(asking backwards fails, so the sequence is enforced rather than
merely available), and a frame cannot satisfy two waits. A frame
completed before the call but never yet returned *is* still matched —
deliberately, so a fast application cannot slip one past you between
two waits — but a *superseded* frame no longer can, which is what makes
`send(key); wait_frame(old_state)` fail instead of passing on stale
content. `resize` advances the cursor too: a frame drawn at the old
size is not the repaint that answers the new one.
Honest caveat: a burst longer than the retention bound drops its
oldest frames. A frame is one *completed* update — an End that closes a
Begin we saw, so the `?2026l` in a defensive mode-reset string is not a
repaint — and a Begin/End pair that changed nothing still counts, since
the count is of repaints rather than of changes. An app that never
emits a synchronized update makes the timeout error say so and point at
`wait_until`.
- `wait_idle(quiet)` — resolves when **no bytes for `quiet`** AND the
stream does not end mid-escape-sequence (or mid-UTF-8-character) AND no
synchronized update is open (a begun-but-unfinished DEC 2026 repaint is
by definition mid-update). The sequence conditions come from a minimal
tracker (`emu/seq.rs`) — not a VT parser, just enough state to answer
"did the stream stop inside an update?". EOF counts as idle. An
application that opens an update and never closes it therefore times out
here, and the message says exactly that instead of "waiting for 100ms of
output silence", which reads as nonsense against a quiet terminal.
**This is a heuristic**: silence is evidence of a finished render, not
proof. Prefer `wait_until` on visible content, or `wait_frame` where the
app uses synchronized output.
- `wait_stable(quiet)` — resolves when the **picture** has not changed for
`quiet` — no cell, cursor or size differs between snapshots — under the
same mid-sequence and open-update conditions as `wait_idle`, and returns
the screen that held still. What resets the clock is the difference:
bytes for `wait_idle`, changes here. A bell, a cell rewritten with the
glyph already in it, a query answered — output that changes nothing —
keeps `wait_idle` from ever seeing silence and is invisible here.
Stillness before the call counts (bytes are the only thing that can
change the grid, so the last byte bounds the last change), EOF counts as
still, the screen returned is the newest observation of the picture so
its counters are current, and the heuristic caveat is the same.
- `snapshot_after(pred)` — `wait_until(pred)`, then `wait_stable` with a
fixed 100ms window, returning the settled screen: rules 1–3 below as one
call, for the common case of "wait for the app to show X, then snapshot
the whole screen". Both halves run under the deadline, each on its own.
- `wait_exit()` — polls `try_wait` on a capped backoff ladder (1→20ms),
then grace-drains the PTY (≤500ms) so the final screen is complete
before returning. Idempotent via a cached status.
`Drop` kills + reaps the child unconditionally (unless already reaped):
tests never leak zombies, including on panic.
### `screen()` is the live grid, frames or no frames
`wait_frame` is the only frame-gated observation in the crate. `screen()`
returns the grid as it stands, and "as it stands" can be **inside** a
repaint — for an application that brackets every repaint in DEC 2026
exactly as intended, just as much as for one that never heard of
synchronized output. Open an update, paint row 1, and a snapshot taken
there has row 1 and nothing else. The application did everything right
and still gets the pre-2026 failure mode.
This is deliberate, and the alternative is worse. Serving the newest
*complete* frame from `screen()` while an update is open would mean that
a `wait_until` predicate could match content the following `screen()`
does not show — the predicate reads the live grid, so it sees the
half-painted row that the substituted frame lacks. Disagreeing with your
own predicate is a nastier failure than tearing. And the torn read is
positively wanted in one case: an application hung mid-repaint is
diagnosed by seeing the half-painted grid, which is why timeout and
`Error::Eof` screens keep showing it.
So the honest answer is three routes to a frame-consistent screen, each
matching a way of waiting:
| you waited with | use |
|---|---|
| `wait_frame` | the `Screen` it returns — the matched frame, complete by construction |
| `wait_until` | `wait_idle` or `wait_stable` after it (neither settles while an update is open), then `screen()` |
| `snapshot_after` | the `Screen` it returns — predicate, then stillness, in one call |
| neither | a predicate naming the last thing the app paints, so its truth implies the repaint finished |
### The three rules for race-free waits
`wait_until(pred)` guarantees exactly one thing: every byte up to and
including the ones that made `pred` true has been processed. Nothing in
the byte stream marks where a repaint ends, so the predicate can fire on
a half-painted screen — **including half a row**. The first real user hit
exactly that (the [termlens-demo coverage
study](https://github.com/vyncint/termlens-demo/blob/main/docs/TERMLENS-COVERAGE.md),
§2):
```rust
t.wait_until(|s| s.contains("NORMAL"))?; // status bar, last row
assert!(t.screen().contains("Tasks 1/10")); // the SAME row — still in flight
```
This failed roughly 2 runs in 15 under parallel load: `NORMAL` had
landed; ` Tasks 1/10 …`, the rest of the same row, was still crossing
the PTY. Three rules make such waits deterministic:
1. **Put everything you assert into one predicate.** A `Screen` is one
consistent instant; two waits are two instants with a race between
them. The fix for the failure above is race-free by construction:
```rust
t.wait_until(|s| s.contains("NORMAL") && s.contains("Tasks 1/10"))?;
```
2. **Wait on the last thing painted.** Before snapshotting a whole
screen, wait on the **final** thing the app draws — the rightmost
text of the bottom row, or the cursor's resting position
(`s.cursor() == (row, 0, true)`) — never a line drawn midway. Which
text is "last" is a property of *your app's* render order; termlens
cannot tell you. Waiting on an early marker and snapshotting is a
race at chunk boundaries; the stress workflow found exactly that in
our own suite.
3. **Settle before whole-screen snapshots.** A snapshot asserts on cells
the test never named, so no targeted predicate can cover it; call
`wait_idle` first — or `wait_stable`, which an application's bells and
no-op repaints cannot keep from settling. That is a heuristic (silence
≠ proof of a finished render — see above), and it is the honest tool
for the job. `snapshot_after(pred)` is rules 1–3 as one call: the
predicate, the settle, and the screen it settled on.
4. **`wait_frame` removes the torn-frame race, not rule 1.** An
application that brackets its repaints in DEC 2026 synchronized updates
lets `wait_frame` evaluate predicates only on complete frames, so half a
row can never match — but a predicate true of frames you did not mean
returns one of them. `send(key)` then `wait_frame(|s| s.contains("MENU"))`
hands back the oldest not-yet-returned frame showing a menu, which can
be the one painted *before* the key. Name what the key changes, exactly
as rule 1 asks. The frame you get carries its own `repaints()`, so how
far behind the live screen it is can be checked rather than assumed.
### The resize stale-frame trap
Rule 1 has one non-obvious failure mode after `resize`. The emulated
grid resizes immediately — but its *content* is still the old frame,
merely clipped or padded to the new geometry (the backend reflows
nothing), until the application handles SIGWINCH and repaints. Both halves of this predicate are true of
the **stale** frame:
```rust
t.resize(50, 20)?;
t.wait_until(|s| s.cols() == 50 && s.contains("tasks (10)"))?; // ← matches old content
```
`s.cols() == 50` holds from the moment `resize` returns, and the
clipped old frame still says `tasks (10)` — the wait resolves before
the app has repainted at all. Wait for something only the
post-SIGWINCH frame can show — content that needs the new width, a
complete status bar on the new bottom row — or use `wait_frame` where
the app emits synchronized updates, which is now unconditionally safe
here: `resize` advances the frame cursor, so only a frame completed
*after* the resize can satisfy the wait.
### The instant-exit caveat (macOS PTY teardown)
A child that **writes and exits within its first milliseconds** races the
platform's PTY teardown: on macOS, bytes still buffered in the kernel when
the slave side closes can be discarded, and teardown can even surface as a
signal-death instead of the real exit code. Stress-testing found this at
roughly 1 in 80 instant-exit spawns under load. termlens narrows the window
as far as userspace allows — the reader thread is attached *before* the
child is spawned — but cannot close it.
The deterministic pattern (used throughout our own suite): make the child's
last action a `read` on stdin, assert on its output, then send Enter and
`wait_exit`. Real TUI applications are unaffected — they live far longer
than the window and exit on request.
Related, and fixed inside the library: macOS tears PTYs down with
`revoke()` and recycles PTY device numbers immediately, so with concurrent
terminals one thread's teardown could revoke another thread's *freshly
opened* PTY and kill its child at birth (~1/800 spawns under CI load; the
same suite ran 100/100 on Linux). termlens therefore serializes every PTY
lifecycle edge — open+spawn on one side, kill+reap+close on the other —
behind a process-wide lock (`PTY_LIFECYCLE` in `terminal.rs`). The lock is
held for microseconds per edge; steady-state I/O never touches it.
### Scrollback
A `Screen` carries the rows that have scrolled off the top, so content the
application handed back to the terminal stays assertable:
`scrollback_rows()`, `scrollback_text()`, and `full_text()` — history
followed by the visible screen, which is the assertion an author actually
writes when the application moves content between a live region and
native scrollback as it runs. Every other query on `Screen` (`contains`,
`find`, `cell`, `text`) is visible-screen only, unchanged.
Two design constraints shaped this. First, **a snapshot is a fixed
observation**, and vt100 models scrollback as a *stateful view* —
`set_scrollback(n)` moves an offset so the same accessors read history. So
the view is moved only inside `process`, under `&mut`, while bytes are
being consumed, and always restored before anyone can observe the grid; no
`Screen` ever depends on parser state read later. Second, **snapshots are
taken on every wait evaluation**, so history is materialized once per
chunk that scrolled rather than per snapshot, and is kept as shared text
rather than cells — a thousand rows of styled cells per snapshot would
dominate the cost of every wait, and history is asserted on for its
content.
Below the retention length the history only grows, so a chunk that
scrolled nothing costs one length check. At the length, vt100 evicts from
the front and its length stops changing, so growth is no longer visible
there — and there is no sound cheap substitute, since consecutive
identical rows are ordinary output and comparing the ends of the history
would miss real scrolls. So at the bound the window vt100 still holds is
re-read, which is by definition the newest N rows. Measured on 50,000
lines through an 80x24 screen: 352ms with retention off, 327ms below the
bound (free, within noise), 639ms on the re-read path.
Two limits are documented rather than papered over: history is bounded, so
a longer run drops its oldest rows; and resize does not reflow, so rows
keep the width they were captured at — a decision, recorded on
`Terminal::resize`, not an omission. The record needed to reflow exists —
the backend reports which rows soft-wrapped, and `Screen::row_wrapped` and
`Screen::logical_text` read it for the visible grid — but history is
captured as text without it, and re-laying a thousand rows out on every
resize is a cost no assertion has asked for; the other honest option,
discarding history on resize, would cost a suite that exercises a
responsive layout everything it had asserted on. Rows captured
after a resize have the new width, so `full_text()` can hold both
geometries, and says so where a caller meets it. The alternate screen
accumulates no history at all (vt100 gives the alternate grid none), which
is what makes retention safe to default on for full-screen TUIs.
## 3. Snapshot text format (spec)
`Display for Screen` produces:
```
size: 80x24 cursor: 2,3
╭──────────────────────────────╮
│ repoatlas │
│ > main.go │
╰──────────────────────────────╯
```
Rules:
1. Header line first: `size: <cols>x<rows> cursor: <row>,<col>` — two
spaces between the fields; cursor coordinates are `row,col` zero-based.
A hidden cursor renders as `cursor: hidden` (TUIs hide the cursor
deliberately; snapshots should say so).
2. Then the grid **verbatim**, one terminal row per line, top to bottom.
3. **Trailing whitespace is stripped per line** (snapshot files stay
readable and diff-able), but blanks are *preserved inside* the `Screen`
grid, so `cell()`, `row_text()`, and `find()` coordinates are unaffected.
4. Wide characters occupy their real columns: the character appears once;
its continuation cell contributes nothing to the text but does count
for column arithmetic (`find` returns true terminal columns).
5. Styles are text-invisible by default; `Screen::with_styles()` opts in.
Its rendering is the plain `Display` output followed by a blank line
and a `styles:` block:
```
styles:
1: 0-8 fg=4 bold; 12 reverse
3: 0-79 bg=#1e1e2e
```
One line per row containing any non-default cell, ascending; each line
is `<row>: <spans>` with spans joined by `; `. A span is an inclusive,
0-based column range (`start-end`, or just `start` for one column)
followed by style tokens in fixed order: `fg=`, `bg=` (indexed colors
as decimal, RGB as `#rrggbb`), then `bold`, `dim`, `italic`,
`underline`, `blink`, `reverse`, `conceal`, `strikethrough` — SGR order,
which is the order the original five were already in, so an existing
span's tokens are unchanged unless the cell carries one of the three
attributes added in 0.4. Default-styled spans are omitted entirely —
absence means default — so a highlight moving rows diffs as exactly
two lines. A fully default-styled screen renders `styles:` followed by
`(none)`, so the snapshot itself records that styles were asserted.
Wide-character continuation cells participate in spans like any other
cell.
`Screen` itself compares as **the same observation** (`PartialEq`/`Eq`):
cells, cursor, size, and every piece of out-of-band state including the
cumulative counters. Two visually identical snapshots either side of a
bell are unequal, because they are different moments. The text format is
deliberately blind to all of that — so comparing `to_string()` output is
style-blind, and `with_styles()` is the rendering to compare for "looks
the same".
6. **Out-of-band terminal state is not part of the text format.** The
window title (tracked by termlens itself from `OSC 0`/`OSC 2` — the
vt100 backend does not expose it), the alternate-screen flag, and the
input modes (bracketed paste, application cursor, mouse tracking) are
captured with every snapshot and read through plain accessors —
`Screen::title`, `Screen::alternate_screen`, `Screen::bracketed_paste`,
`Screen::application_cursor`, `Screen::mouse_mode`, `Screen::mouse_modes`,
`Screen::focus_events`, `Screen::clipboard`, `Screen::cursor_shape`,
`Screen::cursor_blink`, `Screen::links`. Keeping them out of
the rendering means existing snapshot files stay valid, and state
assertions read as ordinary predicates:
`wait_until(|s| s.alternate_screen())`.
Two of those are the terminal holding a fact **on the application's
behalf**, in the same sense the alternate-screen flag is. `DECSCUSR`
(`CSI Ps SP q`) sets the cursor's shape — a bar for insert, a block for
normal — and the parameter carries the blink rate along with it, so the
two are reported apart: they are one parameter and two facts, and a test
usually wants only one. "Never asked" is a third state and is reported as
itself: a terminal's default usually *is* a block, but a program that
stopped emitting the escape is not a program that asked for one. This is
also what makes the *restore* assertable, which is the half that ships
broken — a program that switches the cursor and never switches back
leaves the user's terminal wrong after exit, exactly as one that never
leaves the alternate screen does.
A hard reset (`RIS`, `ESC c`) returns the cursor shape to the terminal's
default and closes any open hyperlink span, because a real terminal's
power-on state holds neither. It deliberately does **not** clear the
title, the clipboard, the bell count or the link log: the first is a
window property `RIS` does not restore in xterm, and the rest are records
of what the application emitted rather than state the terminal still
holds. `DECSTR` (soft reset) is not modelled at all — the sequences it
touches are not ones tracked here.
`OSC 8` hyperlinks are **captured**, on the same grounds as `OSC 52`
below: a link changes no cell, its label renders as ordinary text, and
the URL therefore exists nowhere a content predicate can reach. A test
asserting that a TUI linked an issue or a file passes identically against
one that emitted no link, or linked the wrong target. `Screen::links`
reports each span with its URI, its `id=` (so spans that are one logical
link can be grouped) and the text it wrapped. Two bounds keep it honest
rather than merely bounded: the span log evicts oldest-first, so an
application that redraws its links every frame still reports the current
frame's; and a label that runs past the capture bound is reported as
*unknown* rather than as a prefix, since a prefix of the wrong length is
a wrong answer. A span the application never closed is reported open —
in a real terminal every character written afterwards joins it.
The same slot holds three **cumulative counters**, for behaviour that by
definition leaves the grid unchanged: `Screen::repaints` (completed DEC
2026 updates — repaints, not changes, so one input becoming four
repaints is catchable), `Screen::bells` (a `BEL` in ground state; the one
closing an `OSC` string is punctuation and the one inside a DCS-class
string is payload), and `Screen::graphics` (kitty and sixel payloads
transmitted, by protocol and total bytes). Monotonic on purpose: a test
takes a delta around an action rather than resetting a gauge. Counting a
graphics payload is not rendering it and claims nothing — DA1 goes on
declining both protocols, which is why an application that transmits one
anyway is worth catching.
Those counters count **images**, which is not the same as counting
escapes, and the difference is not a detail. The kitty protocol caps a
payload at 4096 bytes and continues with `m=1`, so every image of
consequence arrives in several escapes and the continuations carry no
control block at all; and `a=d` deletes an image rather than sending one.
Counted per escape, a 4.9 KB chart read as two pictures and a teardown
read as a third — so "how many images did this frame send?", the question
that distinguishes a diffing painter from one that re-sends the year,
could not be asked. `GraphicsBuilder` joins the chunks and classifies the
action; `deletes` is counted separately, because an application that
takes down what it drew and one that draws twice as much are opposite
behaviours.
The payloads themselves land on the same slot, as `GraphicsSeen::payloads`.
This is the position `OSC 52` already takes and for the same reason: a
transmission is not a question, the only other evidence is the
application's own output, and the payload is usually the behaviour under
test. What each one states — format, compression, id, declared pixel size,
declared cell extent — is parsed from the control block; *where* it landed
is the one fact that lives in the grid instead, so the emulator stamps the
cursor position at the terminator. That is why `process` now feeds the
parser in pieces: the tracker runs a byte ahead of the grid, and a
placement read before the grid caught up would be the position from before
the whole read. Neither protocol's escape moves the cursor, so the position
after the terminator is the image's top-left corner.
Decoding sits behind the `decode` feature and runs on demand, never as the
bytes arrive: a suite that only asks whether an image went out should pay
neither the inflate nor the dependency (zlib, for kitty's `o=z`). It is
still not rendering — a `Bitmap` is handed to the test and never
composited onto the grid, so nothing termlens reports about the screen
changes because an image was decoded. Retention is bounded like scrollback
(4 MiB and 512 payloads by default, `TerminalBuilder::capture_graphics`),
and a payload past the bound reports `None` data and refuses to decode
with `DecodeError::NotCaptured` rather than handing back a prefix that
would decode into a plausible-looking wrong picture. `f=100` (PNG) is
refused for the same reason a guessed query reply is: termlens carries no
image codec, and inventing one answer is worse than naming the gap.
Per-frame **cost** is the one observation that does not fit a snapshot: a
`Screen` is an instant, and a performance line is a series. So
`Terminal::frame_timings` keeps one `FrameTiming` per repaint — the span
between the application's own DEC 2026 markers, and the printable
characters written inside them — bounded at 512 and independently of the
eight frames `wait_frame` retains, because a timing is three words where a
frame is a whole grid. Both ends are stamped at the byte carrying the
marker rather than when the read arrived, so a burst delivered in one read
is still timed per frame. The span is measured through a PTY and therefore
includes the application's write pacing: it is a trend to watch, not a
render benchmark, and the rustdoc says so where someone would otherwise
assume otherwise.
All of it lives behind one `Arc` on `Screen`. `Screen` is embedded in
every `Error`, so its size is load-bearing: the counters alone pushed
`Result<T>` past clippy's `result_large_err` threshold, and the `Arc`
took `Screen` from 80 bytes to 40 while making a clone one refcount bump
instead of a field-by-field copy.
The `insta` feature (default) re-exports `insta` and ships
`assert_screen_snapshot!` so the snapshotting insta version can't drift
from the one the macro targets.
## 4. Emulator abstraction — why
`vt100` is the first backend: small, pure, battle-tested by its own suite.
But it is an implementation detail:
- Public types (`Screen`, `Cell`, `Style`, `Color`, `MouseMode`) are
termlens's own. vt100 types never appear in the API, so swapping the
backend is a non-breaking change. The one piece of state vt100 does
not track — the window title — termlens tracks itself in the sequence
tracker, so it survives a backend swap too.
- The `Emulator` trait is seven methods (`process`, `snapshot`,
`mid_sequence`, `in_sync_update`, `input_modes`, `mode_state`,
`set_size`) — deliberately the *narrowest* surface that
the terminal loop needs, so candidate backends (wezterm-term for wider
escape coverage, alacritty_terminal for fidelity to a real terminal's
quirks) can be evaluated behind a feature flag without touching layer 3+.
- Known vt100 limits: no reflow of scrollback on resize, and cluster
handling for exotic emoji is whatever vt100 does (pinned by the
unicode-torture snapshot rather than promised).
- vt100 has no character-set handling at all — `ESC ( 0` reaches
`unhandled_escape` and is dropped — so **DEC Special Graphics is
translated by termlens itself**. The sequence tracker holds the G0–G3
designations, the `SI`/`SO` locking shift, and a pending SS2/SS3, and the
emulator asks it, byte by byte and *before* stepping, whether the byte
draws as a glyph; if so the glyph is staged in place of the byte and the
staged stream is what both parsers receive. The single shift is consumed
when that character is processed, not when it is looked up, so a second
reader (an open hyperlink label) still sees the same set. Rewriting
before either parser is what keeps the primary and the attribute shadow
the same shape, exactly as the SGR rewrite does. Scope is deliberately
narrow and stated in the README: G0–G3 designation, locking shifts on
G0/G1, SS2/SS3 for one character, one set translated, everything else
read as ASCII. `LS2`/`LS3` and `DECSC`/`DECRC` of charset state are not
modelled.
- vt100's tab stops are a hardcoded eight with no way to be told otherwise,
so **the stop set is termlens's too**, and for the same reason: `HTS`,
`TBC`, `CHT` and `CBT` reached `unhandled_escape`/`unhandled_csi` and were
dropped, while `hts`, `tbc` and `cbt` sit in the terminfo entry every
child is handed. The set is one flag per column in the sequence tracker;
the *cursor column* each operation is relative to is the grid's, so the
tracker classifies and the emulator resolves — feeding the bytes it still
owes the parser first, so the position it reads is current rather than
wherever the last feed stopped. A motion then goes out as `CSI n G`
(`CHA`), which vt100 does dispatch, so this is a rewrite on the same
staged stream the glyph substitution uses rather than a new grid
operation. The original escape is fed rather than dropped: vt100 ignores
all four, and dropping a final byte would leave its parser in a
half-consumed escape that swallows the next one. Plain `HT` is rewritten
too — vt100 *does* act on it, and its eight and a custom stop would
disagree the moment an application set one; the `CHA` lands after it and
wins, which is sound because `HT` draws nothing and only moves the cursor.
Both parsers are fed the rewrite, so the attribute shadow keeps the same
shape as the primary grid and the invariant `snapshot` debug-asserts still
holds. Scope is in the README: `TBC 0` and `TBC 3` only, back-tab to the
nearest stop strictly left of the cursor, and a resize that extends into
new columns with the default pattern while leaving existing stops alone.
### The attribute shadow
vt100 0.16's `Attrs` is `{ fgcolor, bgcolor, mode: u8 }` and its SGR
dispatch handles only `0 1 2 3 4 7 22 23 24 27` plus the colour params.
`5`/`6` (blink), `8` (conceal), `9` (strikethrough) and their resets never
reach a cell. That is not a missing nicety: **a test asserting that a
password field is masked passes against an application that prints the
secret in clear**, because the two renderings are identical in the grid and
`with_styles()` cannot break the tie either. It is the one place in this
crate where a green test certifies the bug it was written to catch.
Upstream is the right home for the fix — three spare bits are sitting in
`mode: u8` — but 0.16.2 (July 2025) is the newest release, `Attrs` has no
public setter reachable from `Callbacks::unhandled_csi`, and a published
crate cannot carry a `[patch.crates.io]`. So the three attributes are
recovered from **a second `vt100::Parser` fed the same byte stream with
only SGR sequences rewritten**, so that three attributes vt100 does keep
act as carriers for the three it drops (`5`/`6`→`1`, `8`→`3`, `9`→`4`, with
`25`/`28`/`29` mapped to the matching resets and every other parameter
dropped from the shadow stream).
This is sound for a reason that can be checked rather than hoped for: **in
vt100, attributes never influence geometry.** `Attrs` is read in exactly
two places — as the fill value for `clear`/`erase`, and by the escape-code
*output* functions. Cursor movement, wrapping, scrolling, tabs and cell
placement are attribute-independent, and SGR sequences never move the
cursor. So a stream differing only by the replacement of complete plain-SGR
sequences produces an identically-shaped grid, and shadow cell `(r, c)` is
primary cell `(r, c)`. A debug assertion compares the two grids on every
snapshot, so the whole test suite checks the invariant instead of taking it
on trust.
Note what this deliberately is *not*: nothing here attributes styles to
cells by hand. vt100 does the attribution, twice — so there is no second
cursor, no duplicated wrap, scroll-region or alternate-screen logic, and
nothing to diverge quietly. The alternative considered and rejected was
vendoring a patched vt100: 3,950 lines of someone else's code carried
permanently, in a crate whose value is being reviewable, to hold an
80-line patch. The rewriter's one real trap is that the `5` in `38;5;196`
selects palette mode rather than blink, so extended-colour parameters are
stepped over in both the semicolon and colon forms; anything that is not a
plain SGR (private prefix, intermediate byte, aborted sequence) passes
through byte-for-byte, which is what makes "cannot diverge" true rather
than merely likely.
When upstream gains the attributes, `emu/shadow.rs` deletes and
`convert_cell` reads the three flags directly.
## 5. Environment policy
Tests must be hermetic. `TERM=xterm-256color` is always set (unless the
test overrides it) so escape output matches what the emulator speaks
regardless of host. `env_clear()` blocks inheritance while keeping
`env()`-set variables — order-independent, unlike `std::process::Command`,
because a builder that silently drops your explicit `TERM` depending on
call order is a trap.
## 6. Input encoding
Mouse input is **mode-aware**: the emulator knows which tracking mode and
encoding the application enabled (`?9/?1000/?1002/?1003`, SGR `?1006`),
`click`/`scroll` encode to match, and no tracking enabled is a typed
error — never bytes the application would misparse as keys. Modifiers ride
on the button code the same way for a button and a wheel notch (`+4` shift,
`+8` alt, `+16` ctrl), so `MouseButton::Left.ctrl()` and `Scroll::Up.ctrl()`
are one idiom; the wheel gets a chord type of its own (`ScrollChord`) so
that a wheel direction cannot be handed to `click_with`, since a notch has
no release and the two are different gestures on the wire.
A **drag reports one motion per cell crossed**, interpolated on a straight
line and on both axes for a diagonal. A single report at the destination is
invisible to an application that only asks where a gesture started and where
it is now — which is why it went unnoticed — and wrong for every application
that acts *along* the path: a drawing surface painting each crossed cell, a
selection highlighting incrementally, a drag that must cross a pane edge to
register. A real pointer's exact path is not reproducible and does not need
to be; every intervening cell being reported is the property that matters.
**Focus events** (`ESC [ I` / `ESC [ O`) follow the same mode-aware contract
as the mouse: sent only when the application enabled mode 1004, refused with
a typed error when it did not. Without a way to deliver one, the unfocused
branch of a UI is not merely unasserted — it is unreachable, since no input
exists that can enter it.
`Key::encode` documents the xterm *default-mode* sequences (CSI arrows,
SS3 F1–F4, CSI-tilde function keys, DEL for Backspace). `send` is
mode-aware: cursor keys switch to their `ESC O _` application forms while
DECCKM is set, bracketed paste wraps only when mode 2004 is on, and mouse
reports match the tracking mode/encoding the application enabled — the
emulator knows every one of these modes, and the input path consults it,
so the bytes always match what the application configured its "terminal"
to send. The one thing no encoding can fix is the wire itself: `Esc`
immediately followed by another key is byte-identical to an Alt chord.
Where the `Esc` has a visible effect, waiting for it resolves the
ambiguity. Where it has none — leaving a text field's insert mode often
changes nothing — `send_after(delay, key)` puts the two writes in separate
reads instead. The delay is a named argument rather than a hidden constant,
and `send(Key::Esc)` carries no default separation: most suites send `Esc`
with nothing behind it, and a hidden sleep would slow all of them for a
hazard they do not have while making the tests that need it work for a
reason invisible at the call site. It is a heuristic either way — it works
by making the application's read boundary fall between two writes, and a
slow enough poll loop can still merge them.