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

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:

OptionTypeDefaultDescription
widthSizefitWidth
heightSizefitHeight
left / top / right / bottomSizeInset from that edge
znumber0Paint order among siblings
bgColor | ShaderBackground 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):

OptionTypeDefaultDescription
direction"column" | "row""column"Flow axis
sideSizeShorthand: sets both width and height. An explicit width/height wins.
borderboolean | BorderLine | BorderSpecDraw a border frame one cell inside the box. See borders.
paddingnumber | 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 or mount() 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:

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

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

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

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.

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
FunctionEffect
fg(color)Set foreground color
bg(color)Set background color
boldBold
dimDimmed
italicItalic
underlineUnderlined
blinkBlinking
reverseSwap fg/bg
hiddenInvisible
strikeStrikethrough
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';
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.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.

ModuleContents
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)