tern-sdk 0.2.0

Talk to Tern over the Tern Surface Protocol: typed node builders, diffing with derived ids, flow control, event routing, print/ask helpers and a plain-text fallback.
Documentation

tern-sdk (Rust)

Talk to Tern over the Tern Surface Protocol from any Rust program running in a terminal pane: describe the UI as a tree of nodes, and Tern lays it out, draws and animates it natively. Outside Tern the same program prints plain text.

Synchronous, no async runtime: serde, serde_json, sha2, base64, thiserror, tracing, and libc / windows-sys for the tty.

Install

cargo add tern-sdk

The library is tern_sdk.

The wire, input, node and reconcile layers build on desktop (macOS, Linux, Windows), iOS and wasm32-unknown-unknown. The process tty (term::Tty, tty detection, Session::connect, print and ask) is available only on Unix and Windows; these APIs are target-gated rather than stubbed on wasm. term::Terminal and the session's custom-transport API remain available.

Tern's plugin worker uses tern_sdk::reconcile directly.

Quick start

use tern_sdk::{PrintOptions, ui, ui::View};

fn main() -> Result<(), tern_sdk::Error> {
	let view: View = View::new().main([ui::card()
		.head("Deploy")
		.status(ui::Status::Done)
		.child(ui::kv().item("service", "api-gateway").item("region", "eu-west-1"))]);
	// A native card that stays in the scrollback; plain text anywhere else.
	tern_sdk::print(view, PrintOptions::new())
}

An interactive program connects a session, opens a surface, renders views and reads input:

use tern_sdk::{Font, Input, Options, Session, SurfaceOptions, ui};

#[derive(Clone)]
enum Msg {
	Deploy,
}

fn main() -> Result<(), tern_sdk::Error> {
	let Some(mut session) = Session::<Msg>::connect(Options::new())? else {
		return Ok(()); // no TSP here: use your own output
	};
	// Apps use the UI face; machine strings (code, paths, numbers) stay mono.
	let sf = session.open(SurfaceOptions::flow().font(Font::Sans))?;
	let view = ui::card()
		.icon("rocket")
		.head("api-gateway")
		.meta("eu-west-1")
		.end(ui::button("Deploy").variant("primary").keys(["enter"]).on_click(Msg::Deploy))
		.child(ui::kv().item("commit", ui::span("4f2c9e1").style("mono")));
	session.render(sf, view)?;
	while let Some(input) = session.next(None)? {
		match input {
			Input::Msg(Msg::Deploy, _) => break,
			Input::Key(key) if key.is("escape") => break,
			_ => {},
		}
	}
	session.close()
}

Design rules

Build from kinds and props, not CSS: the kinds already carry Tern's look. The design guide, Designing views, has the rules, recipes and a review checklist. In short:

  • Open apps with SurfaceOptions::font(Font::Sans); mark machine strings with span tokens (mono, code, num, path, key) or Column::mono(true).
  • Actions are ui::button(text): .variant("primary") for the one main action of a view, "quiet" for secondary ones, "danger" for destructive ones (behind a confirm strip), .size("sm") inside rows, .keys([...]) to show the shortcut. Never html::button with hand-written styles.
  • Dashboards are pages, not piles of cards: a head row (row().justify("between") with the title in the title span token, muted meta, badges and Refresh at the end), a flat ui::stats([...]) strip (at most four tiles, never in a card), underline ui::tabs() with one view per tab (Tab/⇧Tab switch; render only the active view), and per view a full-width ui::table() beside a detail card (min/max width 44ch–56ch) for the selected row. Tables take .selectable(true), .selected(id), .empty(...) and typed cells (TextCell with a status dot or icon, BadgeSpec, ButtonCell, SparkCell, MeterCell); exactly one column grows (the main text one), numbers are .align(TextAlign::End).mono(true). A narrow table hides the lowest priority first and a missing one is 0: give the grow column the highest so it never hides, numbers next, sparks, meters and secondary columns the lowest. Rows take .menu(act) and .disabled(reason); row actions arrive as action events with the row in Action::item. With a detail card, the selected row's action is the card's primary button in its head's end and rows carry no buttons.
  • Panel heads: card().icon(…).head(…).meta(…).end(node) puts controls (buttons, tabs().variant("segmented"), badges) at the head's end; overlay().foot(node) adds a footer bar. A node given to head, end or foot becomes the child keyed by the slot (<card id>.end) and the prop is sent as its id.
  • Color carries meaning only: use tones, not colors. Design the four states: loading, empty, error, success.

