termwright-protocol (Rust)
Semantic side-channel client for the termwright terminal test driver.
An instrumented TUI publishes its widget tree over a unix socket and commits each render with a signed OSC marker, so tests assert on roles and names instead of screen-scraping cells. This crate is the protocol side of that contract — framing, the marker, message and snapshot validation, and a blocking socket client. It ships no framework adapter: wire it into whatever draws your screen (ratatui, cursive, a hand-rolled renderer).
Dormant rule. Without TERMWRIGHT_ENDPOINT and TERMWRIGHT_TOKEN in the
environment, Client::from_env returns None and nothing happens at all: no
socket, no marker, no change to what the terminal receives.
Install
[]
= "0.1"
Rust 1.74+, #![forbid(unsafe_code)], five dependencies (serde, serde_json,
sha2, hmac, base64, subtle).
Usage
use ;
let Some = from_env else ;
client.connect?;
// Once per committed frame:
let mut snapshot = new; // session id and revision
snapshot.push; // are filled in
snapshot.push;
client.poll?; // answer driver requests
if let Some = client.publish?
Three rules the client enforces for you:
- The client owns revisions.
publishallocates the next one and overwrites the snapshot'ssession_id/revision; an adapter never picks its own numbers. - The marker commits what precedes it. Write it after the frame's last byte, never before — emitting it early lets the driver act on a paint that has not landed.
- Invalid trees never reach the wire.
publishvalidates against the negotiated limits and returnsError::Validationwithout consuming a revision.
The client is blocking and single-threaded on purpose: a TUI renders on one
thread, and the marker has to follow that render. poll() picks up driver
requests without blocking — call it on each tick.
Application logs
= { = "0.1", = ["tracing"] }
let layer = new;
registry.with.init;
error!; // never painted
The tracing feature is off by default: the crate stays dependency-light for
adapters that do not want a logging framework. Without it, Client::log takes
a LogRecord directly. Either way the client owns the sequence numbers and the
budget, and a dropped record leaves a gap in seq rather than being renumbered.
Diagnostics
When the adapter does not attach, nothing anywhere says why: the dormant rule
means a process with no endpoint behaves exactly like a process that never
heard of termwright. Point TERMWRIGHT_DEBUG_FILE at a file and the adapter
writes down what it decided.
TERMWRIGHT_DEBUG_FILE=/tmp/adapter.log
tw:diag [p41207] 0.000s open adapter=my-tui pid=41207 platform=linux/x86_64 argv0=my-tui
tw:diag [p41207] 0.001s dormant: TERMWRIGHT_TOKEN not set
or, on a session that came up:
tw:sem [p41207] 0.002s dial unix:/tmp/tw-8f21/s timeout=5000ms
tw:sem [p41207] 0.003s hello sent adapter=my-tui/1.0.0 caps=tree,bounds,…
tw:sem [3f9c1a04] 0.011s hello-ack session=3f9c1a04… marker=on subscribe=diffs logs=off
tw:io [3f9c1a04] 0.048s r1 snapshot nodes=17
Three properties are worth knowing before you rely on it:
- It never writes to stderr. The application owns the terminal, and a diagnostic line in the middle of a render corrupts the screen the driver is asserting on. There is no stderr mode to turn on by mistake.
- It never fails the application. An unwritable path, a full disk or a closed file turns the log off and changes nothing else.
- The token never appears in it. The endpoint does, because the endpoint is how you tell one session's socket from another's.
TERMWRIGHT_DEBUG=<path> works too, for symmetry with the driver's own
switch. TERMWRIGHT_DEBUG=1 does not: that value means "log to stderr" to
the driver, it reaches this process as well, and stderr is the one destination
an adapter cannot use. Set the value to a path or the adapter stays silent.
The line format is the driver's, so TERMWRIGHT_DEBUG=1 on the driver and
TERMWRIGHT_DEBUG_FILE=… on the app produce two halves of one story that a
single reader can take.
Deviations
Rules 1–5 of the adapter semantics conventions do not apply here: they govern
how a widget tree becomes semantic nodes, and this crate builds no tree. It
ships no framework adapter at all — roles, names, test ids, states and values
are decided by whatever code calls Client::publish, which is where those
rules land instead.
What the crate does carry is the protocol side that has no adapter in it:
framing, the marker, validation, delta composition and production, and the
tracing bridge. Those follow the wire contract exactly and are checked
against the shared vectors, so there is nothing here to declare an exception
for.
Conformance
tests/vectors.rs runs against clients/test-vectors/, generated from the
normative TypeScript implementation in packages/protocol. Framing bytes,
marker MACs, message parsing and snapshot validation are asserted against the
same vectors in Rust, Python and Go.
One vector group is skipped: serde_json replaces unpaired surrogates with
U+FFFD before this crate can see them, so a frame carrying a lone surrogate is
not detectable here. Those cases are marked "optional": true in
framing.json.
Platform support
Unix domain sockets only. On Windows the driver hands out a named pipe, which
needs a different transport (CreateFile on the pipe path, or a crate such as
tokio's named-pipe support); this crate does not open one, so a
\\.\pipe\… endpoint simply fails to connect and the application carries on
without a side channel. The Go and Python clients do reach the pipe, so the
gap is this crate's, not the protocol's.