Skip to content

Repository files navigation

tern

License: MIT Rust 1.94 CI

Build rich terminal UIs for code agents with React or SolidJS, rendered by a Rust-native engine.

tern is a terminal UI engine for AI coding agents: streaming transcripts, diffs, prompts, tables, progress, and multi-panel workspaces. You write declarative UI in React or SolidJS; a custom renderer drives a Rust scene tree, layout engine, compositor, and terminal frontend. The engine core is always Rust; the JS side only describes the scene.

Features

  • Rust-native engine — scene tree, layout (taffy), compositor, and diff-flush terminal frontend all in Rust; the JS side stays thin.
  • React & SolidJS renderers — first-class custom renderers (@tern/react, @tern/solid) over one scene API. Same props → same scene, full feature parity.
  • Streaming-firstStreamingText consumes an AsyncIterable<Span> with tail-follow auto-scroll, scroll-up detach, and a scroll-to-bottom affordance.
  • Paste end to end — bracketed paste surfaced as events, routed through the FocusManager, and auto-pasted into the focused Input / Textarea.
  • Focus & interaction model — Tab/Shift+Tab traversal, click-to-focus, wheel scroll, panel drag-resize, and modal focus isolation.
  • Code-agent widget set — 16 elements: StreamingText, MarkdownView, DiffView, Input, Textarea, Select, Table, Tabs, Panels, Progress, Spinner, StatusBar, ScrollView, Modal, Box, Text.
  • Diffs & tables — unified or side-by-side diffs with intra-line highlighting; sticky-header tables with windowed rows (a 10k-row table materializes only the visible window).
  • Syntax highlighting — tree-sitter (Rust, TS/JS, JSON, shell) inside Markdown code fences.
  • Theme system — One Dark default; role / component hints resolve to fg / bg / border_style at element-creation time.
  • Golden testingsnapshotFrame / framesEqual paint to an off-screen buffer with no terminal I/O, for buffer-exact assertions.
  • Terminal introspectionrenderer.size reports the last painted viewport (or the current terminal size before the first paint), and renderer.setClipboard(text) copies to the system clipboard via OSC 52.

How it works

 JS renderer (@tern/react | @tern/solid)
      │  scene updates
      ▼
 @tern/core (TypeScript bindings)
      │  napi (tern-node)
      ▼
 tern-core scene tree → tern-layout (taffy) → compositor → tern-terminal
      │  push events (keys / resize / focus / mouse / paste)
      └──────────────────────────────────────────────► JS renderer

Using tern as a library

tern ships as four npm packages:

Package What it is
tern-node The native addon (Rust core, napi binding). Main package plus 5 per-platform sub-packages via optionalDependencies.
@tern/core TypeScript bindings over the addon: renderer, scene nodes, element factories, focus, theme, frame snapshots. Depends on tern-node.
@tern/react react-reconciler custom renderer — host components, hooks, ThemeProvider. Peers: react ^19.2.0, react-reconciler ^0.33.0.
@tern/solid SolidJS universal custom renderer — element factories, subscriptions, setTheme. Depends on solid-js.

Installation

# React renderer
npm install @tern/core @tern/react react react-reconciler

# Solid renderer
npm install @tern/core @tern/solid solid-js

All packages are ESM-only ("type": "module", dist/index.js + dist/index.d.ts) and require Node.js >= 20.

Not on npm yet. Everything ships at version 0.1.0; publishing is automated and only happens when a v* tag is pushed (see Release). Until then, consume the packages from the monorepo:

git clone https://github.com/EricMoin/tern
cd tern
npm install                    # repo root — installs and links the workspaces
cd src/bindings/tern-node
npm install                    # addon deps (use npm install, not npm ci — see below)
npm run build                  # napi build --platform --release && node fix-dts.mjs
cd ../..
npm run build -w @tern/core    # tsc build + fix-dts (core first)
npm run build -w @tern/react   # depends on @tern/core
npm run build -w @tern/solid   # depends on @tern/core

The root workspace install symlinks @tern/core, @tern/react, @tern/solid and tern-node into node_modules, so the packages are importable by name from anywhere in the checkout. The bundled demos run Deno-first straight off the TypeScript sources (via the deno.json import map); a Node consumer imports the built dist, hence the build steps above. Alternatively, npm pack -w @tern/core (etc.) produces installable tarballs — note that @tern/core depends on tern-node, which is itself unpublished, so a standalone tarball install outside the workspace also needs tern-node (and its per-platform packages) resolvable; the workspace is the practical pre-release route.

Quick start — @tern/react

// app.tsx — run with: deno run --allow-all app.tsx
// (requires the native addon built and the @tern/* dist built — see Installation)
import { createElement } from "react";
import { createRenderer } from "@tern/core";
import { Box, Text, render, useApp, useInput } from "@tern/react";

function App() {
  const { exit } = useApp();
  useInput((event) => {
    if (event.name === "char" && event.char === "q") exit();
  });
  return createElement(
    Box,
    { border_style: "rounded", padding: 1, flex_direction: "column" },
    createElement(Text, { text: "Hello tern" }),
    createElement(Text, { text: "Press q to quit" }),
  );
}

