# 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.

### Masks

`Screen::mask_rect`, `mask_matching`, `mask_cells` (and `mask_matches` with
the `regex` feature) return a *new* `Screen` with cell contents replaced and
nothing else changed: the size, the cursor, every style, and the two columns
of a wide character (both become the fill) are preserved, so the masked
screen renders in this same format — a masked cell renders as its fill, a
blank fill as the blank a cell renders as anyway — and its `styles:` block
is the original's. That is the whole reason the masks exist in the crate
rather than as a text filter in the snapshot tool: a filter over the
rendering changes a field's width and moves every column after it while the
style runs still name the original columns; a mask over the grid cannot.

## 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.
