Releases: vyncint/termlens
Release list
v0.9.0
Added
-
Error::Emulator: an emulator panic is a diagnosis, not a timeout. The
emulation runs on the reader thread, so a panic there propagated nowhere —
the drain died, the screen froze, and every wait burned its full deadline
reporting a predicate that could never come true. The reader now catches it,
records it, and keeps draining (a stalled drain blocks the child writing
into a full buffer);wait_until,wait_frameandwait_idlefail at once
with the emulator's own message and the last screen taken before the
failure. The emulator is never asked for a screen again — after a panic its
state means nothing.wait_exitis deliberately unaffected: the child's
exit status is still true. (#211) -
snapshot_afterandwait_stable: the whole-screen snapshot as one
call, and a settle that output changing nothing cannot hold up.
snapshot_after(pred)waits for the predicate, then for the picture to
hold still for 100ms, and returns that screen — DESIGN §2's three rules
for race-free waits without having to remember them.wait_stable(quiet)
is the settle on its own, and differs fromwait_idlein what resets the
clock: changes rather than bytes, so a bell, a cell rewritten with the
glyph already in it or an answered query — outputwait_idlecan never
see silence through — is invisible to it. Both have_fortwins, refuse
to settle inside an open synchronized update, count stillness that
predates the call, and return the screen they settled on. -
termlens::bin!("myapp")spawns one of your package's binaries under
the harness defaults. Every integration test of a binary opened with the
same five lines — a fixed 80x24 grid,env_clear(), a five-second
deadline,spawn(env!("CARGO_BIN_EXE_myapp"))— so the chain has a name.
Builder calls follow the name and override any default:
termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?. A
misspelled binary is a compile error naming the variable, not a spawn
failure at run time. -
Screen::mouse_modesreports every mouse tracking mode the application
enabled, andDECRQManswers each one on its own evidence. The backend
collapses?9/?1000/?1002/?1003into the one protocol a terminal
reports in — right for the input path, and unchanged there — so it could
not say which members of the group an application asked for: crossterm's
EnableMouseCapturesends three at once and only the last survived, a
regression from any-motion to button-motion tracking (losing hover) was
invisible, and aDECRQMprobe for any member but the last had to be
answered "not recognized". The sequence tracker now keeps the requested
set;mouse_mode()still reports the protocol. (#151) -
The fresh-install check verifies a
--no-default-featuresconsumer as
well as adecodeone.install.ymlis the only job that builds
termlens from outside this workspace, and it did so in one shape — the
one the fewest real consumers use: of the three in-house ones, two declare
default-features = false. Its matrix now runs both shapes on Ubuntu and
macOS, the registry check demands thedecodefeature only on the leg
that asks for it, and the no-defaults leg fails if the consumer's tree
still resolvesinsta. (#238) -
The
inspectexample answers--help, and takes its deadline and
silence window from flags.inspect --helpused to look for a program
called--help, and both timings were hardcoded, so an application slower
than five seconds to paint its first screen could not be inspected at all.
--timeout SECONDS(default 5) and--idle MILLIS(default 300) now sit
beside--size;--help/-hprint one usage text to stdout and exit 0,
a missing program prints the same text to stderr and exits 1, and
--versionnames the termlens version the example was built from. An
unknown option is refused rather than spawned. (#229, #236)
Changed
-
The smallest terminal is 2x2, not 1x1. One column panics the emulator on
a double-width character, and one row panics it on a line that wraps — on
the reader thread, in both profiles, where the panic propagates nowhere: the
grid froze, every later wait ran to its deadline against a plausible-looking
screen, andcargo testprintedtest result: okover a suite that had
stopped testing anything.80x1is an ordinary shape, not an exotic one.
spawnandresizenow refuse a dimension below 2 withError::Size, the
way they already refused 0 — #49's "no path can reach the emulator with a
zero" was satisfied exactly one value too low.2x8and2x2render both
trigger shapes correctly, so the floor is the smallest guard that closes
them. (#211) -
A child starts in the test process's working directory, not
$HOME.
Withoutcurrent_dirthe PTY layer fell back to the home directory, so a
relativespawn()path and a directory-sensitive program behaved unlike
every other Rust process API — whilecurrent_dir's rustdoc promised the
test runner's directory all along. The default is now what
std::process::Commanddoes;current_dirstill overrides it. A test that
relied on the old fallback should say.current_dir(std::env::home_dir())
explicitly. (#215)
Fixed
-
G2/G3 designation and SS2/SS3 single shifts are modelled, so a
one-character line-drawing shift draws┌rather thanl.ESC * 0
/ESC + 0designate the DEC Special Graphics set into G2/G3, and
ESC N(SS2) /ESC O(SS3) invoke that set for exactly one character
before the locking shift resumes. The designation was already consumed;
the shift did nothing, so a mixed line of text and box-drawing showed
the letter. A pending single shift is consumed by the next character —
including a multi-byte UTF-8 one — and does not surviveRIS. Locking
shifts remain G0/G1 only (SO/SI);LS2/LS3andDECSC/DECRCof
charset state are still unmodelled. (#235) -
DECSC/DECRCsave and restore the character-set state. Save,
jump, draw the frame, restore is how a full-screen application draws a
border, and the restore lost the designation, so the border after it
rendered aslqk— the failure #204 fixed, arriving through a different
door.ESC 7now saves G0–G3 and the locking shift alongside the cursor
the backend already saved,ESC 8restores them, a restore with nothing
saved returns to ASCII as xterm does, andRISclears the slot so a
restore cannot resurrect a designation from before the reset. (#232) -
DECSTR(soft reset,CSI ! p) is modelled. The polite reset a
well-behaved TUI sends on startup and teardown parsed cleanly and did
nothing, so text printed after it kept rendering in the character set the
application had told the terminal to forget, whileRISgot this right.
It now returns the character sets and theDECSCslot to power-on, the
cursor shape to the terminal's default, and turns off cursor-key mode,
bracketed paste, every mouse tracking mode and encoding, and focus
reporting; the cursor becomes visible and the alternate screen is left
alone, as specified. Attributes, margins, origin and insert modes and the
keypad are not replayed — nothing onScreenobserves them — and the
README says so. (#233) -
The UK character set is translated:
ESC ( Athen#draws£.
The designation was parsed and then rendered as ASCII, so an application
printing a price in the UK set showed#42on the grid, a test asserting
£42failed against a correct application, and a snapshot that blessed
#42kept passing. The set differs from ASCII in that one position, so
that is the one byte translated; the alternate-ROM sets and the other
national sets still read as ASCII, and the docs now say which sets are
translated. (#234) -
findno longer matches the blank padding past the end of a row. Its
single-row path searched the row padded out to the terminal width while
containssearched the trimmed text, sofind("Total: ")wasSomeon
a screen whose row readTotal:— against the invariant both rustdocs
state, that a needle is found precisely whencontainsis true. Both now
trim trailing whitespace per row first; the trim treats a drawn trailing
U+00A0/U+3000 as padding too, and both rustdocs say so. (#212) -
env_clear()no longer leaks the machine's login shell. The PTY layer
fillsSHELLfrom the host when the variable is absent, so a child's
supposedly hermetic environment differed between two machines with
different shells.SHELL=/bin/shis now pinned underenv_clearthe way
TERMis, an explicit.env("SHELL", …)still wins, and a test asserts
the whole environment rather than probing one name. (#221) -
A bare program name under
env_clear()is refused with the remedies.
Clearing the environment removesPATH, sospawn("sh")could not
resolve and failed with the PTY layer's "Unable to resolve the PATH",
which named neither the cause nor a way out.spawnnow refuses it up
front with anError::Spawnthat saysenv_clearremovedPATHand
offers both fixes: an absolute path, or.env("PATH", …). (#222) -
A byte that is not UTF-8 shows as U+FFFD instead of vanishing. The
backend drops both a byte it cannot decode and the U+FFFD its own parser
substitutes for one, so a Latin-1éin a file name left no trace on the
grid and every column after it shifted left — a test asserting on the
column of what followed passed against the wrong screen. Bytes are now
decoded once on the reader thread: an invalid sequence becomes the
replacement character a terminal shows (carried through the backend as a
noncharacter it will draw, and restored in the snapshot), and a character
split across two reads is carried rather than replaced.wait_idletreats
a stream that stopped mid-character as not yet idle. (...
v0.8.0
Added
-
DEC Special Graphics is translated, so an ncurses border reads as
┌───┐rather thanlqqqk.ESC ( 0selects the line-drawing set —
it is whatsmacs/rmacsare on an xterm terminfo, so it is how every
ncurses application and plenty that are not draw their frames — and the
bytes used to reach the grid untranslated. That put the crate's own
promise in the wrong: a user sees a box, and a test asserting a border was
right failed against an application that was correct, while a snapshot
that had blessedlqqqkwent on passing after the border broke. The
vt100backend drops the designation entirely, so termlens tracks the
G0/G1 designations (ESC ( Ps/ESC ) Ps) and theSO/SIlocking
shifts itself and hands both parsers the glyph a byte draws. Scope is
stated rather than implied: only the DEC Special Graphics set is
translated, other designations read as ASCII, G2/G3 and the single shifts
are not modelled, and a hard reset (RIS) returns both sets to ASCII. -
Terminal::scroll_with, withScroll::ctrl()/alt()/shift()
and aScrollChordtype.Ctrl-wheel is zoom andShift-wheel is
horizontal scrolling in a large share of terminal applications, and
neither could be sent:scrolltook a bare direction, so the binding most
likely to be wired to the wrong handler was the one with no coverage. The
modifier bits ride on the wheel's button code exactly as they do on a
click, andscrollis now the unmodified case ofscroll_with, as
clickis ofclick_with. The wheel gets a chord type of its own rather
than a widerMouseChord, so a wheel direction cannot be handed to
click_with: a notch has no release, and the type keeps that from being a
runtime surprise. -
ScreenimplementsPartialEqandEq. It was the only public
value type without them, so "did anything change?" was written by
comparingto_string()renderings — which is text only, and so passes
two screens that differ in a highlight, a colour or a concealed field.
Equality means the same observation: cells, cursor, size, and every
piece of out-of-band state including the cumulativerepaintsand
bellscounters, so two visually identical snapshots either side of a
bell compare unequal because they are different moments. The doc says
which comparison to reach for when "looks the same" is what is meant. -
A content wait that fails while rows have scrolled off says so. The
most-copied line in these docs,wait_until(|s| s.contains(..)), reads
the visible grid, so text the application printed and then scrolled away
in the same burst can never satisfy it — and the screen embedded in the
timeout does not show the text either, so the failure read as "the app
never printed it".Error::TimeoutandError::Eoffromwait_until
andwait_framenow say how many rows have scrolled off the top and
point atScreen::full_text. The note is conditional, because the wait
cannot know what an arbitrary closure was looking for; it is silent when
nothing has scrolled.containsandfinddocument where they stop.
Changed
resizeis refused once the child has released the terminal, with
the sameError::Writethatsendreturns, naming the child and its exit
status. It used to succeed, and the snapshot then reported a geometry no
application ever rendered at with the dead child's last frame clipped
underneath it — the one operation on a departed child that silently
mutated observable state. The final screen stays readable at the size the
child exited with. A size that cannot work is stillError::Size, checked
first.
Fixed
-
Bitmap::coloursis linear in the pixel count, and its order is
defined. It counted distinct colours with a scan of the distinct set per
pixel — O(pixels × colours), 9 ms for 96x96 and extrapolating to seconds
for a screenshot, in a test suite, where a wait that takes seven seconds
looks like a hang. It now counts in one pass. Ties, which the old
implementation happened to order by first appearance through a stable
sort, are now ordered that way on purpose and the doc says so: a test
asserting oncolours()[0]has one answer. -
wait_frameafterresizecould miss the repaint that answered it.
resizesent theSIGWINCHfirst and took the frame cursor afterwards, so
a fast application's acknowledging repaint could complete in that gap, be
counted as a frame from before the resize, and never be offered — the
wait then timed out reporting that the application had not repainted,
while the live screen showed that it had. Found by the stress workflow at
16 threads, once in 25 runs. The grid is now resized and the cursor taken
before the signal goes out, under one lock, so a frame drawn in answer to
the resize is always newer than the cursor and always lands in a grid of
the new size. -
resizedocuments the resize-then-type trap. A keystroke that reaches
a crossterm application in the same instant as theSIGWINCHcan be lost:
its event reader returns theResizeas soon as the poll reports the
signal and abandons the input readiness delivered alongside, and the poll
is edge-triggered, so the byte is not offered again until more input
arrives. The stress workflow caught termlens's own suite doing exactly that
— a resize followed at once byEschung the fixture about one run in
forty. The test now waits for the application to acknowledge the resize,
which is the advice the doc gives. -
resizedocuments what happens to history. Rows already in scrollback
keep the width they were captured at and rows captured afterwards have the
new width, sofull_text()after a narrowing resize can hold both
geometries. That was true before and said nowhere a caller would meet it;
it is now a recorded decision onresizeandscrollback_text, with the
alternative — discarding history on resize — rejected in writing rather
than by omission. -
Doc comments on
TerminalBuilder::size,spawnandresizename
Error::Sizefor a rejected size, notError::Input. The links
resolved, to the wrong variant, so following them gave amatcharm that
never fires — andErroris#[non_exhaustive], so a wildcard elsewhere
would have swallowed the mistake silently. -
Colon-form SGR colours now reach cell styles.
38:2::r:g:b,
38:2:r:g:b, and indexed foreground and background colours are normalized
before the backend parses them, matching their semicolon-form equivalents. -
Mouse
click/click_with/drag/scrollrefuse coordinates
outside the current grid. A real terminal cannot produce an off-window
mouse event; sending one was the same class of mistake as clicking with
no tracking enabled. The error names the position and the grid size at
the time of the call (so a post-resizerejection is obvious).drag
checks both endpoints only — the interpolated path cannot leave the
rectangle they span. Separately, the SGR encoder no longer wraps or
panics atu16::MAX: the 1-based+ 1is done inu32.
Security
SECURITY.mdno longer claims the crate has nounsafe. It has two
blocks, both FFI and both present since the features they serve landed:
dup(2), which opens the responder thread's writer on the PTY master, and
kill(2)behindTerminal::signal. Neither touches memory the child can
influence and the parsing path has none, which is the claim that matters;
the policy now says exactly that rather than something stronger and
false.
v0.7.0
Added
-
TerminalBuilder::envssets several child environment variables from an
iterator of key-value pairs. Values keep their iteration and builder-call
order, and remain explicit whenenv_cleardisables inherited variables. -
Screen::linksreports theOSC 8hyperlinks an application emitted.
A hyperlink changes no cell — its label renders exactly as unlinked text
would — so the URL existed nowhere a test could reach, and an assertion
that a TUI linked an issue, a file or a doc page passed identically
against an application that emitted no link at all, or linked the wrong
target. Captured rather than answered, on the same grounds as the
OSC 52clipboard: the only evidence otherwise available is the
application's own visible output, which proves the code path ran and
nothing about where it points.Each span reports its
uri, itsid(spans sharing one are one logical
link), thelabelit wrapped, and whether the application everclosed
it — an unterminated link is a real defect, because in a real terminal
every character written afterwards joins it. Two bounds keep the capture
honest: the log holds the most recent 64 spans and evicts oldest-first, so
a TUI that redraws its links every frame still reports the current
frame's; and a label past the capture bound is reported as unknown
rather than as a prefix, since a prefix of the wrong length is a wrong
answer. -
Screen::cursor_shapeandScreen::cursor_blinkreportDECSCUSR.
A screen where the application asked for a bar and one where it never
asked used to be the sameScreen. The shape is load-bearing behaviour
rather than decoration — a modal editor switches to a bar for insert and
back to a block for normal, and "the mode indicator says INSERT" and "the
terminal was actually put into insert" are different claims. It also makes
the restore assertable, which is the half that ships broken: a program
that changes the cursor and never changes it back leaves the user's
terminal wrong after exit, the same class of defectalternate_screen()
already catches.Shape and blink are one
DECSCUSRparameter but two facts, so they are
reported apart.CursorShape::Default— the application never sent the
escape — is a third state and is reported as itself rather than folded
intoBlock. -
Key::Insert, encodingESC [ 2 ~, and chording like its
neighbours (Key::Insert.shift()→ESC [ 2 ; 2 ~). It was the2
missing from a navigation run that already had3,5and6, so an
application binding Insert could not be tested without hand-writing the
escape.
Changed
-
KeyandSignalare now#[non_exhaustive]. This is breaking for
downstream code thatmatches either without a wildcard arm; adding a
_ => …fixes it, and equality and construction are unaffected.Worth doing now rather than later. Adding a variant to an exhaustive
public enum is itself a breaking change, so every future key and every
future signal would have cost a version of its own —Keyhas no F13+ and
no keypad, andSignalcarries seven of POSIX's thirty, missing
SIGWINCHandSIGCONT, which are exactly what a terminal application
reacts to. Both types are constructed far more often than matched
(t.send(Key::Enter),t.signal(Signal::Int)), so the cost falls almost
entirely on the crate and not on its users.#[non_exhaustive]is
breaking to add, which makes the cheapest moment the earliest one.Coloris deliberately left exhaustive. Default, palette index and 24-bit
RGB is the whole terminal colour model — there is no fourth variant
waiting — andColoris the one enum here that downstream code really
does match on.
Security
-
A decoded image can no longer choose how much memory it allocates.
GraphicsPayload::decodetrusted four sizes that the program under test
writes: kitty'ss=/v=, sixel's raster attributes, its!nrepeat count
and its#ncolour-register index. A compressed kitty payload was also
inflated with no output limit, and zlib reaches about 1000:1. Each turned a
handful of bytes into a request for tens of gigabytes —!4294967295~is
twelve bytes; a declared65535x65535is about twenty and asks for 17 GB
before the pixel data is touched at all.Decoding is now bounded: no image above 4096x4096 (far beyond what a
terminal can place, and 64 MiB of RGBA once built), sixel colour registers
capped at 65536, and a compressed payload inflated only as far as its
declared size needs. Refusals are a newDecodeError::TooLargerather than
a silent clamp —DecodeErroris#[non_exhaustive], so matching on it
already required a wildcard.Present in every release before this one. It sits behind the off-by-default
decodefeature and is reached only when a test callsdecode(), so a
suite that merely counts images was never exposed.SECURITY.mdnow
enumerates these bounds with the rest.
Fixed
-
A hard reset (
RIS,ESC c) returns the cursor shape to the terminal's
default and closes any openOSC 8span.printf '\033c'is one of the
ways a program hands the terminal back on exit, so reporting the last
DECSCUSRafter one claimed a shape the terminal no longer held — and it
did so in exactly the casecursor_shapeexists to check. The window
title, the clipboard, the bell count and the link log are deliberately
left alone: the title 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. -
The crate's doctests build with default features disabled. The bundled
snapshot macro example is compiled only when itsinstafeature exists,
and CI now runscargo test --workspace --no-default-featuresso this
supported configuration cannot silently rot again.
v0.6.1
Fixed
-
spawnno longer fails when the machine is briefly out of PTY devices.
On macOS a PTY is torn down withrevoke()and its device recycled, and a
suite asking for devices faster than the kernel returns them getsENXIO—
"Device not configured", which reads like a broken machine and is really a
queue.cargo testruns one test per core by default, so this is what a
sixteen-core Mac does with any suite of this shape; the failure was not
exotic, it was Tuesday.openptyis now retried for about 1.6 seconds
before giving up, releasing the PTY lifecycle lock between attempts —
that lock is the one a teardown also takes, so waiting under it would have
blocked the only work capable of freeing a device.Found by the stress workflow the first time it ran the suite at sixteen
threads, on macOS; Linux had run the same suite twenty-five times over
without noticing.tests/concurrency.rsnow applies the same pressure on
purpose — two dozen terminals at once, and eight rounds of open-and-recycle
— so it is reproducible rather than a matter of which shard drew the short
straw.
v0.6.0
What an application drew, as against how many bytes it spent drawing it.
Inline graphics were observable only as a count and a size: an image had
gone out, and it had been about so big. Three things were wrong with that,
and the first two were wrong rather than merely thin — the count was of
escapes, not of images, so the kitty protocol's own 4096-byte chunking
inflated it and a delete posed as a transmission.
Added
GraphicsSeen::payloads— the transmissions themselves. Each
GraphicsPayloadcarries its protocol, action, format, compression,
image id, the pixel size and cell extent the application declared, the
bytes it cost, the chunks it took, and the data itself. Placement is the
one fact that lives in the grid rather than in the payload, soat()
reports the cursor position at the terminator — the image's top-left
corner for both protocols. An application that lays out in characters and
draws in pixels can now be held to keeping the two in step, which is a
failure nothing on screen shows: a picture that slides out from under its
own labels leaves every cell exactly as it was.GraphicsSeen::deletes, counting kittya=d— images taken off
the screen — apart from images transmitted.- The
decodefeature:GraphicsPayload::decodeandBitmap. Kitty
f=24/f=32, zlib'd or not, and the sixel data stream decode into
pixels, so an assertion can be about the picture rather than about its
size. Off by default: it is the one thing here needing a dependency of
its own (zlib), and every other fact about a payload stays free. Refusals
name their reason —f=100(PNG) is unsupported rather than guessed, a
delete carries no image, and a payload past the capture bound says so
instead of decoding a prefix of itself into a plausible wrong picture. TerminalBuilder::capture_graphics, the retention budget: 4 MiB by
default,0to keep counts and drop every byte. Bounded like scrollback,
and the counters stay exact whatever the bound.- The
image-echofixture, which transmits a known image over kitty
(compressed, plain, and chunked), over sixel, and with a delete after it.
Fixed
- A chunked kitty transmission is one image, not one per escape. The
protocol caps a payload at 4096 bytes and continues withm=1, so a
4.9 KB chart counted as two images and the continuations — which carry no
control block — counted as pictures nothing could be said about. The
chunks are joined before anything is counted. - A kitty delete is no longer counted as an image transmitted.
a=d
carries no picture. Every byte of it is still counted inbytes(): a
delete is traffic.
Changed
GraphicsSeenisClonerather thanCopy, since it now carries the
payload list. Existing code that reads a counter is unaffected; code that
copied the value into two bindings needs aclone.
v0.5.0
What the harness could not observe, could not reach, and quietly got wrong.
Seventeen issues, every one verified against the published 0.4.2 before a
line was written — four by reproductions that contradicted the report, and
one of those by a reproduction that contradicted me. Three themes:
behaviour a test could not see at all (repaints, bells, images, focus),
applications that could not be driven down a path they probe for first, and
accessors that answered confidently where they had nothing to say.
Two API changes are breaking, both in the direction of honesty:
send/send_str/paste return Result, and ExitStatus::code returns
Option.
Changed
-
send,send_strandpastereturnResult<()>and no longer
panic. Every input call in the crate is now fallible, so a write that
cannot be delivered is something a test can see, handle, or propagate
with?— previously the only route from a failed write to the test was
aborting it. Call sites grow a?; that is the whole migration. -
Typed input to a closed terminal is refused identically on Linux and
macOS. It was not: a write to a master whose slave descriptors are all
closed fails withEIOon macOS and succeeds on Linux, queueing the
bytes for a reader that no longer exists. The same keystroke was
therefore an error on one CI runner and silently discarded on the other.
Every sender now checks for a closed terminal before writing, so the
answer is the same everywhere and no keystroke is lost quietly. -
A batch of startup probes is answered in full, and the reply queue is
now bounded by memory rather than by queue slots — which took three
attempts to get right, each one teaching what the invariant actually is.
200 queries asked back to back returned 173 answers; 400 returned 235; 1000
returned 285. The stated cause — the application had stopped reading — was
wrong: the same 200 queries a millisecond apart were all answered, so
nothing was blocked anywhere. The reader was enqueueing one entry per
reply while the writer issued onewrite(2)per entry, so it outran the
writer and the 64-slot queue overflowed. Batching per read fixed that on a
fast machine — but on a slow one an application's writes dribble out, the
same 400 queries arrive in hundreds of small reads, and 64 slots ran out
again at 235 of 400. Slots were never the thing worth bounding: the queue is
now unbounded with a 1 MiB ceiling on undelivered reply bytes, so the
reader can never block, a real application is never shorted, and a hostile
one still cannot grow memory without limit. The writer coalesces whatever is
queued into a single write. -
Undelivered replies are counted whether dropped or blocked mid-write, so
a non-reading application is named in the wait error rather than producing a
plain timeout.
One diagnosis got weaker on Linux, and that is the price of the fix
above. The note used to appear there because replies overflowed our queue
— the same overflow that was losing a well-behaved application's answers.
With that fixed, the replies reach the kernel, and the platforms diverge: a
write into a full terminal input queue blocks on macOS, where the backlog
stays visible and the count is exact, while Linux'sn_ttydiscards input
once its 4 KB buffer is full — the write succeeds, the bytes are gone, and
nothing distinguishes that from delivery. We cannot report what we were never
told.docs/DESIGN.md§1 states the split; the trade is a diagnosis for a
pathological application in exchange for a well-behaved one actually
receiving its answers. -
dragreports one motion per cell crossed, on a straight interpolated
path, instead of a single report at the destination. Seven cells crossed
used to produce one motion event. Invisible to an application that only
asks "where did it start, where is it now" — which is why it went unnoticed
— and wrong for every application that does something along the path: a
drawing surface painting each crossed cell, a selection highlighting
incrementally, a drag that must cross a pane edge to register. The
mode-aware refusals are unchanged:?1000still hears no motion at all,
and X10 is still a typed error. -
A mouse action at a departed child names the child.
click,drag
andscrollcheck liveness before the mouse-tracking mode, because a
child that has exited necessarily never enabled tracking either — so the
old order reported a missingCSI ?1000 hfor a terminal whose
application was simply gone. The tracking-mode error is unchanged for a
live application that really has not enabled it. -
ExitStatus::codereturnsOption<u32>,Nonewhen a signal killed
the child. A signalled process has no exit status — POSIX gives one or
the other — and the OS placeholder (1) that filled the slot made
assert_eq!(status.code(), 1)pass on aSIGTERMpath, which would keep
passing if the application later started exiting 1 for a real reason.
Displayno longer prints the invented(code 1)tail either. Mirrors
std::process::ExitStatus::code. -
Screen::rect_textpanics on a backwards range instead of returning
""or a bare"\n", and both axes now behave identically — they did
not. It reads as "this pane is empty", a plausible assertion outcome, so
a call with its arguments swapped passed for the wrong reason and kept
passing. A panic rather than an error for the same reason&slice[3..0]
panics: a backwards literal range is a mistake in the calling source, not
a fact about the terminal. Out-of-range bounds are a different thing and
stay clamped. -
An implausible terminal size is refused, at most 1000 per axis, with
the limit named.5000x5000used to spawn happily and then spend 16
seconds inside the first wait before timing out with a message about the
predicate — a transposed.size()turned a sub-second test into a wedged
one with no hint of why.resizeis held to the same limit. -
Screen::containsandScreen::findfold both sides to NFC, so a
needle finds text the application normalized the other way. A terminal
drawscaf\u{e9}andcafe\u{301}identically — and so do the failure
output and the diff, which is what made the mismatch a trap rather than a
limitation: an author types NFC (what editors produce) while text from a
filesystem path, a git author name or macOS input is frequently NFD.
Unconditional, and no escape hatch is needed because the raw form is never
taken away:text,row_text,rect_text,cellandtitleall still
return exactly the codepoints the application sent. One consequence worth
knowing: matching is grapheme-shaped, so on a screen showingcaf\u{e9},
contains("cafe")is now false — the screen does not showcafe.
Added
Screen::repaints— how many synchronized updates the application has
completed, as of this observation, on every snapshot including the frames
wait_framereturns. It counts repaints, not changes, so a
Begin/End pair that drew nothing still counts, which is exactly the
property an amplification test needs: "one wheel notch produced four
repaints" is invisible to every content predicate, because each
intermediate frame shows correct content.Terminal::frame_timingsandFrameTiming— per-repaint wall-clock cost
and printable-character count, so a suite can hold a performance line as well
as a correctness one. A TUI's most common regression is not wrong output; it
is a repaint that got slower or larger, and no content predicate sees either.
Both ends of the span are stamped at the byte carrying the marker, not when
the read arrived, so a burst delivered in one read is still timed per frame.
The docs state what the span includes rather than leaving it to be assumed:
it is measured through a PTY and covers the application's write pacing, so it
is a trend to watch and not a render benchmark. Bounded at 512 repaints,
independently of the eight frameswait_frameretains, since a timing is
three words where a frame is a whole grid.Screen::bells— how many times the application rangBEL. The bell
is often the only feedback a rejected input produces, so "an invalid key
does nothing" and "an invalid key is refused with a bell" used to be the
same screen. A count, not a flag, so twice differs from once; and only a
BELin ground state counts, since the one terminating anOSCstring is
punctuation and one inside a DCS-class string is payload.Screen::graphicsandGraphicsSeen— kitty (APC G … ST) and
sixel (DCS q … ST) payloads transmitted, by protocol, with total bytes.
The assertion this exists for is as often the negative one —
assert!(s.graphics().is_empty()), "this must render as text in every
terminal and never go out as an image" — sois_emptyis a method rather
than something to spell out. Observing is not rendering and claims
nothing: DA1 still declines both protocols.- The kitty graphics query is diagnosed.
APC _G…a=q…STwas swallowed
whole — no answer and no mention in the timeout note, alone among the
startup probes, becausestring_finalinspected only+q/$qand an APC
matches neither. An application blocked on it now gets the same one-line
diagnosis^[[?uand^[P+q…already got. Only an explicita=qcounts
as a question: a transmission is an instruction, and treating one as a
query would put "the application queried the terminal" into the next
timeout of every application that draws. XTGETTCAPis answered — the last of the common startup probes with no
reply. A capability termlens genuinely implements gets a truthful
DCS 1 + r <name>=<value> ST; anything else gets an explicit
DCS 0 + r <name> ST, which is the half that turns a hang into a decision:
the application...
v0.4.2
The documentation set, brought up to what 0.4 actually does.
Two statements were wrong and the rest understated the crate by a
release or two. No library code changed.
0.4.1 was tagged for exactly this content and never published: its
release run caught a latent race in this suite's own UTF-8 mouse test —
padding written after a click could be read by the script's exit guard
instead of by head, ending the child early so the next write failed
with EIO. Fixed before publishing, so the version on crates.io is the one
whose gates all passed.
Fixed
- The README no longer contradicts itself about scrollback. Its
limitations section was still headed(v0.3)and still opened with "No
scrollback assertions" — sixty lines below the paragraph explaining that
scrollback is retained, 1000 rows by default. Since the README is the
crates.io front page, the first thing a reader learned about 0.4's
headline feature was that it did not exist. The section now states the
bounds that actually hold: history is capped, text only and unreflowed;
wait_frameneeds the application to opt into DEC 2026 and retains eight
frames; and the questions termlens declines to guess at are named. SECURITY.mdno longer claims the emulator runs with zero
scrollback. That sentence was the whole memory-bound argument in the
resource-exhaustion note, and 0.4 made it false — the emulator is
constructed with the configured history length. Every bound a child's
output can reach is listed in its place: history length, retained frames,
the read buffer, theOSC 52capture cap, the reply queue, and the
diagnostics set.
Documented
- The crate-level docs describe 0.4, not 0.2. The docs.rs landing page
never mentionedwait_frame, retained scrollback, per-call deadlines or
the clipboard accessor, so the crate's own front page understated it by
two releases. It now names them, in the same breath as the guarantees
that make them worth using. docs/DESIGN.md§2 records the per-call deadlines. The document that
calls itself the contract for wait semantics had never mentioned the
_forvariants. It also now records thewait_idletimeout that names
an unfinished frame instead of reporting silence against a quiet
terminal.docs/HANDOFF.mdis marked as the historical v0.1 record it is,
rather than reading as a description of the project today — it described
a private repository and an unfinished go-public checklist. The checklist
keeps its original text, with the outcome recorded beneath it, including
the one item resolved differently on purpose (required approvals stay at
0: a solo maintainer cannot approve their own pull request).- The announcement draft carried v0.1's limitations, two of which have
since shipped; it now describes 0.4, and no longer claims to be untracked
while sitting in the repository. The bug-report template no longer offers
0.1.0as its example version.
No library code changed in this release.
v0.4.0
What termlens could not do, and where it quietly did the wrong thing.
Three gaps each made a whole category of subject untestable, and four
defects were found by adversarially probing the 0.3.0 release rather than
by reading its source — two of them undercutting the frame guarantee that
is this crate's headline.
Changed
wait_frameandwait_frame_forreturnResult<Screen>— the
frame the predicate matched. Assert on that rather than on a later
screen(), which can already be a newer state; the old shape let a
test assert on one instant and read another. The dominant
t.wait_frame(..)?;call form still compiles unchanged.wait_frameno longer offers a frame twice. Each call scans only
frames newer than the one it last returned. A frame that satisfied a
wait cannot satisfy the next, so N calls observe N distinct frames, a
burst is observable in emission order (asking backwards now fails), and
send(key)followed bywait_frame(|s| s.contains(OLD_STATE))times
out instead of passing on the superseded frame while the assertion
after it reads the old screen. A frame completed before the call but
never yet returned still matches, deliberately: a fast application must
not be able to slip one past two waits.resizeadvances the cursor
too — a frame drawn at the old size is not the repaint that answers the
new one.Stylegained the public fieldsblink,concealand
strikethrough, so struct literals need updating
(..Style::default()keeps working).with_styles()emits the new
tokens in SGR order —bold dim italic underline blink reverse conceal strikethrough— which leaves an existing span's tokens unchanged
unless the cell carries one of the three.- Scrollback is retained by default (1000 rows;
TerminalBuilder::scrollback(0)restores the old behaviour). Snapshots
now carry history, which is invisible in the text rendering, so
existing snapshot files stay valid.
Added
- Scrollback retention. Content that scrolled off the top used to
cease to exist, which ruled out every application that hands finished
output back to the terminal — a pager, a log view, a TUI that commits
completed blocks into native scrollback and keeps a small live region.
TerminalBuilder::scrollback(rows)sizes the history, andScreen
gainedscrollback_rows,scrollback_textandfull_text— history
followed by the visible screen, which is the assertion an author
actually writes when the application moves content between regions as
it runs. Two limits are stated rather than papered over: history is
bounded, and resize does not reflow. It costs nothing where unused: the
alternate screen accumulates no history at all. Style::conceal,blinkandstrikethrough.SGR 5/6,8and
9reached nothing, so three renderings collapsed into one value.
Conceal was not a missing nicety but a trap: a test asserting that a
password field is masked passed against an application that printed
the secret in clear, andwith_styles()could not break the tie
either. That was the one failure mode in this crate where a green test
certified the bug it was written to catch.OSC 52clipboard capture.Screen::clipboard()reports the most
recent write — the decoded text and the target selections as the
application named them — so "did it copy the right thing?" is
answerable instead of resting on the application's own toast. An
undecodable payload reportsNone, neverSome(""): bad base64, bytes
that are not UTF-8 and a payload past the capture bound are all
distinct from a real write of nothing. Clipboard reads stay
named-but-unanswered.
Fixed
- A stray
?2026lno longer publishes a phantom frame. The frame
publisher fired on any End, whether or not a Begin was seen. The
damaging case was not a false pass but a suppressed diagnosis:
applications reset terminal modes defensively at startup and on crash,
and such a string contains?2026l, so one stray End pushed the frame
count off zero and replaced "the application never emitted a DEC 2026
synchronized update — usewait_until" with a count implying the
predicate was at fault. A frame is now one completed update; a
Begin/End pair that changed nothing still counts, because the count is
of repaints rather than of changes. DECRQMno longer calls the mouse tracking modes "not recognized"
when none is active. The old answer set was self-contradictory —
claiming the SGR mouse encoding while denying the tracking modes
those reports come from — and it closed a loop on itself: an
application doing ordinary probe-then-enable detection concluded the
terminal had no mouse, never enabled tracking, andclickthen refused,
blaming it for a decision termlens caused. With nothing tracking,
nothing was collapsed and every tracking mode is genuinely reset. The
ambiguous case — probing1000while1002is active — stays "not
recognized", since the backend keeps only the last of a group.wait_idletimeouts name an unfinished frame. An application stuck
inside an open synchronized update is silent, so it used to time out
"waiting for 100ms of output silence", which reads as nonsense next to
a quiet terminal. The message now says the application is inside an
unfinished DEC 2026 update and that the screen below is a half-painted
frame.- Timeout messages from
wait_framecarry the reason as well as the
count: when every frame has already been returned, the message says the
application has not repainted rather than implying the predicate is
wrong. Pluralization fixed while there.
Documented
- A snapshot may be a half-painted frame, including for an
application that brackets every repaint in DEC 2026 exactly as
intended:wait_frameis frame-gated,screen()is not. Now stated on
screen, onwait_until's third rule, and indocs/DESIGN.md§2 with
the three routes to a frame-consistent read — one per way of waiting.
The behaviour is deliberate: substituting the newest complete frame
would let await_untilpredicate match content the following
screen()does not show, and a torn read is what you want when
diagnosing an application hung mid-repaint. wait_idlewill not declare idleness while a synchronized update is
open — it treats a begun-and-unfinished repaint the way it treats a
half-received escape sequence. That is what makes the "settle before
whole-screen snapshots" rule work, and it is now a stated guarantee
rather than an implementation detail.
v0.3.0
The features the first real user's coverage study asked for, in the
order it ranked them, plus the terminal-query work that lets
capability-probing applications run against termlens unmodified.
Changed
ScrollgainedLeftandRightvariants and is now
#[non_exhaustive]. Exhaustivematchon it needs a wildcard arm;
marking it non-exhaustive means later additions won't break code
again.
Added
- Writes now respect the terminal's deadline.
send,send_str,
paste,clickandscrollused to block indefinitely if the
application stopped reading its input and the PTY buffer filled — the
one place the crate's own "no unbounded waits" rule wasn't applied.
They now fail at the deadline with the screen attached and a message
naming the real cause, instead of hanging a CI job. - A fuller mouse API:
click_with(button, col, row)for middle and
right buttons,drag(button, from, to), modifier chords
(MouseButton::Left.ctrl(), mirroringKey::Right.ctrl()), and
horizontal wheel viaScroll::Left/Scroll::Right. Everything
stays mode-aware: encoded for the tracking mode and encoding the
application enabled, and refused with a typed error when the mode
cannot express the gesture — a drag under X10, which reports no
release, is an error rather than a misleading half-gesture. - termlens answers
DECRQM("is private mode n set?"), so an
application that probes before using synchronized output enables it
against termlens —wait_frameworks against programs nobody
modified for the harness. Replies are truthful or absent: modes whose
state the emulator holds exactly report set/reset, everything else
reports "not recognized" rather than a guess.DECRQSS,OSC 4
palette reads andOSC 52clipboard reads are now recognized too, so
an application blocked on one is named in the timeout instead of
hanging silently. TerminalBuilder::foreground_rgbconfigures theOSC 10answer,
which was hardcoded white. Applications that pick a theme by
comparing foreground and background luminance can now be tested
against both.- Every wait now takes a per-call deadline:
wait_frame_for,
wait_idle_forandwait_exit_forjoinwait_until_for. One
known-slow step no longer forces the builder timeout up for every
other wait in the suite — which is what made a genuinely stuck
application burn the long timeout on its first failure. Timeout errors
report the deadline that actually applied. wait_frameretains the last 8 completed frames and evaluates
them oldest first, so a burst of frames arriving in a single read is
observable step by step — a progress counter ticking1,2,3in
one write used to be visible only at3. The retention bound and its
two consequences (a longer burst drops its oldest frames; a retained
frame stays matchable, so a predicate satisfied earlier resolves at
once) are documented onwait_frameand indocs/DESIGN.md§2.
v0.2.1
Correctness patch. Every entry below was found by probing the published
0.2.0 rather than reading the source, and each one is a case where the
harness quietly did the wrong thing, panicked inside a dependency, or —
in the worst of them — hung itself.
Changed
pastenow transforms the text the way a real terminal does, so what
the application receives matches what a user pasting would produce.
Line breaks become\r(the byte Enter sends; applications in raw
mode never see\nfrom a terminal), and while bracketed paste is
active, paste markers embedded in the text are removed — previously an
ESC[201~inside the text ended the paste early and the remainder
arrived as ordinary key presses.send_strremains the untransformed
path.
Fixed
- The harness can no longer deadlock itself. Query replies were
written by the reader thread, so an application that emitted queries
faster than it read the answers filled the PTY's input queue, blocked
that write, and stopped the drain — after which the child blocked
writing and neither side could proceed. Reproduced in the default
configuration with no test input at all: the wait timed out with a
stale screen and thenDropnever returned. Replies now go to a
dedicated responder thread, so the drain never writes; undeliverable
replies are counted and reported ("the application is not reading its
input") instead of stalling anything.Drop's reap is bounded too —
teardown must always terminate. - Mouse reports now follow the UTF-8 encoding (mode 1005) when the
application selects it. The encoding was collapsed to "SGR or not", so
a 1005 application received the legacy form — identical below column
95, and a bare non-UTF-8 byte past it, which such an application
cannot decode. - The unanswered-query diagnosis no longer misattributes unrelated
failures. It was recorded once and never cleared, so a single
deliberately-unanswered probe at startup (kitty'sCSI ? uis the
common one) claimed to be the cause of every later timeout. A query is
now only blamed while the application has produced no output since
asking; otherwise it is reported as context. Every unanswered query is
named rather than just the most recent, the set is bounded, and
wait_frameand theEoferrors carry the note too — previously
wait_framewithheld it, which is the worst place for it to be
missing, since an application blocked on a probe never reaches its
first repaint. wait_frametimeouts embed the live screen, like every other
wait. They previously embedded the last completed frame — which can be
arbitrarily old — under a header saying "screen at timeout", so the
one place a CI log is the only evidence showed the wrong screen. The
count of observed frames is still in the message.- A zero terminal dimension is now a typed
Error::Inputfromspawn
andresizeinstead of a panic inside the emulator. In release builds
— the profile the stress workflow uses — it was worse than a panic:
the arithmetic wrapped, both calls returnedOk, and the emulator
panicked on the reader thread, silently killing the drain so every
later snapshot was blank and a careless test went green. current_dirpointing at a path that is not an existing directory now
fails the spawn instead of being silently ignored: the PTY layer
falls back to the home directory, so a directory-sensitive test could
pass against the wrong tree with no error anywhere.spawn("")now fails with a one-lineError::Spawnnaming the problem
instead of surfacing the PTY layer's entirePATHsearch. Genuine
"program not found" failures keep their underlying diagnosis.