TUI
yeet:tui is a declarative terminal UI framework built into the runtime. Import widgets and helpers directly — no install required.
import { Box, Text, signal, rgb } from 'yeet:tui';
The data flow is one-way: widgets are Signals of layout nodes. Each time a Signal changes, only the affected part of the tree re-renders. The rest stays live and unchanged.
signals → widget tree → layout → paint → flush → terminal
mount
mount(view: (size: Signal<{ rows: number; cols: number }>) => Signal, term?: Terminal): () => void
Mounts a view tree and drives the render loop. Call once at the top of your script.
view(size)— Function that builds the root widget. Receives a livesizeSignal — read it inside a widget (not at the top level) to stay reactive across resizes.term— Optional terminal object; defaults to the globaltty.- Returns a teardown function that stops rendering and restores the terminal.
The isolate keeps running as long as the mount is active. Call the teardown (or yeet.exit()) when done.
import { Box, Text, mount, signal } from 'yeet:tui';
const counter = signal(0);
setInterval(() => counter.update(n => n + 1), 1000);
mount(size => (
<Box>
<Text>{() => `Count: ${counter.get()}`}</Text>
</Box>
));
mount frames the root at the live terminal size, so responsive Sizes — fr, pct, vw/vh — reflow on their own; most views never read size. When you do need the dimensions in code — switching layout at a width breakpoint, say — read the size Signal mount passes to view, inside a widget or Effect so the read stays reactive.
Widgets
All widgets take (options, ...children). The first argument is always the options object ({} when you have none), followed by children.
Thunks work the same everywhere — content, children, and option values alike. A thunk f is shorthand for computed(f): it is read lazily, its signal reads are tracked, and the widget re-mints when they change. So a function passed as an option stays live without wrapping it in a Signal yourself.
const focused = signal(false), weight = signal(1);
Box({ border: () => focused.get() ? "double" : "single",
width: () => Size.fr(weight.get()) }, content)
The exceptions are the pointer handlers, which the engine calls itself rather than reading.
Pointer events
Any container option named after a mouse event attaches a handler to
its node. An event bubbles up the component tree with a DOM-like
interface. A child can event.stopPropagation() to preempt parent
callbacks. Unlike the DOM, there is no capture phase.
Box supports these event listener props: onMouseDown, onMouseUp,
onMouseMove, onWheel, onClick, onDblClick, onMouseEnter, onMouseLeave.
Event properties include: clientX/Y, button/buttons, deltaX/Y, modifier flags.
<Box onClick={() => count.update(n => n + 1)}>+1</Box>
To track pointer state declaratively, setHover and setPressed notify
their argument, a setter function or a State Signal (any object with
.set()). setHover follows enter/leave; setPressed follows
press/release, and leaving while pressed releases.
const hot = signal(false);
<Box setHover={hot} bg={() => hot.get() && idx(8)}>...</Box>
<Box setHover={v => log(`hover=${v}`)}>...</Box>
Text
A run of inline styled text — like a DOM text node, not a layout node. Its bare attributes are a face; its children are strings, numbers, nested <Text>, or thunks/signals of those. A container (Box / Layer) promotes it to a leaf, where size, wrap, and trim live.
Text(face: Face, content: string | number | Run | Array | (() => …)): Signal<Run>
Face attributes (all optional, all bare props):
| Attribute | Type | Description |
|---|---|---|
fg | Color | Foreground colour — rgb/rgba/idx, or a hex string (#rgb/#rgba/#rrggbb/#rrggbbaa) |
bg | Color | Background colour, same forms as fg |
bold dim italic underline blink reverse hidden strike | boolean | SGR attributes |
<Text bold fg="#f00">error</Text>
Children concat into one run, and the face merges under each child span — so a nested <Text> wins on conflict:
<Text bold fg="#888">loading <Text fg="#0f0">{progress}%</Text></Text>
A <Text> is a value, not a placement — it carries no size, wrap, or position. Put it in a Box to lay it out; the Box owns width, break, and overflow for the text it holds. A bare string child of a container is shorthand for a default-faced <Text>.
Box
A flow container — stacks children along a direction, like flex-direction in CSS. A Box (like every container) owns the layout: placement, size, and how the text it holds wraps and trims. Unsized, a Box hugs its content along the flow direction and fills across it — give a pane an explicit 1fr on its flow axis to absorb leftover space.
Box(opts: BoxOpts, ...children): Signal
Options:
| Option | Type | Default | Description |
|---|---|---|---|
width / height | Size | auto | Extent on each axis |
left / top / right / bottom | Size | — | Inset from that edge |
z | number | 0 | Paint order among siblings |
order | number | 0 | Flow order (CSS flex order); ties keep document order |
bg | Color | Shader | — | Background fill |
snap | "round" | "blend" | "round" | How fractional rects meet the cell grid |
direction | "column" | "row" | "column-reverse" | "row-reverse" | "column" | Flow axis |
side | Size | — | Shorthand: sets both width and height. An explicit axis wins. |
border | boolean | BorderLine | BorderSpec | — | Border frame one cell inside the box. See borders. |
padding | number | number[] | — | Cells between the frame and content. CSS shorthand: one number for all sides, [vertical, horizontal], [top, horizontal, bottom], or [top, right, bottom, left]. |
break | "word" | "anywhere" | "all" | "none" | "word" | Soft-wrap strategy for the text it holds |
overflow | "visible" | "hidden" | "ellipsis" | "visible" | Clip children to the rect; "ellipsis" marks cut text with … |
Children may be Signals, thunks (functions), strings, numbers, <Text> runs, or arrays (flattened). A string or run becomes a text leaf wearing the box's break.
Face inheritance: fg and the boolean attributes (bold, italic, …) set on any container cascade to the text beneath it, CSS-fashion. The nearest setting wins, a span's own face wins over any inherited slot, and an explicit false switches an inherited attribute back off. bg does not cascade — it tints the container's own rect.
<Box fg="#888" bold>
dim heading
<Text fg="#0f0">status</Text> {/* green, still bold */}
</Box>
Borders
border accepts:
true— single-line border (same as"single")"single""round""double""heavy"— named lines- Six custom glyphs as a string in
"┌┐└┘─│"order { line?, fg?, bg? }— named line plus color overrides
<Box border="round" padding={1}><Text>Hello</Text></Box>
<Box border={{ line: "single", fg: idx(8) }}>{content}</Box>
Layer
An overlap container. Children stack at the origin (z-stack); use insets for absolute positioning within the layer.
Layer(opts: LayerOpts, ...children): Signal
Takes the same placement and paint options as Box (left, top, right, bottom, z, bg, snap). direction, border, and padding are not meaningful on a Layer.
<Layer>
{background}
<Layer left="2" top="1">{overlay}</Layer>
</Layer>
Effect
A zero-sized, invisible lifecycle leaf that binds a side effect to its place in the tree. The effect runs while the leaf is mounted — and again on every Signal edge it reads — and its returned teardown (a function, or an object with unsubscribe) runs before each re-run and once more when the leaf leaves the tree or mount() tears down.
Effect(...effects): Signal
// effect: () => (Teardown | { unsubscribe(): void } | void)
Pass effects as arguments, as children (<Effect>{fn}</Effect>), or via an effect prop (<Effect effect={fns} />) — all land the same way.
import { Box, Text, Effect, signal } from 'yeet:tui';
const n = signal(0);
const Probe = () => (
<Box>
<Text>{() => `n=${n.get()}`}</Text>
<Effect>{() => {
const id = setInterval(() => n.update(x => x + 1), 1000);
return () => clearInterval(id); // runs on unmount / mount teardown
}}</Effect>
</Box>
);
For data sources that need teardown — a subscription, a watcher — prefer wrapping them with from: the producer arms when the value is first read (rendered) and tears down when it is no longer watched, so you don't manage lifecycle by hand.
CellBuffer
A raster leaf for imperative drawing. It is both a widget (a Signal you can place in the tree) and a drawing surface (tensor-backed cell planes you write to).
CellBuffer({ rows, cols, ...opts }): Signal & Surface
Draw with blit/tint/clear at any time. Calling these automatically marks the buffer dirty, scheduling a repaint on the next microtask.
Surface properties:
| Property | Type | Description |
|---|---|---|
rows | number | Buffer height |
cols | number | Buffer width |
chars | Tensor | Glyph code points (Uint32Array at chars.data) |
fg / bg | Tensor | Color planes (Int32Array at .data) |
attrs | Tensor | Attribute bitfield (Uint8Array at .data) |
Each plane is a Tensor view over a typed array. Use blit/tint/clear for most drawing; to write cells directly, reach the underlying typed array through .data (or the Tensor's at/put accessors).
Surface methods:
| Method | Description |
|---|---|
blit(x, y, line, window?) | Write styled text at (x, y) |
tint(x, y, w, h, color, window?) | Blend a color over a rect |
clear(window?) | Blank all planes |
touch() | Mark dirty without drawing (schedules repaint) |
const buf = CellBuffer({ rows: 10, cols: 40 });
buf.blit(0, 0, face({ bold: true })("hello"));
export default () => (
<Box>
{buf}
</Box>
);
Button
A padded, clickable label with idle / hover / pressed visual tiers and a selected overlay for a toggle that is on — selected is controlled; the caller owns the state. Children are arbitrary content, styled through face inheritance.
Button(opts: ButtonOpts, ...children): Signal
The default styling is attributes only — hover underlines, pressed and selected embolden — so it reads on light and dark terminals alike.
| Option | Type | Description |
|---|---|---|
selected | boolean | Signal | thunk | Paint the pressed tier while on |
setHover / setPressed | setter | Mirrors of the pointer state |
Everything else — onClick, sizing, bg, fg — forwards to the underlying Box and overrides the defaults. For conditional styling, mirror the pointer state with setHover/setPressed and condition on it, the way the defaults do:
const hot = signal(false);
<Button setHover={hot} fg={() => hot.get() ? "#b58900" : "#93a1a1"}
selected={() => tab.get() === "cpu"} onClick={() => tab.set("cpu")}>
CPU
</Button>
Signals
yeet:tui exports the signal primitives used by the widget system. Use them to drive reactive state in your scripts.
signal
signal<T>(initialValue: T, options?: SignalOptions): Signal.State<T>
Creates a mutable state cell. Write with .set() or .update(); any computed value or widget that reads it will re-run when it changes.
const count = signal(0);
count.set(1);
count.update(n => n + 1);
console.log(count.get()); // 2
SignalOptions:
| Option | Type | Description |
|---|---|---|
equals | (a, b) => boolean | Custom equality check. Default: Object.is. A .set() that compares equal is a no-op. |
[Signal.subtle.watched] | () => void | Called when the first watcher (Watcher or live Computed) attaches. |
[Signal.subtle.unwatched] | () => void | Called when the last watcher detaches. |
computed
computed<T>(fn: () => T): Signal.Computed<T>
Creates a derived value. fn runs immediately; any signal.get() or computed.get() call inside it registers a dependency. Re-evaluates lazily when a dependency changes.
const doubled = computed(() => count.get() * 2);
console.log(doubled.get()); // 4
from
from<T>(producer: (state: Signal.State<T>) => TeardownOrNull, initialValue: T): Signal.State<T>
Wraps an external data source as a State. producer is called when the first watcher attaches; it should push values into state and return a teardown to run when the last watcher detaches.
import { from } from 'yeet:tui';
const termSize = from(state => {
const push = () => state.set(tty.size());
push();
tty.on('resize', push);
return () => tty.off('resize', push);
}, tty.size());
The size Signal passed to mount's view function is produced this way.
Signal.subtle.Watcher
Low-level imperative watcher. Calls its callback synchronously the first time a watched signal becomes stale.
new Signal.subtle.Watcher(callback: () => void): Watcher
watcher.watch(...signals): void // arm and add signals
watcher.unwatch(...signals): void // detach signals
import { Signal } from 'yeet:signal'; // or use yeet:tui's signal/computed
const count = signal(0);
const watcher = new Signal.subtle.Watcher(() => {
queueMicrotask(() => {
console.log('count changed to', count.get());
watcher.watch(); // re-arm for next change
});
});
watcher.watch(count);
Signal.subtle.untrack
Signal.subtle.untrack<T>(fn: () => T): T
Runs fn without recording any reads as dependencies on the enclosing computed context.
Reactivity
Reactive state is signal / computed; external sources wrap with from. Side effects with a lifecycle live in the Effect widget — place one in the tree and its teardown runs when that subtree unmounts.
Terminal size
There is no global resize event to subscribe to, and you rarely need one: mount frames the root at the live terminal size, so responsive Sizes (fr, pct, vw/vh) reflow on their own. When code genuinely needs the dimensions — switching layout at a width breakpoint — read the size Signal mount hands view:
mount(size => (
<Box direction={() => size.get().cols < 80 ? "column" : "row"}>
{left}
{right}
</Box>
));
Colors
Faces are set as bare attributes on <Text> (fg, bg, bold, …). A colour is a hex string (#rgb / #rgba / #rrggbb / #rrggbbaa) anywhere a colour is taken, or an opaque value from one of these constructors:
| Function | Description |
|---|---|
rgb(hex) | Opaque truecolor from a hex value, e.g. rgb(0xff5500) |
rgb(r, g, b) | Opaque truecolor from 0–255 components |
rgba(hex, alpha) | Translucent truecolor; alpha ∈ [0, 1] |
rgba(r, g, b, alpha) | Same with components |
idx(n) | Indexed palette color, 0–255 |
DEFAULT | Terminal's own color (transparent in blends). Imported from yeet:tui:face, not yeet:tui. |
import { idx } from 'yeet:tui';
<Text fg="#00ff00" bold>OK</Text>
<Text bg={idx(8)}>{label}</Text>
For a face computed at runtime, face(patch) from yeet:tui applies a patch object to content — the programmatic form behind <Text>:
import { face } from 'yeet:tui';
face({ fg: theme.accent, bold: weight > 600 })(label)
Shaders
A container's bg may be a per-cell fill instead of one color: { shader: (x, y, w, h) => Color }, sampled once per cell at coords relative to the node's rect. Returning DEFAULT leaves the cell untouched, so a shader can mask. Samples are cached by object identity — vary a shader by minting a new object, not by mutating state it reads. Since bg takes thunks like any option, minting reactively is one closure:
// A one-column scrollbar: thumb over track, re-shaded when `progress` moves.
const bar = () => {
const p = progress.get();
return { shader: (_, y, __, h) => y / h < p ? idx(15) : idx(8) };
};
<Box width={1} height="1fr" bg={bar} />
Size system
A Size denotes a function of layout context — how many cells to occupy given parent extent, viewport, content range, flex share, and whether the parent flows this axis or overlaps it (the flow vs cross axis). Build one with the constructors below (or a string) and pass it as width, height, left, top, right, or bottom.
import { Size } from 'yeet:tui';
// or import { Size } from 'yeet:tui:layout';
| Constructor | Description |
|---|---|
Size.fixed(n) | Exactly n cells |
Size.fr(weight) | Flex: weight shares of remaining space |
Size.pct(frac) | Percentage of parent: frac * parent (e.g. 0.5 = 50%) |
Size.vw(frac) | Percentage of viewport width |
Size.vh(frac) | Percentage of viewport height |
Size.fit() | Intrinsic: shrink-wrap to content, up to parent |
Size.auto() | Hug along the parent's flow axis, fill across it — flexbox's item default |
Size.minContent() | Minimum content width (longest unbreakable word) |
Size.maxContent() | Maximum content width (no wrapping) |
Size.min(a, b) | Smaller of two sizes |
Size.max(a, b) | Larger of two sizes |
Size.clamp(floor, val, ceil) | Constrain between floor and ceil |
Size.add(a, b) / Size.sub(a, b) / Size.mul(a, b) / Size.div(a, b) | Arithmetic |
Size(v) | Coerce: a Size passes through, a number is cells, a string parses, a custom measure function lifts |
String shorthand — size options also accept strings:
<Box width="50%" height="10" />
<Box width="1fr" height="fit" />
<Box width="auto" height="auto" />
<Box width="25vw" height="50vh" />
<Box width="clamp(8, fit, 20)" />
<Box width="max(1fr + 4, 2 * 20%)" />
JSX
The runtime transpiles JSX automatically for .jsx and .tsx files — no pragma or build step required. It always lowers to the automatic runtime, importing jsx/jsxs/Fragment from the virtual yeet:tui/jsx-runtime module under the hood.
import { Box, Text, signal } from 'yeet:tui';
const count = signal(0);
setInterval(() => count.update(n => n + 1), 1000);
export default () => (
<Box direction="row" border="round" padding={1}>
<Text>{() => `Count: ${count.get()}`}</Text>
</Box>
);
The import source is fixed to yeet:tui (the runtime resolves it to yeet:tui/jsx-runtime); @jsxImportSource and classic @jsx/@jsxFrag pragmas are not honored. If you ever need the runtime functions directly, import them from yeet:tui/jsx-runtime:
import { jsx, jsxs, Fragment } from 'yeet:tui/jsx-runtime';
JSX element types must be component functions. String tag names (like <div>) are not supported.
Fragments (<>…</>) project to an array, which containers flatten automatically.
Full example
import { Box, Text, from, idx } from 'yeet:tui';
// `from` arms the subscription when the value is first rendered and tears it
// down when nothing reads it anymore — no manual lifecycle to manage.
const ifaces = from(state => {
const ticket = yeet.graph.subscribe(
`subscription { network_interface_stats(interval_ms: 1000) { name recv_bytes sent_bytes } }`,
d => state.set(d.network_interface_stats),
);
return () => yeet.graph.unsubscribe(ticket);
}, []);
export default () => (
<Box border="round" padding={1}>
<Box direction="row" border={{ line: "single", fg: idx(8) }}>
<Box width="1fr"><Text bold>Interface</Text></Box>
<Box width="12"><Text bold>RX</Text></Box>
<Box width="12"><Text bold>TX</Text></Box>
</Box>
{() => ifaces.get().map(iface =>
<Box direction="row">
<Box width="1fr">{iface.name}</Box>
<Box width="12"><Text fg="#00ff88">{String(iface.recv_bytes)}</Text></Box>
<Box width="12"><Text fg="#ff8800">{String(iface.sent_bytes)}</Text></Box>
</Box>
)}
</Box>
);
Text utilities
yeet:tui:text measures, wraps, and encodes text against the same Unicode tables the renderer uses — display width by grapheme cluster, UAX #14 line breaking, and terminal-safe truncation. It is importable on its own, and its encoders are the substitute for the absent TextEncoder (see runtime reference).
import { displayWidth, wrapRanges, clip, toUTF8 } from 'yeet:tui:text';
Measurement — display columns, not code units, so wide glyphs and clusters count correctly:
| Function | Description |
|---|---|
displayWidth(s) | Display columns s occupies (wide glyphs count 2, controls 0) |
maxWidth(s) | Columns the longest line wants unwrapped |
minWidth(s, brk?) | Narrowest wrap brk allows — its widest indivisible piece |
height(s, w, brk?) | Rows s occupies once wrapped to w columns |
Wrapping and truncation:
| Function | Description |
|---|---|
lines(s) | Split on hard newlines (s.split("\n")) |
wrapRanges(s, max, brk?) | Greedy wrap to max columns as [[lo, hi], …] offset ranges into s |
clip(s, w) | Truncate to w columns, never splitting a cluster or leaving half a wide glyph |
clusterOffsets(s) / clusterWidths(s) / breakOffsets(s) | Grapheme-cluster boundaries, per-cluster widths, and line-break opportunities |
brk is the break mode — "word" (default), "anywhere", "all", or "none" — matching the break prop.
Encoding — string → a fresh typed array of code units:
| Function | Result |
|---|---|
toUTF8(s) | Uint8Array of UTF-8 bytes |
toUTF16(s) | Uint16Array of UTF-16 code units |
toUTF32(s) | Uint32Array of scalar code points |
import { clip, displayWidth, toUTF8 } from 'yeet:tui:text';
clip("hello world", 5); // "hello"
displayWidth("世界"); // 4 — two wide glyphs
toUTF8("hi"); // Uint8Array [104, 105]
Sub-modules
The sub-modules below power the TUI internals. Most scripts only need yeet:tui, but they are also importable directly.
| Module | Contents |
|---|---|
yeet:tui:core | The framework itself — primitives, signals, and mountRaw, the render driver. yeet:tui re-exports it. |
yeet:tui:widgets | Composite widgets (Button) built on core. Also re-exported by yeet:tui. |
yeet:tui/jsx-runtime | JSX runtime (jsx, jsxs, Fragment) |
yeet:tui:layout | Node, Size, layout — the layout engine |
yeet:tui:face | Runs, blend, fade, resolve, sgr, Attr — styled-string and color primitives |
yeet:tui:text | displayWidth, wrapRanges, clip, lines, and other text measurement utilities |
yeet:tui:screen | Buffer, DoubleBuffer, paint, flush — the raster and diff layer |
yeet:signal | Signal — the TC39 signals implementation (State, Computed, subtle) |