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

```sh
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

```rust
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:

```rust
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](https://docs.stencil.so/tern/guides/design.html), 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 `grow`s (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 `tone`s, 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 `Key`s (`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:

```rust
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.

```sh
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

```sh
cargo test
```

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