const renderer = createRenderer({ exitOnCtrlC: true });
render(createElement(App), renderer);

// React schedules passive effects (useInput's key subscription) on the
// scheduler, so give them a beat before the event loop starts.
await new Promise((resolve) => setTimeout(resolve, 100));

// Push-based events: startEventStream() delivers every key / resize /
// focus / mouse / paste event to renderer.events. exit() (on 'q') and
// Ctrl+C (exitOnCtrlC) destroy the renderer, closing the stream.
renderer.startEventStream();
for await (const event of renderer.events) {
  if (renderer.destroyed) break;
}

The scene is a plain React tree of host components. Bare string children are rejected — text lives in an explicit <Text text="..." /> element.

Quick start — @tern/solid

// app.ts — run with: deno run --allow-all app.ts
// (requires the native addon built and the @tern/* dist built — see Installation)
import { createRenderer } from "@tern/core";
import { Box, Text, render, subscribeInput } from "@tern/solid";

const renderer = createRenderer({ exitOnCtrlC: true });

const box = Box({ border_style: "rounded", padding: 1, flex_direction: "column" });
box.addChild(Text({ text: "Hello tern" }));
box.addChild(Text({ text: "Press q to quit" }));

// Mount the scene through the solid universal renderer; the returned
// disposer releases the solid root.
const dispose = render(() => box, renderer.root);
renderer.render();

// The Solid-flavored input hook: routes each key through the FocusManager
// first, then the tree handler. Solid has no context, so the renderer is an
// explicit argument.
let quit = false;
subscribeInput(renderer, (event) => {
  if (event.name === "char" && event.char === "q") quit = true;
});

renderer.startEventStream();
for await (const event of renderer.events) {
  if (quit || renderer.destroyed) break;
}
dispose?.();
renderer.destroy();

What runs where

  • Node.js >= 20 — ESM only; all packages are "type": "module" and export an import condition. TypeScript/JSX sources need your own transpilation step (the demos run Deno-first, straight from source).
  • Deno 2.x — supported; Node-API addons load with --allow-ffi, and the demos run with deno run --allow-all. deno.json import-maps @tern/core to the source for in-repo runs.
  • Terminal/PTY required — constructing a renderer enters raw mode and the alternate screen immediately (crossterm). The scene renders only into a real terminal; non-interactive shells cannot paint it. For headless assertions, use renderer.snapshotFrame(width, height) and framesEqual instead.

Supported platforms

The native addon follows the napi-rs distribution model: the tern-node root package declares per-platform packages in optionalDependencies, and the generated loader picks the one matching the running system. The release matrix builds these five targets:

Platform Rust target triple npm package
Linux x64 (glibc) x86_64-unknown-linux-gnu tern-node-linux-x64-gnu
Linux arm64 (glibc) aarch64-unknown-linux-gnu tern-node-linux-arm64-gnu
macOS x64 (Intel) x86_64-apple-darwin tern-node-darwin-x64
macOS arm64 (Apple Silicon) aarch64-apple-darwin tern-node-darwin-arm64
Windows x64 (MSVC) x86_64-pc-windows-msvc tern-node-win32-x64-msvc

When the platform package is missing, the loader falls back to a locally built tern-node.<platform>-<arch>.node. Systems outside the matrix (musl Linux, Windows arm64, ...) are not covered — build the addon locally.

Troubleshooting

  • ERR_DLOPEN_FAILED / "Cannot find native binding" — the addon could not be loaded for this platform: the per-platform package is missing (unsupported target), node_modules is stale, or the addon was never built. Build it locally:

    cd src/bindings/tern-node && npm install && npm run build

    If node_modules is stale, follow the loader's own hint: remove package-lock.json and node_modules, then npm install (see npm/cli#4828).

  • Raw-mode / PTY requirementcreateRenderer() enters raw mode and the alternate screen immediately; a scene never renders into piped output or a CI log. Run inside a real terminal. To render inline without the alternate screen, construct with { useAltScreen: false }. For buffer assertions without a terminal, use snapshotFrame / framesEqual.

  • npm ci fails in src/bindings/tern-node — the tern-node-<platform> optional dependencies are not on the registry yet, so the lockfile cannot pin them and npm ci's sync check fails. Use npm install there (see CONTRIBUTING.md).

Widgets

Element What it does
Box / Text Container (border, padding, flex) / text leaf
StreamingText Incrementally fed styled-span stream, tail-follow auto-scroll
MarkdownView Markdown blocks + inline styles, tree-sitter-highlighted code fences
DiffView Unified or side-by-side diff rows, intra-line highlight
Input Single-line entry with caret, placeholder, focus, auto-paste
Textarea Multi-line editor with soft wrap, scroll-to-caret, line splits
Select Filterable option list, multi-select, floating overlay
Table Sticky header, windowed rows, per-column alignment
Tabs Tab bar + content region, ctrl+tab / ctrl+w routing
Panels Collapsible header/body stack with drag-resize gutter
Progress Framed gauge with label + percentage readout (setProgress)
Spinner Determinate bar or indeterminate glyph, focus-aware ticking
StatusBar Left/center/right strip; reserves the bottom viewport row
ScrollView Clip/scroll region with optional scrollbar
Modal Dimmed overlay with centered content and focus isolation

