Skip to main content

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 live size Signal — read it inside a widget (not at the top level) to stay reactive across resizes.
  • term — Optional terminal object; defaults to the global tty.
  • 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>
));
Reacting to resizes

mount frames the root at the live terminal size, so responsive Sizesfr, 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):

AttributeTypeDescription
fgColorForeground colour — rgb/rgba/idx, or a hex string (#rgb/#rgba/#rrggbb/#rrggbbaa)
bgColorBackground colour, same forms as fg
bold dim italic underline blink reverse hidden strikebooleanSGR 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:

OptionTypeDefaultDescription
width / heightSizeautoExtent on each axis
left / top / right / bottomSizeInset from that edge
znumber0Paint order among siblings
ordernumber0Flow order (CSS flex order); ties keep document order
bgColor | ShaderBackground fill
snap"round" | "blend""round"How fractional rects meet the cell grid
direction"column" | "row" | "column-reverse" | "row-reverse""column"Flow axis
sideSizeShorthand: sets both width and height. An explicit axis wins.
borderboolean | BorderLine | BorderSpecBorder frame one cell inside the box. See borders.
paddingnumber | 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:

PropertyTypeDescription
rowsnumberBuffer height
colsnumberBuffer width
charsTensorGlyph code points (Uint32Array at chars.data)
fg / bgTensorColor planes (Int32Array at .data)
attrsTensorAttribute 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:

MethodDescription
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.

OptionTypeDescription
selectedboolean | Signal | thunkPaint the pressed tier while on
setHover / setPressedsetterMirrors 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:

OptionTypeDescription
equals(a, b) => booleanCustom equality check. Default: Object.is. A .set() that compares equal is a no-op.
[Signal.subtle.watched]() => voidCalled when the first watcher (Watcher or live Computed) attaches.
[Signal.subtle.unwatched]() => voidCalled 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:

FunctionDescription
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
DEFAULTTerminal'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';
ConstructorDescription
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:

FunctionDescription
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:

FunctionDescription
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:

FunctionResult
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.

ModuleContents
yeet:tui:coreThe framework itself — primitives, signals, and mountRaw, the render driver. yeet:tui re-exports it.
yeet:tui:widgetsComposite widgets (Button) built on core. Also re-exported by yeet:tui.
yeet:tui/jsx-runtimeJSX runtime (jsx, jsxs, Fragment)
yeet:tui:layoutNode, Size, layout — the layout engine
yeet:tui:faceRuns, blend, fade, resolve, sgr, Attr — styled-string and color primitives
yeet:tui:textdisplayWidth, wrapRanges, clip, lines, and other text measurement utilities
yeet:tui:screenBuffer, DoubleBuffer, paint, flush — the raster and diff layer
yeet:signalSignal — the TC39 signals implementation (State, Computed, subtle)