# termwright-protocol (Rust)
Semantic side-channel client for the [termwright](https://github.com/gorce-ai/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
```toml
[dependencies]
termwright-protocol = "0.1"
```
Rust 1.74+, `#![forbid(unsafe_code)]`, five dependencies (serde, serde_json,
sha2, hmac, base64, subtle).
## Usage
```rust
use termwright_protocol::{Client, Node, Options, Rect, Role, Snapshot, DIAL_TIMEOUT};
let Some(mut client) = Client::from_env(Options::new("my-tui", "1.0.0")) else {
return; // not instrumented: render normally and stop here
};
client.connect(DIAL_TIMEOUT)?;
// Once per committed frame:
let mut snapshot = Snapshot::new(80, 24); // session id and revision
snapshot.push(Node::new("root", Role::Dialog, "Permission")); // are filled in
snapshot.push(
Node::new("ok", Role::Button, "Approve")
.with_parent("root")
.with_bounds(Rect::new(1, 2, 9, 1)),
);
client.poll()?; // answer driver requests
if let Some(marker) = client.publish(&mut snapshot)? {
print!("{marker}"); // AFTER the render's last byte
std::io::Write::flush(&mut std::io::stdout())?;
}
```
Three rules the client enforces for you:
- **The client owns revisions.** `publish` allocates the next one and
overwrites the snapshot's `session_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.** `publish` validates against the
negotiated limits and returns `Error::Validation` without 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
```toml
termwright-protocol = { version = "0.1", features = ["tracing"] }
```
```rust
let layer = TermwrightLayer::new(Arc::new(Mutex::new(client)));
tracing_subscriber::registry().with(layer).init();
tracing::error!(path = "/etc/app/policy.json", "policy missing"); // 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
```
```text
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:
```text
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.
```sh
cargo test
```
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.