Terminal
Run a real shell on a pseudo-terminal, stream its output into a terminal emulator, and paint the grid as real text on the canvas. The pty vocabulary is effects-as-data all the way down: your core names a model-owned pty key, a command, and a grid size; the host opens the pty, forks the child onto it, and streams coalesced output back as Msgs. Nothing in update ever touches a file descriptor or forks a process.
The headline capability: record a terminal session and it replays byte-identical offline — no shell runs, the journaled output batches and exit are the whole session.
The terminal state itself — cursor, styles, scrollback, wrapping, reflow, selection — is owned by libghostty-vt, Ghostty's extracted VT core, consumed as the ghostty-vt Zig module. The toolkit never parses escape sequences; it owns the transport (the pty) and the pixels (the canvas), and hands the bytes between.
Spawn, then stream
The pty key is ordinary model data — pick it like an effect key. Spawn the shell, feed each output batch into the emulator, and write typed input back to the pty. Exactly one exit event ends the session.
import { Cmd, asciiBytes } from "@native-sdk/core";
const shellKey = 1;
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "start":
return [model, Cmd.ptySpawn(
[asciiBytes("/bin/zsh"), asciiBytes("-i")],
{ cols: model.cols, rows: model.rows, event: "shell" },
)];
case "shell":
// A coalesced output batch, or the exactly-one exit.
if (msg.kind2 === "output") return { ...model, screen: feed(model.screen, msg.bytes) };
return { ...model, phase: msg.reason === "exited" ? "ended" : "failed" };
case "type":
return [model, Cmd.ptyWrite(shellKey, msg.bytes)];
case "viewport":
return [model, Cmd.ptyResize(shellKey, msg.cols, msg.rows)];
}
}const shell_key: u64 = 1;
fn initFx(model: *Model, fx: *Fx) void {
fx.ptySpawn(.{
.key = shell_key,
.argv = &.{ "/bin/zsh", "-i" },
.cols = model.cols,
.rows = model.rows,
.on_event = Fx.ptyMsg(.shell),
});
}
pub fn update(model: *Model, msg: Msg, fx: *Fx) void {
switch (msg) {
.shell => |event| switch (event.kind) {
// A coalesced output batch: feed the emulator, then flush
// any query answers (DSR, DA1) back to the pty.
.output => {
model.session.feed(event.bytes);
const answers = model.session.pendingResponses();
if (answers.len > 0) _ = fx.ptyWrite(shell_key, answers);
model.session.clearResponses();
},
// The exactly-one terminal: exited, signaled, cancelled,
// rejected, or spawn_failed.
.exit => model.phase = if (event.reason == .exited) .ended else .failed,
// Write-admission verdicts are journal/replay machinery;
// they never arrive as events.
.write => unreachable,
},
// Typed text goes straight to the child's stdin. `ptyWrite`
// returns whether the payload was accepted whole — a caller
// that must not lose bytes retains a refused payload and
// retries as the child reads (see the example's outbound ring).
.text => |event| _ = fx.ptyWrite(shell_key, event.text),
.viewport => |size| {
model.session.resize(size.cols, size.rows);
fx.ptyResize(shell_key, size.cols, size.rows);
},
}
}The full app lives in examples/terminal: the grid renderer, keyboard-first selection and scrollback, clipboard copy, and the record/replay tests.
The vocabulary
| Verb | What it does |
|---|---|
ptySpawn | Open a pty, fork argv onto it as its controlling terminal, and stream output back through on_event. Initial cols/rows and an optional term (default xterm-256color). |
ptyWrite | Bytes toward the child's stdin (keystrokes, pastes, query answers), all-or-nothing: returns whether the payload was accepted, so a caller that must not lose bytes retains a refusal and retries. Journaled as command payloads — verdicts included — so replay reproduces them for free. |
ptyResize | Push a new grid size; the child receives SIGWINCH and re-queries. |
ptyKill | Terminate the child's job (SIGKILL to its process group — the whole foreground job; a descendant that re-grouped itself escapes, POSIX offering no kill-whole-session primitive). The exit then reports cancelled. |
A pty is a spawn with a different transport, so it rides the same seams as Cmd.spawn: the same command permission in app.zon, the same environment policy (the child inherits the host environment the app bound, plus TERM), the same argv budgets, and the same one key space as spawns, fetches, and channels — a pty key is occupied from spawn until its exit Msg delivers.
Output is coalesced, never per-read
Output arriving between frames coalesces into one batch per drain, bounded at 64 KiB. cat largefile does not journal a record per read() — it journals per-frame batches, so a firehose stays a handful of Msgs per frame and the session journal stays small. The transport applies real back-pressure to the child (its staging ring is lossless: a full ring parks the reader and the kernel slows the writer, exactly a terminal's native flow control), so no byte is ever dropped — a fast producer is paced, not truncated.
One exit, honest classes
Exactly one .exit event ends every spawn — accepted or refused:
exitedcarries the child's exit code.signaledcarries the terminating signal number (the code is-1).cancelledfollowsptyKill— after the verb, the exit always says so, even if the child was already exiting.rejectedis a request refused before a child existed: an over-budget argv, a zero grid dimension, a duplicate live key, a full pty table, or a platform without pty support.spawn_failedis a pty or exec that could not start (a missing binary).
The exit also reports dropped_writes — ptyWrite payloads refused over the session's life (an over-bound single write, or a child that stopped reading and filled the outbound buffer). Zero means every keystroke reached the child.
One contract rides the whole family: the toolkit forks each pty child, is its sole reaper, and never touches SIGCHLD disposition — an embedding process must not either. A host that ignores SIGCHLD (the kernel auto-reaps), sets SA_NOCLDWAIT, or wait()s on children it did not spawn frees pids outside the toolkit's kill/reap fence; POSIX offers no portable way to signal a pid safely once a third party can reap it first.
Rendering the grid
The emulator owns cell state and damage; the app paints the viewport as real text through the canvas primitives. The example maps the palette honestly:
- ANSI-16 derives from the active theme tokens wherever the emulator still holds its default palette entry — so a themed app and its terminal read as one surface. The moment a program restyles a slot (OSC 4), the programmed color wins verbatim.
- 256-color (the cube and the grayscale ramp) and truecolor always pass through exactly.
- Wide CJK cells occupy two columns with a spacer tail, straight from the emulator's width semantics; the registered mono font supplies the glyphs.
Scrollback windows the viewport through the emulator's page list; keyboard-first selection (line and block) reads real cell text for clipboard copy. Pointer-drag selection is not wired in the example — the grid is keyboard-first by design.
Recorded sessions replay byte-identical, offline
The output bytes are the effect result, and the session journal treats them the way dynamic images treat decoded pixels. When a recorded session streams pty output, each batch's bytes are written — at effect-result time — into the content-addressed blob store beside the journal (blobs/<sha256[..16]>), and the journal record carries the hash and length. Identical batches (a repeated prompt, a redrawn screen) share one blob.
Replay never spawns a process. The ptySpawn is an ordinary replayed dispatch that parks the pty (the key registers as live; ptyWrite/ptyResize/ptyKill send nothing — the recorded output already carries their consequences), and the journaled output batches and exit feed verbatim: the blob store supplies the bytes, the emulator re-runs the same VT parsing, and the fingerprint checkpoints verify the replayed grid against the recording frame by frame. ptyWrite admission verdicts are journaled too — whether the outbound buffer had room is executor truth, like the bytes themselves — so a replayed write returns exactly what the live one did and an app that retains refused input takes the identical path. A journal whose blobs/ directory is missing or damaged refuses loudly rather than replaying a different session.
NATIVE_SDK_SESSION_RECORD=session/app.journal native run # records blobs/ beside the journal
NATIVE_SDK_SESSION_REPLAY=session/app.journal native run # replays offline, verifying fingerprintsAdding the pty record kind moved the journal format's layout fingerprint: recordings from older builds are refused at the preamble with the standard re-record teaching — the format's usual honest break.
Platform support
| Platform | Pty transport |
|---|---|
| macOS | Supported — openpty + a controlling terminal via libSystem. |
| Linux | Supported — openpty + a controlling terminal (glibc 2.34+ or musl). |
| Windows | Staged. ConPTY is not wired yet; ptySpawn answers with a rejected exit until it lands. |
| Null / headless | A scriptable fake pty drives the whole vocabulary in tests — spawn requests, output and exit feeds, write capture — with no process, so CI is fully deterministic. |