tern-sdk (Python)
The Python SDK for the Tern Surface Protocol: a program running in a terminal pane describes its UI as a tree of nodes and Tern draws it natively. Outside Tern the same program falls back to plain text.
Python 3.11 or newer, standard library only.
Install
uv add tern-sdk
The distribution is tern-sdk; the import is tern_sdk.
Quick start
import tern_sdk
from tern_sdk import ui
# Static output that stays in the scrollback (plain text outside Tern).
tern_sdk.show(ui.card(ui.md("All **42** tests pass."), head="cargo test", status="done"))
# One question, its answer returned.
form = ui.html.form(
ui.html.p("Which size?"),
ui.html.label(ui.html.input(type="radio", name="size", value="s"), "Small"),
ui.html.label(ui.html.input(type="radio", name="size", value="l"), "Large"),
ui.html.button("Create", actions={"click": "submit"}),
)
try:
answer = tern_sdk.ask(form)
print(answer.values["size"] if answer else "cancelled")
except tern_sdk.Unsupported:
size = input("Which size? ")
A live surface:
session = tern_sdk.connect(app="build")
if session is None:
print("building...") # no TSP: plain output
else:
with session, session.open(mode="flow") as surface:
surface.render(ui.card(ui.spinner("Compiling"), head="Build", status="running", key="b"))
for item in session.input(timeout=0.1):
...
connect() returns None when TSP isn't available (no exception for that
case); a Session and a Surface are context managers that close on exit.
Layers
Each layer is usable without the ones above it.
| Module | What it provides |
|---|---|
tern_sdk.wire |
Constants (VERSION, APC_LIMIT, CREDITS, KINDS, TEXT_KINDS, features), program -> terminal messages (hello, blobs_query, open_surface, frame, blob, palette, stylesheet, close_surface, Ops.* for the twelve frame ops), the encoder (encode, chunks, ChunkIds) and typed replies and events (decode_reply, decode_event; every one keeps its raw object) |
tern_sdk.input |
InputParser: feed pty input, get key bytes, Reply, Event and DA1 items; flush() releases undecided prefixes |
tern_sdk.keys |
KeyDecoder: key bytes to Key(name, text, ctrl, alt, shift, meta), legacy xterm, kitty CSI u and bracketed paste |
tern_sdk.ui |
Builders for every kind but block, span, el builders per tag under ui.html, and node(kind, props, children) |
tern_sdk.reconcile |
View.build(view) and View.ops(next, surface): Tern's own diff with derived ids |
tern_sdk.session |
connect, start, Session, Surface, Capabilities |
tern_sdk.helpers |
show, ask, plain (also at the top level) |
Nodes
Children are positional and props keyword-only. Text is a string or styled
spans (ui.span("3 failed", "error")). Snake-case keywords map to the
wire's camelCase names (target_kind is targetKind, max_lines is
maxLines); el's class is class_. Structured props are TypedDicts
whose keys are the wire's names. Enumerations are Literals; props={...}
sets any prop untyped and ui.node(kind, props, children) builds any kind.
ui.col(
ui.card(ui.kv([("service", "api"), ("region", "eu-west-1")]), head="Deploy", status="running", key="d"),
ui.row(ui.badge("v2.3.1", tone="accent"), ui.icon("check", tone="success"), ui.kbd("cmd", "k")),
ui.html.table(ui.html.tr(ui.html.td("Cargo.toml"), ui.html.td("2.1K", class_="size"))),
)
The common props (key, role, tone, hidden, mark, actions,
title, aria, href, grow, shrink, basis, min, max) are
keywords on every builder. In ui.html.*, a leading string child is the
element's text; later string children become span elements.
Handlers
Any builder takes handlers; each receives the typed event:
| Keyword | Runs on |
|---|---|
on_click, on_dblclick |
the click / double-click action (sets actions.click to "click" unless the node names its own) |
on_menu={"rerun": fn} |
context-menu picks (adds the names to actions.menu) |
on_action={"name": fn} |
action events with that act |
on_toggle, on_select, on_activate, on_change, on_focus, on_edit, on_undo, on_send |
those events |
Events are routed by sf and id to the node with that id in the view last
rendered (for a list's select/activate, the list). Handlers run inside
session.poll() / session.input(); handled events are consumed and every
other event reaches the loop. Named gesture actions such as sort=name
match the event's act="sort" and value="name". ask returns its first
submit action even when a node handler handles it; that handler runs first.
Kind-specific props replace colliding common props: tool/picker titles
accept spans, prefs owns its title, list owns max, and picker owns
its action list. AgentStatus is separate from card/tool Status.
Table meter cells use {"meter": {"value": 0.5}} (or parts inside
meter), typed as MeterCell containing MeterCellProps; plain tables
render those meters too.
Session
connect(app=None, *, version=None, features=(), timeout=1.0, paste=True, kitty=True)checks the environment, switches the tty to raw mode, sendshello+ DA1 and returns aSession, orNone.start(input, output, ...)runs the handshake on given file descriptors or binary streams without the environment checks (tests drive it overos.pipe()).session.open(id=None, *, mode="inline", title=None, role=None, listen=True, adopt=None, keep=True)opens a surface (s1,s2, ...).surface.render(view)orsurface.render(main=..., dock=..., layer=...)sends only the difference. A node given for a region is that region's root, whose own kind and props Tern doesn't draw (only its children); a list region is wrapped in acol; a bare node or list passed as the whole view becomesmain's children. Alsostylesheet(name, css),palette(dark=, light=, name=),focus,reveal,scroll,settle,suspend,resume, rawsend(ops)andclose(keep=None).- Flow control: at most
creditsframes in flight; a render while blocked stores the view, and the nextacksends one frame with the difference from the last view sent, then queued ops.closesends what is still pending as a last frame even without credit, so a kept surface shows its final state. A failed frame write raises without advancing the sent view, sequence, or queued ops, including a flush triggered by an ack. Re-rendering a mutable node detects changes to nested props. adopt=Truereuses a closed surface's last-sentmainand frame sequence in this session (not its formerdock/layer). An unknown adopted id starts empty and deletesmainbefore its first frame's additions.- Input:
for item in session.input(timeout=None):yieldsKeys and unhandledEvents and ends at end of input, on close or aftertimeoutseconds idle;session.poll(timeout)returns one item orNone. Evenpoll(0)reads available input, including acks. Iterating buffered keys also dispatches available input, so acks promptly flush pending frames rather than waiting for the key queue to empty. Held prefixes flush only after 30 ms without input, not when a shorter poll expires. Key decoder flush drops incomplete sequences except a lone Escape; neither parser flushes a paste in progress. session.blob(data, mime)sends a blob once and returns its id;session.blobs(ids)asks which Tern holds.session.close()closes open surfaces, undoes bracketed paste and the kitty flag, drains input for 50 ms and restores the tty. It also runs from thewithblock, at process exit (atexit) and on SIGINT / SIGTERM / SIGHUP when started on Python's main thread. Cleanup is armed before raw mode and the handshake; only one session may own tty state at a time. Close attemptsxeven after a failed final frame, resets modes and restores the tty despite write errors, then raises the first error. The drain records late replies without invoking handlers. Windows console waits discard non-character records rather than blocking on a character read.
Helpers
show(view, *, css=None, fallback=None, session=None): static output in aflowsurface opened withlisten: false. It is namedshow, notprint, so it never shadows the builtin. Without TSP it printsplain(view)orfallback.ask(view, *, css=None, submit="submit", session=None) -> Answer | None: returns the firstactionwhoseactissubmitasAnswer(id, act, values), orNoneon Escape, Ctrl+C or Ctrl+D. Without TSP it raisestern_sdk.Unsupported.plain(view, cols=80) -> str: readable plain text without escapes.
Environment
| Variable | Effect |
|---|---|
TERN_TSP=0 |
Never speak TSP |
TERN_TSP_RECORD=<file> |
Append every TSP message as JSONL (surface-play replays it) |
TMUX, STY, ZELLIJ |
Inside a multiplexer: 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.
uv run examples/table.py # the current directory as a table
uv run examples/ask.py # "Which size?"
uv run examples/progress.py # a running card, streamed log, progress bar
uv run examples/chat.py # transcript + editor; Enter sends, Escape quits
Development
uv run pytest # wire, UI, session, helpers
uvx mypy --strict src examples
uvx ruff check src tests examples
uvx ruff format --check src tests examples
Metadata
Release files for tern-sdk 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tern_sdk-0.1.0.tar.gz | 64.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tern_sdk-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 118.2 kB
Release files / tern_sdk-0.1.0.tar.gz
| Download URL | tern_sdk-0.1.0.tar.gz |
|---|---|
| Size | 64.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3e2c719887b7f73e00f963b649dd41b7909a63bd3e5bf88d77dd0c6e8aceb420
|
|
BLAKE2b-256 checksum How to use checksums |
3db0d3ad680ef7ad1c1be9ceb679db24b5b50f68e1f106ca9b93d6d235814048
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / tern_sdk-0.1.0-py3-none-any.whl
| Download URL | tern_sdk-0.1.0-py3-none-any.whl |
|---|---|
| Size | 53.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
13fa459caff98e0215c454933ab395fb8fbd92967c1f0c534da0bb70849a4837
|
|
BLAKE2b-256 checksum How to use checksums |
5c2d0427f2f6fa7254975e2bd68cc2e8de2d6ed868f423e2cce67c43e6ccce62
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|