Skip to content

Latest commit

 

History

History
1267 lines (1137 loc) · 74.3 KB

DESIGN.md

File metadata and controls

1267 lines (1137 loc) · 74.3 KB

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

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
Loading

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 report the one protocol. Both are facts; each is reported where it belongs. The one protocol is the tracker's as well (#693): the backend turns its value off only on a reset of the mode it holds, so after ?1002h ?1000l it went on reporting, where xterm — for which a reset of any of the four ends reporting — sends nothing. 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. Under origin mode (DECOM) the report counts rows from the top margin, as xterm's does, so an application that restores the position with CUP lands where it was; the backend keeps neither the margins nor the mode where termlens can read them, so the sequence tracker follows both, by the backend's rules (#689). 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 an unbounded queue, capped on reply bytes (below). 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 depends on the terminal mode, and one mode on Linux hides it. In raw (non-canonical) mode, which is the mode of every application that reads its replies, a write into a full terminal input queue blocks, on Linux as on macOS: the stuck replies stay in our writer, so they are counted, and the next wait's error names them. Measured on Linux 7.0, a raw-mode application that never read took about 2,300 six-byte replies (13.8 KB) before the write blocked, and once it did read, every reply arrived. In canonical mode 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 a canonical-mode application that never reads produces a timeout carrying the screen and no reply count; it can later read 4,095 bytes, about 680 replies, and the rest are gone. Past either limit 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 CSI t reports other than the text-area and pixel sizes, …) is recorded, and the next wait timeout names it: "the application queried the terminal (^[[14t) and received no answer" — a hang becomes a diagnosis. The record keeps eight distinct shapes and counts the rest, each once; the most recent query is named whatever came before it, and the note claims a cause only when no output arrived in a later read than that query. answer_queries(false) mutes the responder for tests that need a silent terminal; the diagnosis still works.

On Unix termlens starts the child itself

portable-pty opens the PTY, but on Unix it no longer starts the child. Its spawn_command closes every descriptor above stdio between fork and exec, and one of them is the close-on-exec pipe through which std reports a failed exec. When the program exists but cannot be executed, the child's write into that pipe failed and std aborted the child. spawn then returned Ok with a process already dead of SIGABRT (#563; upstream, wezterm/wezterm#7893). The usual causes are a missing #! interpreter (a \r left by CRLF line endings counts), a missing dynamic loader, and an argument list over the kernel's limit.

src/spawn.rs builds the command portable-pty 0.9.0 would build and runs it with std::process::Command:

  • Resolution finds the same file by the same rules. ./x and ../x are looked up in the working directory alone. Any other relative name is searched for along the child's PATH, each entry taken from the working directory. A candidate must pass access(X_OK) and must not be a directory. argv[0] is the name as given. A working directory that is not a directory gives way to the child's HOME, as in portable-pty.
  • The environment is the same: the inherited one unless env_clear, less COLUMNS and LINES (§5), plus the SHELL that portable-pty fills in from the login shell when the process has none, then termlens's pins and the test's own variables.
  • The session gets the same pre_exec steps. Six signals go back to their defaults and the mask is emptied. setsid gives the child a session of its own, and TIOCSCTTY makes the PTY its controlling terminal. portable-pty's umask step is left out, because it acts only on a umask termlens never sets.
  • Descriptors above stdio are marked close-on-exec rather than closed. A successful exec closes exactly what portable-pty closed, and a failed one still has std's pipe to report through.

A unit test in spawn.rs starts one probe both ways and requires the same answers about argv[0], directory, environment, descriptors, umask, process group and controlling terminal. A portable-pty release that changes its own spawn fails that test instead of drifting apart from this one.

The error names the culprit where the kernel's errno would point at the wrong file. ENOENT for a script that is plainly there means a missing #! interpreter, which is named. For a binary it means a missing loader. EACCES from an interpreter that cannot be run names the interpreter. A failure before the exec, such as a NUL in an argument or a directory that cannot be entered, reads as std reports it, with the directory named in the second case.

Reporting the exec has a price. std's spawn now returns only once the exec has happened, so a spawn holds the lifecycle lock about a quarter of a millisecond longer on Linux. On a machine whose security software inspects every exec, it is held for the length of that inspection.

The slave is opened by name (tty_name, O_NOCTTY), because portable-pty does not expose its own handle. Four cases still go to portable-pty unchanged:

  • a process whose children the kernel reaps, because SIGCHLD is ignored or carries SA_NOCLDWAIT. std panics when it cannot reap a failed exec, and every wait fails there anyway;
  • a working directory that is not one, with no UTF-8 HOME in the child's environment; portable-pty then reads the passwd entry;
  • a program that does not resolve. portable-pty's diagnosis is what termlens has always reported, word for word. A name with a directory part (target/debug/app) is the exception: it is a file in the working directory however that file stands, so its exec reports the OS's reason, EACCES or ENOENT, rather than a PATH search that was never the place to look (#631);
  • a slave that cannot be named or opened.

These are the old behaviour, not new risk. On Windows ConPTY reports a failed CreateProcess itself, and portable-pty spawns the child as before.

termlens's own descriptors are all close-on-exec, the second writer it duplicates from the master included. One window remains, and it belongs to the PTY layer. openpty creates the pair without close-on-exec, and portable-pty marks it a moment later. A child that another thread of the test process forks inside that window inherits the PTY. If it holds the slave, that terminal reports no EOF until the child exits. It is measured at a few in a thousand concurrent spawns, and only alongside processes the test starts itself.

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; docs/LIMITATIONS.md 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_stable_for, wait_exit_for, and wait_until_matches_for with the regex feature), as does snapshot_after (snapshot_after_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. A resize that changes a dimension advances the cursor too: a frame drawn at the old size is not the repaint that answers the new one. A resize to the size the grid already has is a no-op — no SIGWINCH, no repaint — and leaves the cursor where it was.

    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. A child the kernel reaped, where the test process ignores SIGCHLD, counts as reaped: nothing is signalled, wait_exit says why there is no status, and signal refuses (#630).

On Unix Drop stops the child's process group, not the child alone (#714). The child is a session and group leader (setsid), so its pid is the group's id, and a member that ignores SIGHUP, a nohuped helper or a background job that traps it, ignores the kernel's hang-up as the session leader exits too and would run on, on a PTY nobody holds. The group is hung up; a child that has exited, or that outlives HANGUP_GRACE, is followed by SIGKILL to the group; then the child is reaped. A group id is safe to signal only while the child is unreaped, since its zombie holds the id, so Drop reaps nothing before the last signal (it polls the exit with waitid(WNOWAIT)), and signals nothing once the child is reaped: after wait_exit, or where the kernel reaped it. signal still reaches the child alone.

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, §2):

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:

    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.

A note on wait_frame: it removes the torn-frame race, not rule 1. This is not a fourth rule, since it asks for nothing the three do not. 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:

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 safe here when the size actually changes: resize advances the frame cursor, so only a frame completed after the resize can satisfy the wait. A resize to the size the grid already has is a no-op, and the frame the application last drew is still the current truth.

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, the close of each master descriptor on the other, the responder threads' duplicates included — behind a process-wide lock (PTY_LIFECYCLE in terminal.rs). The lock is held for an open and a spawn, or for a close; steady-state I/O never touches it. Stopping a child is not an edge: it happens before the lock is taken, while the terminal's master is still open and its device cannot be recycled. It used to be one, and portable-pty's kill sleeps in 50 ms steps, so every spawn in a test binary waited out every drop (#589).

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. A hard reset (RIS, ESC c) is the exception: it rebuilds the screen and empties the history, so the feed is split at the reset byte — the rows that scrolled before it are captured while vt100 still holds them, and the capture after it sees an empty history, which is what re-syncs the mark (#391). An entry to the alternate screen splits the feed the same way, since from that byte every accessor reads the alternate grid and the main screen's newest rows are out of reach until it is back (#622). The exit splits it just after its final byte, so the main screen is read back before anything scrolls it: a taller resize while it was hidden pulled rows back out of its history, and new rows scrolling off in the same feed would cancel that in the length (#682). A resize while the alternate screen is up reaches the main grid too, out of sight, so the emulator puts the main grid on display for it (CSI ? 47 l to both parsers, which in vt100 switches grids and nothing else, and CSI ? 47 h after) and copies and captures as if the main screen were up: a row the resize pushes is read at its own width, and a row it pulls back and pushes again is read again (#678). Bytes cannot be fed inside a sequence, so a resize that arrives while a read has left one open is still settled on the exit. At the length, vt100 evicts from the front and its length stops changing, so growth is no longer visible there — and the rows cannot say it either, since consecutive identical rows are ordinary output. vt100 reports each row it drops off the front through its on_scroll callback, though, so the emulator counts them (emu/unhandled.rs) and copies only the rows past the ones it already holds, at the bound as below it. It used to read the whole window again at the bound, on every chunk. That read was at the screen's width of the moment, so after a narrower resize it cut every retained row to the new width, and reading a row through vt100's view widens it to the screen, which cleared the wrap flag of a row a wider resize had left narrow (#671). Now each row is read once, whole. Measured on 50,000 lines through an 80x24 screen, release build, end to end: 40 ms with retention off and 84 ms with the default 1,000-row history, which re-reading the window had taken to 90 ms.

History is text unless the builder asks for styles (scrollback_styles), in which case the scrolled rows are captured as cells too and the shadow parser keeps the same history, read at the same offset, so the correspondence invariant extends from the grid to history; the knob's rustdoc carries the measured cost. Screen::locate searches grid then history and names the region.

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. A change of height moves rows the way xterm does (vt100 0.18): a shorter screen with the cursor on its last row pushes its top rows into history, and a taller one pulls the newest back onto the grid — where they leave the history, so no row is on record twice. A taller screen moves down only by the rows the history holds, as xterm's ScreenResize does, and grows below for the rest; vt100 counts every row that ever scrolled off, and termlens resizes it in two steps so it adds no blank rows above the text (#684). RIS and ED 3 (CSI 3 J, which clear sends) empty the terminal's own history; the rows already retained survive both, since a record that forgets what a test may still assert on is worse than one a real terminal would have cleared. 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. Whitespace is Unicode's (str::trim_end), so a no-break space or an ideographic space the application drew at the end of a row is stripped with the padding; "Reading it back" says what that costs.

  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). The columns are the emulator's, character by character — a ZWJ family is two wide cells, not one cluster — and an emoji-presentation selector (U+FE0F) makes the emoji it follows two columns, as vt100 0.18 lays it out. Releases before 0.11.7 gave such an emoji one column and the text does not say which wrote it, so the reader widens it unless the row then would not fit, and reads that row the earlier way. A row an earlier release wrote with room after the emoji therefore reads at the later layout (#723): every cell after the emoji is one column right of where its writer drew it, while the row's styles: spans and the header's cursor keep their column numbers, so each lands one character left of what it marked. ⚠️ and a red ERR ok written by 0.11.6 read with the span 1-3 on the emoji's second half and ER, and the cursor it left after ok on the k. No rule can tell the layouts apart — a span or a cursor that only makes sense narrow hints at one, and is no proof — so the JSON is the format to keep where an old file's columns matter. tests/compat/0.11.6/vs16-styled-after pins the reading.

  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 — the token Color's Display writes and Screen::parse reads), 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) follows xterm's, which resets less than RIS in both directions: it resets cursor keys, the cursor's visibility and shape, the attributes, the character sets, insert mode, the margins and origin mode, and homes the saved cursor, and it keeps bracketed paste, the mouse modes, focus reporting, the tab stops and the alternate screen (#687). vt100 implements none of it, so the emulator replays the resets as sequences vt100 does implement, then moves the cursor back where the reset found it, since vt100 homes it on a margin or origin reset and xterm's soft reset moves no cursor. docs/LIMITATIONS.md keeps the exact list.

    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 four 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), Screen::visual_bells (ESC g, a flash rather than a beep, counted apart so the two are assertable separately), 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 m= and none of the image's facts; 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. Only a kitty escape continues an upload. A delete arriving mid-upload aborts it, as the protocol requires, and so do a sixel and RIS: the upload is then no image, though its bytes still count, and the escape that interrupted it stands on its own. DECSTR leaves an upload alone, as kitty's does (#587).

    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. The position at the terminator is the image's top-left corner, stamped before anything moves the cursor. A terminal that speaks the protocol then does move it, and the emulator follows the one the test declared (graphics(), with cell_size(); #588): kitty puts it on the placement's last row, one column past its right edge, wrapping to the next row past the screen's and scrolling past the bottom margin, unless C=1, U=1 or P= says otherwise; xterm, with sixel scrolling on as it is by default, puts it on the image's last text row and leaves the column. A terminal declared without the protocol ignores the escape and moves nothing, which is the default. The cursor is moved with line feeds and a cursor-forward fed to the grid, so a scroll it causes reaches history like any other. Past a scroll region's bottom margin, where line feeds stop on the margin, kitty leaves the cursor below it, on the image's last row (#697): a VPA fed after the line feeds puts it there, vt100 taking that row as absolute whatever the margins say.

    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.

Reading it back

Screen::parse (0.10.0) is the reader for everything above: it accepts exactly what Display and with_styles write — the header, the grid, and optionally the blank line, styles: and its spans — and every other line is an Error::Parse naming it. The termlens CLI is built on it: diff and render take saved screens, so a snapshot outlives the test run that produced it.

The round trip is exact for what the format carries. A parsed screen renders to the same text, with the same styles: block, and diffs empty against the original. It is not == to the original, and rule 5's last paragraph is why: equality is "the same observation" and the text format is deliberately blind to most of it. What does not survive is exactly what the rules above never wrote down — an erased cell and a written blank are one character either way; whitespace of any other kind at the end of a row, a no-break space (U+00A0) or an ideographic space (U+3000) the application drew there, is stripped by rule 3 and comes back as blank cells, U+3000 as the two it covered (#724); the out-of-band state, the title, the mode flags, the counters and the history, comes back default; and a hidden cursor's position is not recorded, so cursor: hidden parses back to a hidden cursor at 0,0. diff reports none of those as a difference, which is the same claim from the other side: the text format is the picture, and two screens that render alike are the same picture. For diff a cell that holds only whitespace is a blank wherever it sits, as it is on a terminal, so a no-break space inside a row does not differ from a space there either, though the text keeps it; Cell::contents tells the two apart, and so does the JSON. One header releases before 0.11.2 wrote does not render back as written: a visible cursor at col == cols, the pending-wrap column (#401), reads onto the last cell, as the JSON reader reads it, so the header comes back one column left (#643). And one row releases before 0.11.7 wrote renders back as written but is not the picture its writer saw: a VS16 emoji with room after it reads two columns wide, as rule 4 says, so it differs from its own JSON twin (#723).

The header's row count is what decides where the grid ends — not a search for the styles: marker — so a grid may hold the words styles: or (none) as ordinary content. The one ambiguous input is a snapshot whose trailing blank rows were trimmed by hand and which carries a styles block: its block falls inside the declared row count and is read as content. Content wins on purpose, because a wrong row of text shows up in a diff and a silently dropped one does not.

The JSON shape below is the format for the other job — carrying a Screen whole, out-of-band state included, so that reading it back gives one that compares ==.

What a reader skips, and what it does not

The format proper begins at the size: header and ends with the grid or the styles: block. Screen::parse accepts exactly that — every other line is an Error::Parse naming it. termlens itself writes two things around a screen, and its own reader, the termlens CLI, skips both:

  • Above: the insta header, --- / source: … / ---, which a .snap file carries over the content that was snapshotted.
  • Below: the trailer termlens inspect wrote to stdout before 0.11 — --- exited: … ---, --- still running … --- (both forms), --- waiting for the program failed: … ---. Since 0.11 the trailer goes to stderr and stdout carries the screen alone, so termlens inspect prog > file saves a screen diff and render read back (#340); the CLI still drops exactly those three shapes when they are the last line of a file, so a file saved by a 0.10 inspect reads too. Nothing else trailing is skipped: a grid row that merely begins with --- is content.

The split is deliberate. The library reads the format; the tool knows its own wrappers. A consumer parsing a file with Screen::parse sees the strict format and nothing else, and the list of decorations is the CLI's to extend.

The JSON shape (serde feature)

With the serde feature a Screen serialises to JSON, and the CLI reads that JSON back as a saved screen — so it is a persisted artifact, and it is specified here next to the text format (#329). Format 1:

{
  "format": 1,
  "cols": 30, "rows": 4,
  "cursor": { "row": 2, "col": 4, "visible": true },
  "cells": [ [ { "contents": "m", "style": { … }, "wide": false, "wide_continuation": false }, … ], … ],
  "state": { "title": "", "alternate_screen": false, … }
}
  • format — the shape number, first. A file without it was written by 0.10 and is format 1; a reader that meets a number it does not know refuses the file and names both numbers rather than reading as far as the fields line up. A new number is a new stability candidate or a major version, never a silent change.
  • cols, rows — the geometry, in the terminal's order: at least 1 and at most 1000 each, as a terminal's is (#582, #583).
  • cursor — three named fields. A hidden cursor keeps its position here ("visible": false), unlike the text format, where cursor: hidden drops it; Screen::diff treats a hidden cursor's position as not part of the picture either way. A col equal to cols — the pending-wrap position releases before 0.11.2 wrote after a write filled the last row (#401) — is read onto the last cell rather than refused, so those files stay readable (#375); any other position off the grid is refused with both numbers named.
  • cells — rows of cells, rows arrays of exactly cols objects, so a JSON diff reads by row. A cell is contents (a string: one grapheme, possibly with combining marks; "" for an erased cell and for the continuation half of a wide character), style, wide and wide_continuation. A style is fg, bg and the eight attribute booleans in SGR order; a colour is "default", {"indexed": 4} or {"rgb": [30, 30, 46]}.
  • state — the out-of-band state the text format omits, every field the accessors on Screen expose: the title, the mode flags, the mouse protocol and the tracking-mode set, the clipboard, the counters, the cursor style, the links, the graphics record, the scrollback (text, and cells when retained), the wrap flags, insert mode, the unsupported sequences with their overflow count, the visual bells. The tracking-mode set keeps its four mode bits and drops any other, which no termlens wrote and which len() counted while iter() never yielded it.

Nothing is omitted that a Screen holds, which is why reading the JSON back gives a Screen that is == to the original where the text format gives one that only diffs empty. Every row must hold cols cells and there must be rows of them; a file that does not hold together is an error, never a screen that panics on its first cell(). Both counts are checked while the grid is read, so the first row or cell past the header's size is refused before anything after it is read, and a file costs memory in proportion to the screen it declares, not to its length (#713). A grid that comes before the header (jq -S sorts cells ahead of cols) is held to the 1000-per-axis limit until the header is known.

A cell must also be one a termlens could have written, because the CLI prints what it reads: a cell carrying an escape sequence drove the terminal of whoever ran render --text or diff on the file (#582). Refused, naming the cell, in the grid and in the retained history alike:

  • a control character — C0, DEL or C1 — anywhere in a cell, by the test Screen::parse applies to a line (#376). vt100 drops every one before it reaches the grid;
  • printable ASCII after a cell's first character. A cell is one character and the zero-width marks after it, and no width table makes ASCII zero-width;
  • an empty cell marked wide, or one whose first character is ASCII with no U+FE0F after it. vt100 sizes a cell by its first character, or, from 0.18, by the whole cell once a U+FE0F asks for emoji presentation, which makes a keycap such as 1️⃣ two columns;
  • a wide_continuation cell that holds text, or that does not follow a wide one.

Two kinds of cell a fresh capture would not make are read as written, because a termlens did write them. Whether a non-ASCII character is a mark, one column or two is an entry in unicode-width's tables, and the tables move between releases — 0.2.1 to 0.2.2, both of which vt100 0.16 accepted, moved 197 code points — so a cell another release wrote correctly can look malformed under this one's; control characters and printable ASCII are the only widths no release reads from a table. And a wide cell need not be followed by its continuation: vt100 0.16, behind every release up to 0.11.6, narrowed a row by truncating it, so a resize that cut a wide character left it in the last column, and widening again left it before a blank. The text reader reads the same row the same way: a wide character whose second column would fall one past the edge is that stranded cell, not an over-long row (#643). So is one that a redraw after widening left before other text: a row too long at two columns per wide character, but not at one for some of them, reads with as many as it needs stranded, the leftmost first (#683). A row that fits reads with every wide character whole, since the text does not say one lost its second half; the JSON twin of such a row, which carries the flags, can then hold the text after a stranded character one column further left than the text reads it. Both render back to the same text.

The compatibility corpus under crates/termlens/tests/compat/ holds both formats as written by each release since 0.10.1, and tests/compat.rs holds every later release to reading them (#327).

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 listed in docs/LIMITATIONS.md: G0–G3 designation, locking shifts on G0/G1, SS2/SS3 for one character, two sets translated — DEC Special Graphics and the UK set — and everything else read as ASCII. DECSC/DECRC save and restore the charset state with the cursor, in one slot per screen as xterm keeps it (#685); LS2/LS3 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 docs/LIMITATIONS.md: 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's Attrs (0.16 through 0.18) 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.18.1 still drops all three, 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). A parameter written with a sub-parameter (9:1) is one of the dropped: xterm reads sub-parameters only on 38 and 48, vt100 matches no such group, and carrying its first value put an attribute on the cell that no terminal shows (#688).

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. A sequence left with no parameter is rewritten to ESC[39m rather than deleted (#390): the ESC introducing it may also be terminating an unterminated OSC or aborting a half-written CSI, and dropping it would remove that ESC from the shadow stream, leaving its parser to swallow the next printable byte. 39 resets only the foreground colour, and foreground is not one of the three attributes read from the shadow.

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. env_clear() also pins SHELL=/bin/sh unless the test sets SHELL itself: the PTY layer fills SHELL from the host's login shell when it is absent, which put one machine-shaped value inside an environment env_clear promises is hermetic (#221).

Without env_clear the child inherits the test process's environment, but never COLUMNS or LINES: curses and tput take them over the size the PTY reports, so a pair exported by the shell that ran the tests laid the program out at that shell's size and kept it there across a resize (#709). Set on the builder, either reaches the child like any other variable.

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, urxvt ?1015, UTF-8 ?1005, the last one set winning as in xterm), click/scroll encode to match, and no tracking enabled is a typed error — never bytes the application would misparse as keys. X10 (?9) is xterm's too: a press carries the button alone, with no modifiers, and a wheel notch is a typed error, since that mode reports neither. 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 button's release carries the modifiers as its press does: SGR repeats the button code with a final m, and the legacy, UTF-8 and urxvt forms send button 3 plus the modifier bits, as xterm's BtnCode does (#701).

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.