The layers

Layer Module What
1. Wire tern_sdk::wire Constants (VERSION, APC_LIMIT, CREDITS, kind::*, feature::*), the messages (Query, Open, Frame with every Op, Blob, Palette, Sheet, Close), the encoder (encode, chunks, Encoder) and the decoded Reply and Event (typed variants plus Unknown; raw() is the JSON object as received)
2. Input tern_sdk::input, tern_sdk::keys input::Parser splits replies, events and DA1 answers out of the pty's input and passes everything else through; keys::Decoder turns key bytes into Keys (key.is("ctrl+c"))
3. Nodes tern_sdk::ui, tern_sdk::ui::html A builder per kind (ui::card(), ui::table(), ui::editor(), …) with typed props, the common props, Text as a string or spans![…], typed enumerations with an Other escape hatch, el builders per tag (html::form(), html::radio(name, value), …), and .prop(name, value) / ui::node(kind) for anything untyped
4. Reconcile tern_sdk::reconcile Doc (a view with derived ids) and doc.ops(&next, surface), Tern's plugin algorithm
5. Session tern_sdk::Session Detection and handshake, raw mode, bracketed paste and kitty keys, surfaces, credits, blobs, routing, recording, close
6. Helpers tern_sdk::{print, ask, plain} Static output, one question, the plain-text fallback

Views and regions

A View has three regions, main, dock and layer. A node given for a region is the region root itself; a list (an array, a Vec or nodes![…]) is wrapped in a col root. Tern draws a region's children into an element of its own and ignores the root's kind and props (but role), so a root should be a container. A bare node or list converts into a view as main's children, so session.render(sf, node) and session.render(sf, [a, b]) work.

Ids derive from the tree: a region root is main, dock or layer, a child is <parent>.<key> (its key prop, else its index). The editor in View::new().dock([ui::editor().key("ed")]) is dock.ed, which is what session.focus(sf, Some("dock.ed")) names.

Doc::from_json accepts the plugin wire form {main?, dock?, layer?}: region roots must be nodes with a string k, not lists. A null view is empty; null or absent p/c means no props/children. An empty object c also means no children, matching an empty Luau table. Other non-object props and non-list children are errors. Unknown regions are rejected even when null. Duplicate sibling identities report the parent path and key (for example, main: duplicate child key "a").

Handlers

Nodes are generic over the program's message type M (Node<M>, View<M>, default ()). Handlers map events to messages instead of closures with side effects:

Builder method Runs on
.on_click(msg), .on_dblclick(msg) the node's click / double-click action (set to "click" / "dblclick" unless the node names it)
.on_menu("rerun", msg) the context-menu entry it adds
.on_action("name", msg), .on_action_with("name", |a| …) action events with that act (name or name=value)
.on_toggle(|collapsed| …), .on_select(|item| …), .on_activate(|item| …), .on_change(|c| …), .on_edit(|e| …), .on_send(|text| …) those events
.on_focus(msg), .on_undo(msg) those events