Interactive elements register with a FocusManager: focusId on the @tern/react host components, useFocus(id, node, onKey, onPaste?) on the core, subscribe* helpers on @tern/solid. See docs/guide.md for the full widget API reference.

Events & interaction

Terminal events are push-based: renderer.startEventStream() delivers every key / resize / focus / mouse / paste event to the JS thread (no polling in the app hot path), as a tagged TernEventJs union on the renderer.events async iterable and through the onKey / onResize / onFocus / onMouse / onPaste handlers. Keys and pastes route through the FocusManager first — a focused element consumes them, the tree-level handler sees the rest.

renderer.size reports the terminal size as { width, height } — the viewport the last render/snapshot painted at (the current terminal size before any paint) — and renderer.setClipboard(text) copies to the system clipboard via OSC 52 (ESC ] 52 ; c ; <base64> BEL; the terminal emulator must support it).

Examples

Run the bundled demos from the repo root (Deno-first):

deno run --allow-all packages/examples/react-demo.ts
deno run --allow-all packages/examples/solid-demo.ts
deno run --allow-all packages/examples/kitchen-sink-react.ts
deno run --allow-all packages/examples/kitchen-sink-solid.ts

Or through the @tern/examples workspace scripts:

npm run demo:react -w @tern/examples
npm run demo:solid -w @tern/examples
npm run demo:kitchen-react -w @tern/examples
npm run demo:kitchen-solid -w @tern/examples

Each demo renders a scene, asserts it against the scene tree, and quits on q.

Packages

Package What it is
@tern/core TypeScript bindings over the napi addon: renderer, scene nodes, element factories, focus, theme, frame snapshots
@tern/react react-reconciler custom renderer — host components, hooks, ThemeProvider
@tern/solid SolidJS universal custom renderer — factories, subscriptions, setTheme
@tern/examples Runnable demos (react-demo.ts, solid-demo.ts, kitchen-sink scenes) with a PTY smoke harness (run-smoke.sh)

Documentation

Development

Prerequisites for building from source: stable Rust 1.94 (pinned in rust-toolchain.toml), Deno 2.x (the primary runtime for check/test and demos), and Node.js >= 20 (for the napi build and the JS toolchain).

Build the native addon and the JS packages:

npm install                                  # repo root — JS deps (npm workspaces)
cd src/bindings/tern-node
npm install                                  # addon deps (npm install, not npm ci — see CONTRIBUTING.md)
npm run build                                # napi build --platform --release && node fix-dts.mjs
cd ../..
npm run build -w @tern/core                   # tsc build + fix-dts (core first)
npm run build -w @tern/react                  # depends on @tern/core
npm run build -w @tern/solid                  # depends on @tern/core

npm run build:debug in src/bindings/tern-node is the fast local dev build (debug profile); the default build is the release profile.

Check / test / smoke:

npm run check                                       # deno check across packages
npm test                                            # deno test across packages
cargo build --workspace && cargo test --workspace   # Rust gates — all green required
bash packages/examples/run-smoke.sh                 # PTY smoke: 4 demos, quit on 'q', exit 0

Release

Publishing is fully automated — nothing is published on ordinary pushes or by hand. Pushing a v* tag (e.g. v0.1.0) — or running .github/workflows/release.yml manually — triggers the release:

  1. Build — the napi-rs matrix compiles the tern-node addon for all five targets in napi.targets (natively on each host runner, or via napi-cross for Linux arm64), load-checks the native rows, and uploads each binary as a workflow artifact.
  2. Release — collects the binaries into the per-platform packages (napi create-npm-dirs + napi artifacts), publishes tern-node (its prepublishOnly publishes the tern-node-<platform> packages first), then builds and publishes @tern/core@tern/react@tern/solid in dependency order.

The workflow requires the NPM_TOKEN secret — an npm automation token with publish rights on the @tern/* and tern-node* names — and declares id-token: write for npm provenance.

tern/
├── src/                    # Rust workspace
│   ├── core/               #   tern-core (scene tree) · tern-layout · tern-terminal · tern-components · tern-highlight
│   └── bindings/tern-node/ #   napi binding (Node.js/Deno → Rust)
├── packages/               # JS packages (core | react | solid | examples)
├── examples/rust/tern-demo # Rust example binary
├── docs/                   # architecture & design documents
└── tools/                  # developer tooling

Built with

The Rust core leans on crossterm (terminal I/O and events), taffy (layout), and tree-sitter; the JS renderers are built on react-reconciler and the SolidJS universal renderer; the bridge is napi-rs.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages