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.
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.
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.
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.
./xand../xare looked up in the working directory alone. Any other relative name is searched for along the child'sPATH, each entry taken from the working directory. A candidate must passaccess(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'sHOME, as in portable-pty. - The environment is the same: the inherited one unless
env_clear, lessCOLUMNSandLINES(§5), plus theSHELLthat 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_execsteps. Six signals go back to their defaults and the mask is emptied.setsidgives the child a session of its own, andTIOCSCTTYmakes 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
HOMEin 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,EACCESorENOENT, 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.
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 ?2026lthen the content. A frame is the bracket, sowait_framesees 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 the6nis answered — the responder answers it as it would any query, which is why the harness runs there at all — and?1004hmeansfocus_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.
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-evaluatespredon a fresh snapshot whenever the reader delivers bytes (condvar notification), with a 50ms poll cap as a missed-wakeup backstop. Fails fast withError::Eofthe moment the PTY closes whilepredis false: more waiting can never succeed. When rows have scrolled into history, both errors say how many and point atfull_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)— evaluatespredonly 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 ascreen()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. Aresizethat 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 — noSIGWINCH, 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
?2026lin 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 atwait_until. -
wait_idle(quiet)— resolves when no bytes forquietAND 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. Preferwait_untilon visible content, orwait_framewhere the app uses synchronized output. -
wait_stable(quiet)— resolves when the picture has not changed forquiet— no cell, cursor or size differs between snapshots — under the same mid-sequence and open-update conditions aswait_idle, and returns the screen that held still. What resets the clock is the difference: bytes forwait_idle, changes here. A bell, a cell rewritten with the glyph already in it, a query answered — output that changes nothing — keepswait_idlefrom 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), thenwait_stablewith 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()— pollstry_waiton 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.
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 |
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 flightThis 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:
-
Put everything you assert into one predicate. A
Screenis 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"))?;
-
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. -
Settle before whole-screen snapshots. A snapshot asserts on cells the test never named, so no targeted predicate can cover it; call
wait_idlefirst — orwait_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.
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 contents.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.
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).
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.
Display for Screen produces:
size: 80x24 cursor: 2,3
╭──────────────────────────────╮
│ repoatlas │
│ > main.go │
╰──────────────────────────────╯
Rules:
-
Header line first:
size: <cols>x<rows> cursor: <row>,<col>— two spaces between the fields; cursor coordinates arerow,colzero-based. A hidden cursor renders ascursor: hidden(TUIs hide the cursor deliberately; snapshots should say so). -
Then the grid verbatim, one terminal row per line, top to bottom.
-
Trailing whitespace is stripped per line (snapshot files stay readable and diff-able), but blanks are preserved inside the
Screengrid, socell(),row_text(), andfind()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. -
Wide characters occupy their real columns: the character appears once; its continuation cell contributes nothing to the text but does count for column arithmetic (
findreturns 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'sstyles:spans and the header's cursor keep their column numbers, so each lands one character left of what it marked.⚠️and a redERR okwritten by 0.11.6 read with the span1-3on the emoji's second half andER, and the cursor it left afterokon thek. 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-afterpins the reading. -
Styles are text-invisible by default;
Screen::with_styles()opts in. Its rendering is the plainDisplayoutput followed by a blank line and astyles:block:styles: 1: 0-8 fg=4 bold; 12 reverse 3: 0-79 bg=#1e1e2eOne 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 juststartfor one column) followed by style tokens in fixed order:fg=,bg=(indexed colors as decimal, RGB as#rrggbb— the tokenColor'sDisplaywrites andScreen::parsereads), thenbold,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 rendersstyles:followed by(none), so the snapshot itself records that styles were asserted. Wide-character continuation cells participate in spans like any other cell.Screenitself 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 comparingto_string()output is style-blind, andwith_styles()is the rendering to compare for "looks the same". -
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 propertyRISdoes 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 thanRISin 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.mdkeeps the exact list.OSC 8hyperlinks are captured, on the same grounds asOSC 52below: 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::linksreports each span with its URI, itsid=(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(aBELin ground state; the one closing anOSCstring 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), andScreen::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 carrym=and none of the image's facts; anda=ddeletes 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.GraphicsBuilderjoins the chunks and classifies the action;deletesis 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 andRIS: the upload is then no image, though its bytes still count, and the escape that interrupted it stands on its own.DECSTRleaves an upload alone, as kitty's does (#587).The payloads themselves land on the same slot, as
GraphicsSeen::payloads. This is the positionOSC 52already 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 whyprocessnow 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(), withcell_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, unlessC=1,U=1orP=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): aVPAfed after the line feeds puts it there, vt100 taking that row as absolute whatever the margins say.Decoding sits behind the
decodefeature 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'so=z). It is still not rendering — aBitmapis 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 reportsNonedata and refuses to decode withDecodeError::NotCapturedrather 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
Screenis an instant, and a performance line is a series. SoTerminal::frame_timingskeeps oneFrameTimingper 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 frameswait_frameretains, 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
ArconScreen.Screenis embedded in everyError, so its size is load-bearing: the counters alone pushedResult<T>past clippy'sresult_large_errthreshold, and theArctookScreenfrom 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.
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 ==.
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.snapfile carries over the content that was snapshotted. - Below: the trailer
termlens inspectwrote 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, sotermlens inspect prog > filesaves a screendiffandrenderread 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.10inspectreads 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.
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, wherecursor: hiddendrops it;Screen::difftreats a hidden cursor's position as not part of the picture either way. Acolequal tocols— 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,rowsarrays of exactlycolsobjects, so a JSON diff reads by row. A cell iscontents(a string: one grapheme, possibly with combining marks;""for an erased cell and for the continuation half of a wide character),style,wideandwide_continuation. A style isfg,bgand 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 onScreenexpose: 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 whichlen()counted whileiter()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::parseapplies 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 as1️⃣two columns; - a
wide_continuationcell that holds text, or that does not follow awideone.
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).
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.
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
Emulatortrait 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 ( 0reachesunhandled_escapeand is dropped — so DEC Special Graphics is translated by termlens itself. The sequence tracker holds the G0–G3 designations, theSI/SOlocking 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 indocs/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/DECRCsave and restore the charset state with the cursor, in one slot per screen as xterm keeps it (#685);LS2/LS3are 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,CHTandCBTreachedunhandled_escape/unhandled_csiand were dropped, whilehts,tbcandcbtsit 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 asCSI 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. PlainHTis rewritten too — vt100 does act on it, and its eight and a custom stop would disagree the moment an application set one; theCHAlands after it and wins, which is sound becauseHTdraws 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 invariantsnapshotdebug-asserts still holds. Scope is indocs/LIMITATIONS.md:TBC 0andTBC 3only, 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.
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.
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.
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.