An event is routed by its sf and id to the node with that id in the view last rendered (for a list's select/activate, the item's own handler first, then the list's). Session::next returns Input::Msg(msg, event) for a handled event, Input::Event(event) for any other (resize, theme, motion, errors, gone, …), Input::Key(key) for keys and Input::Clipboard(bytes) for an OSC 5522 clipboard packet (the bytes after 5522;; inside a bracketed paste it stays keys). Acks are consumed inside.

Session::waker() hands out a Waker (Send + Sync) that interrupts the wait from another thread: next then returns Ok(None), even with no timeout. The process tty polls a pipe (Unix) or an event (Windows) beside stdin, so waiting costs no CPU. Session::write_raw(bytes) writes OSC and mode sequences to the terminal as they are.

Flow control and closing

At most credits frames are unacknowledged per listening surface. A render while blocked only stores the view (and view ops queue); the frame that carries the difference goes out when an ack arrives, which happens while the program calls next or pump. listen(false) surfaces send at once. close_surface (and the session's close) first sends whatever still waits as one last frame, credits or not, then x. Dropping a Session closes it: open surfaces (kept unless SurfaceOptions::keep(false)), the input modes, a 50 ms drain of late input, the tty. On Unix, SIGTERM, SIGHUP, SIGINT and SIGQUIT also restore the tty before the process dies.

SurfaceOptions::default() listens and keeps its surface. Adopting an id closed by this session resumes its last sent main and frame sequence; dock and layer start empty. An unknown adopted id first deletes main. .closable(true) tells Tern closing the pane loses nothing: while that surface is open, the pane closes without the "a program is running" prompt. Frame writes commit the sent view, sequence and queued operations only on success; an ack-triggered write error reaches next/pump for handling. Close still attempts x, mode reset, and tty restoration after a write error.

Only one Tty can own process terminal state at a time. Setup arms cleanup before changing modes and rolls back partial failures. Unix signal handlers use async-signal-safe mode/tty restoration, then re-raise the signal; they cannot run Rust allocation, user handlers, or the normal session close from the signal context.

Input idle flushes wait for 30 ms without input, not a poll's shorter timeout. Paste content and partial paste end markers remain held across idle flushes; other incomplete key sequences are dropped. Zero-timeout polling dispatches already-ready input, including acknowledgements.

Tests and custom transports drive a session over anything implementing term::Terminal (Session::with_terminal); one that returns a Waker from Terminal::waker gets wakes too.

Helpers

  • print(view, PrintOptions::new().css(…).fallback(…)): a flow surface opened with listen:false, its sheet, one frame, x with keep. Without TSP (or when stdout is not a tty) the plain rendering, or the fallback text.
  • ask(view, AskOptions::new().font(Font::Sans).submit("submit")) returns Answer::Submitted(Submission { id, act, values }), Answer::Cancelled (Escape, Ctrl+C, Ctrl+D) or Answer::Unsupported. ask_with(view, opts, |msg| …) also runs the form's handlers while it waits, and renders the view the closure returns.
  • plain(view, cols) (or view.plain(cols) for a view with handlers): readable plain text without escapes. It prints a row's children on one line, so build fallback-friendly views (a col of cards) or pass fallback to print.

print and plain take message-free views, so bare builder chains need no type annotations. ask_with remains generic over handler messages; a submit handler runs before the submission is returned. For example:

tern_sdk::print(
	tern_sdk::ui::card().head("cargo test").status(tern_sdk::ui::Status::Done)
		.child(tern_sdk::ui::md("All **42** tests pass.")),
	tern_sdk::PrintOptions::new(),
)?;

Agent builders use ui::AgentStatus (Pending, Running, Done, Failed, Aborted, Idle, Parked, or Other), distinct from card/tool Status. Table meter cells serialize as { "meter": { "value": 0.5 } }; their plain fallback renders the same nested value. Inline key/value layouts stay on one line, and blocked checklist entries have a distinct [!] marker. Headed statuses use parentheses, for example cargo test (done), consistently across cards, sections, overlays, tools and agents.

Environment

Variable Effect
TERN_TSP=0 Never speak TSP
TERN_TSP_RECORD=<file> Append every message, both ways, as JSONL
TMUX, TMUX_PANE Inside tmux: TSP only when Tern draws the session (@tern-tsp), with the pane's allow-set-title off while connected
STY, ZELLIJ Inside screen or zellij: no TSP

Examples

The ask example reads the selected size only from the submit action's form values, without tracking radio changes. Outside Tern it asks on the command line.

cargo run -p tern-sdk --example table     # print: the current directory
cargo run -p tern-sdk --example ask       # ask: "Which size?"
cargo run -p tern-sdk --example progress  # a flow surface with a running card
cargo run -p tern-sdk --example chat      # an inline surface with a composer
cargo run -p tern-sdk --example dashboard # a sans screen page: head row, stats, tabs, table + detail

Tests

cargo test

The suite drives sessions over a scripted in-memory terminal.