termwright-protocol 0.3.0

Semantic side-channel client for the termwright terminal test driver: framing, render-commit markers, snapshot validation
Documentation

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).

The client speaks termwright/2. Every published semantic revision is a complete v2 snapshot with evidence-qualified geometry and pointer observations.

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

[dependencies]
termwright-protocol = "0.1"

Rust 1.74+, #![forbid(unsafe_code)], five dependencies (serde, serde_json, sha2, hmac, base64, subtle).

Usage

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.

Framework probes must not perform socket I/O under a framework render lock. After the handshake they move Client into PublicationQueue: the render boundary validates and encodes a complete snapshot+commit pair, then admits it to a bounded FIFO without waiting for transport. The returned marker proves queue admission, not socket completion; the driver independently pairs marker and semantic frames. Queue pressure returns PublicationQueueFull without a revision gap or marker, and a worker failure closes later admission.

Framework lifecycle code must call PublicationQueue::shutdown() outside the render hook. It closes admission, drains the FIFO and joins the writer, which prevents a short-lived one-frame process from exiting ahead of semantic data that it already admitted.

Application logs

termwright-protocol = { version = "0.1", features = ["tracing"] }
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
  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,intended-geometry,…
  tw:sem  [3f9c1a04]  0.011s hello-ack session=3f9c1a04… marker=on subscribe=snapshots 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, full-snapshot validation and publication, 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.

Application evidence providers

The evidence module exposes closed pointer, focus, scroll, paint, input-mode, and action-strategy families. FocusProvider::observe returns Some(recipient) or authoritative None; wire encoding preserves that distinction as focused | none. Registration is frozen before the session and recipes remain data executed by Termwright's PTY devices, never application callbacks.

InputModeProvider::observe reports the application's production parser configuration. It can prove modes hidden by ConPTY, while input still crosses the real named-pipe/PTY path and conflicting observable VT state fails closed.

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.

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 uses a Unix domain socket. Windows uses the exact-certified interprocess 2.4.2 byte-mode named-pipe transport at the driver's \\.\pipe\… endpoint. Both preserve the same length-prefixed protocol. Windows I/O is nonblocking: polling never stalls the render thread, and a whole-frame monotonic deadline bounds writes when the driver stops reading. CI executes a real Windows named-pipe handshake and snapshot publication; a cross-target build alone is not treated as functional proof.