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>
));
The size Signal passed to view stays live across resizes — read it inside a widget or effect to react to terminal dimension changes. The module also exports a global viewport Signal and an onResize(fn) helper (see Reactivity) if you'd rather not thread size through the tree.
Widgets
All widgets take (options, ...children). The first argument is always the options object ({} when you have none), followed by children.
Text
A text leaf. Renders a string, number, styled run, or a thunk returning any of those.
Text(opts: TextOpts, content: string | number | StyledRun | (() => string | StyledRun)): Signal
Options:
| Option | Type | Default | Description |
|---|---|---|---|
width | Size | fit | Width |
height | Size | fit | Height |
left / top / right / bottom | Size | — | Inset from that edge |
z | number | 0 | Paint order among siblings |
bg | Color | Shader | — | Background fill |
snap | "round" | "blend" | "round" | How fractional rects meet the cell grid |
break | "word" | "anywhere" | "all" | "none" | "word" | Soft-wrap strategy |
overflow | "hidden" | "ellipsis" | "visible" | "hidden" | What to do with overflowing text |
Box
A flow container. Stacks children along a direction, like flex-direction in CSS.
Box(opts: BoxOpts, ...children): Signal
Options (in addition to Text options):
| Option | Type | Default | Description |
|---|---|---|---|
direction | "column" | "row" | "column" | Flow axis |
side | Size | — | Shorthand: sets both width and height. An explicit width/height wins. |
border | boolean | BorderLine | BorderSpec | — | Draw a border frame one cell inside the box. See borders. |
padding | number | number[] | — | Cells of space between the frame and content. CSS shorthand: one number for all sides, [vertical, horizontal], [top, horizontal, bottom], or [top, right, bottom, left]. |
overflow | "visible" | "hidden" | "visible" | Clip children to the box rect |
Children may be Signals, thunks (functions), strings, numbers, or arrays. Arrays are flattened.
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>
Disposal
A zero-sized, invisible lifecycle leaf. It carries one or more cleanup callbacks that mount runs when the node leaves the tree (its subtree unmounts) or when mount() is torn down. Pair it with effect to scope a side effect to a place in the widget tree.
Disposal({ dispose: Teardown | Teardown[] }): Signal
dispose— a teardown function, or an array of them. Each runs once when the node unmounts ormount()stops.
import { Box, Text, Disposal, effect, signal } from 'yeet:tui';
const n = signal(0);
const Probe = () => {
// `effect` returns a stop handle; Disposal stops it when this subtree unmounts.
const stop = effect(() => console.log('n is', n.get()));
return (
<Box>
<Text>{() => `n=${n.get()}`}</Text>
<Disposal dispose={stop} />
</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 a Disposal 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, bold("hello"));
export default () => (
<Box>
{buf}
</Box>
);
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
Helpers for running side effects and reacting to terminal size, exported from yeet:tui.
effect
effect(fn: () => void): () => void
Runs fn now and again on every Signal edge it reads, coalescing a burst of changes into one re-run on a microtask. Returns a stop handle. The initial run is untracked, so creating an effect inside a component (under a render recompute) does not make the enclosing widget depend on what fn reads — the effect is created once, not once per frame. Pair the stop handle with a Disposal to scope the effect to its subtree.
const stop = effect(() => console.log(count.get()));
// later: stop();
fn's return value is ignored — effect does not run a per-cycle teardown. For sources that need cleanup, use from (teardown on unwatch) or Disposal (teardown on unmount).
viewport
viewport: Signal<{ rows: number; cols: number }>
A global Signal of the mounted terminal's size (falling back to the tty before any mount). Read it inside a widget or effect to reflow on resize, as an alternative to threading the size argument through your tree.
onResize
onResize(fn: ({ rows, cols }) => void): () => void
Runs fn with the current viewport now and again on every resize — an effect scoped to the viewport. Returns a stop handle.
const off = onResize(({ rows, cols }) => log(`${rows}x${cols}`));
// later: off();
Face combinators
These functions produce styled strings (called Runs) that Text renders with color and formatting. They come from yeet:tui directly.
import { bold, fg, bg, rgb, idx } from 'yeet:tui';
<Text>{bold(fg(rgb(0x00ff00))("OK"))}</Text>
Color 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. |
Face functions
All take a string or styled run and return a styled run. Nest inside-out, or use pipe from yeet:helpers:
bold(italic("text")) // bold and italic
fg(rgb(0xff0000))("error") // red text
| Function | Effect |
|---|---|
fg(color) | Set foreground color |
bg(color) | Set background color |
bold | Bold |
dim | Dimmed |
italic | Italic |
underline | Underlined |
blink | Blinking |
reverse | Swap fg/bg |
hidden | Invisible |
strike | Strikethrough |
face(patch) | Apply any face patch object |
Size system
Sizes are functions of layout context. Pass them as width, height, left, top, right, bottom options on any widget.
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.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 |
String shorthand — size options also accept strings:
<Box width="50%" height="10" />
<Box width="1fr" height="fit" />
<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, bold, fg, rgb, 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) }}>
<Text width="1fr">{bold("Interface")}</Text>
<Text width="12">{bold("RX")}</Text>
<Text width="12">{bold("TX")}</Text>
</Box>
{() => ifaces.get().map(iface =>
<Box direction="row">
<Text width="1fr">{iface.name}</Text>
<Text width="12">{fg(rgb(0x00ff88))(String(iface.recv_bytes))}</Text>
<Text width="12">{fg(rgb(0xff8800))(String(iface.sent_bytes))}</Text>
</Box>
)}
</Box>
);
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/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) |