README.md

ashlar

Terminal UI framework built on Solid 2.0 reactivity, a pure-TypeScript Yoga flexbox engine, and Bun's native terminal primitives. Renders into a frame buffer with differential ANSI output — only changed cells are written to stdout.


Quick Start

tsx
1import { createSignal } from "solid-js";
2import { mount, useInput } from "ashlar";
3import { driver } from "ashlar/bun"; // see Runtimes & Drivers for other runtimes
4
5function App() {
6 const [count, setCount] = createSignal(0);
7
8 useInput((key) => {
9 if (key.name === "up") setCount((c) => c + 1);
10 if (key.name === "down") setCount((c) => Math.max(0, c - 1));
11 if (key.name === "c" && key.ctrl) {
12 screen.unmount();
13 process.exit(0);
14 }
15 });
16
17 return (
18 <box flexDirection="column" padding={1}>
19 <text bold color="#ff6600">
20 My App
21 </text>
22 <box borderStyle="round" color="#666">
23 <text bold>{` Count: ${count()}`}</text>
24 </box>
25 </box>
26 );
27}
28
29const screen = mount(() => <App />, { driver, fps: 30 });

Runtimes & Drivers

Ashlar's layout and paint paths need three ANSI-aware string operations — stringWidth, wrapAnsi, sliceAnsi. Each runtime provides them through a driver, installed process-wide by the first mount(App, { driver }) (or explicitly via setDriver()). There is no auto-detection: you pick the driver for the runtime you're bundling for.

RuntimeDriver
Bunimport { driver } from "ashlar/bun" — native Bun.*
Other JSbuild a driver from portable npm packages — see below

For Node, Deno, or another JavaScript runtime, build a driver from the pure-JS npm packages that Bun's Zig implementations are based on. Their semantics match Ashlar's expectations, including wrapAnsi stripping trailing whitespace per line. They stay out of Ashlar's dependency tree, so install them in your application:

sh
1npm install string-width wrap-ansi slice-ansi
ts
1import stringWidth from "string-width";
2import wrapAnsi from "wrap-ansi";
3import sliceAnsi from "slice-ansi";
4import { mount, type TermDriver } from "ashlar";
5
6const driver: TermDriver = { stringWidth, wrapAnsi, sliceAnsi };
7
8mount(() => <App />, { driver });

Colors are not part of the driver: parsing is pure JS and identical on every runtime. ColorInput is a packed 24-bit number, a hex string (#rgb, #rrggbb — alpha forms accepted, alpha ignored), or one of the classic 16 terminal color names (red, brightCyan, … at xterm palette values, with autocomplete). Computed colors use the exported helpers instead of CSS strings: rgb(215, 119, 87) and hsl(210, 80, 60) return packed numbers that compose with lerpColor/lighten.


Demos

The demo/ workspace is an interactive gallery. Launch the menu and pick a demo with the arrow keys:

sh
1bun demo # from the repo root

The launcher (demo/src/main.tsx) runs each selected demo in-process — every demo exposes a run() that mounts its own screen and resolves on Ctrl+C, which returns you to the menu.

DemoWhat it shows
alt-screenCounter with keyboard input in the alternate screen buffer
inputSingle- and multi-line <input> with submission
mouseHover/click buttons via mouse tracking
scrollScrollable list — keyboard navigation + mouse wheel
hyperlinksOSC 8 web and file links with capability fallback
selectionClick-drag text selection copied to clipboard via OSC 52
commitscreen.commit() finalizing output into native scrollback
stream-spinnerAnimated shimmer spinner + token counter on a simulated stream
selectKeyboard- and mouse-navigable <Select> menu with custom rows
progressDeterminate <ProgressBar> — auto-advancing, manual, and styled

Build Configuration

bunfig.toml — preloads the Solid transform plugin so .tsx/.jsx is lowered through @solidjs/babel-plugin in universal mode:

toml
1preload = ["./scripts/preload.ts"]
2
3[test]
4preload = ["./scripts/preload.ts"]

The demo workspace preloads the same plugin via the package's ./preload export (see demo/bunfig.toml).

tsconfig.json — JSX set to preserve with jsxImportSource: "ashlar" so JSX type-checks against this package's jsx-runtime.d.ts and runtime calls resolve to its createElement.

Bun plugin (scripts/solid-plugin.ts) transforms .tsx/.jsx via @solidjs/babel-plugin with generate: "universal" and module name ashlar. It also redirects solid-js/server.js to the client runtime under Bun's node condition.

Package Exports

ExportPathPurpose
.src/index.tsMain API
./animationsrc/animation/index.tsLinked animation groups + effects (shimmer, wave, pulse)
./uisrc/ui/index.tsHigher-level components (Spinner, Select, ProgressBar)
./bunsrc/bun.tsText-metrics driver for Bun (native Bun.*)
./preloadscripts/preload.tsBun plugin loader
./bun-pluginscripts/solid-plugin.tsPlugin source
./jsx-runtime, ./jsx-dev-runtimejsx-runtime.d.tsJSX type definitions

The default workflow runs straight from src/ via the preload plugin — no build step. bun run build produces a publishable dist/ (universal .js, a JSX-preserved .jsx tree for the solid condition, and .d.ts); see scripts/build.ts.


Architecture

1Solid 2.0 Signals (batched, settle on flush())
2 │
3 ▼
4Frame Scheduler (setInterval at target fps, calls flush())
5 │
6 ▼
7Solid Universal Renderer (targeted node mutations)
8 │
9 ▼
10Yoga Layout Engine (pure TS, recalculates dirty subtree)
11 │
12 ▼
13Frame Buffer (2D cell grid, paint positioned nodes)
14 │
15 ▼
16Diff Engine (compare current vs previous frame)
17 │
18 ▼
19ANSI Serializer (minimal escape sequences, SGR delta tracking)
20 │
21 ▼
22stdout.write() (wrapped in DEC 2026 synchronized output)

Signal updates queue without propagating. The frame scheduler calls flush() at the target frame rate (default 30fps), settling the entire reactive graph in one pass. This produces exactly one layout + paint + diff + serialize cycle per frame regardless of how many signals changed between frames.

Key events schedule a render on the next microtask via queueMicrotask — rapid keypresses within the same event-loop turn are coalesced into a single render. Mouse events are dispatched to handlers but render on the next scheduled frame.


mount() and Screen

mount() is the top-level entry point. It creates the root TermNode, starts the frame scheduler, sets up input handling, and manages the terminal lifecycle.

typescript
1function mount(code: () => TermNode, options?: ScreenOptions): Screen;

ScreenOptions

OptionTypeDefaultDescription
driverTermDriverrequired on first mountText-metrics driver (see Runtimes & Drivers)
altScreenbooleanfalseUse alternate screen buffer
colsnumberstdout.columnsTerminal columns
rowsnumberstdout.rowsTerminal rows
fpsnumber30Target frame rate
enableInputbooleantrueEnable input handling
mousebooleanfalseEnable mouse tracking
kittyKeyboardbooleanautoEnable kitty keyboard protocol
syncOutputbooleanautoWrap frames in DEC 2026 markers
write(data: string) => voidprocess.stdout.writeCustom output function

Screen Interface

typescript
1interface Screen {
2 root: TermNode;
3 input: InputBus;
4 mouse: boolean;
5 commit(content: () => TermNode): void;
6 refresh(): void;
7 resize(cols: number, rows: number): void;
8 setMouse(enabled: boolean): void;
9 unmount(): void;
10}
  • commit(content) — renders a component once to ANSI, writes it into scrollback above the active region, then disposes the tree. Used to finalize completed output. Primary buffer only.
  • setMouse(enabled) — toggle mouse tracking at runtime.
  • refresh() — force an immediate render, bypassing the scheduler.
  • resize(cols, rows) — update dimensions and re-render (also hooked to process.stdout resize events automatically).
  • unmount() — stop the frame loop, dispose the Solid tree, restore the terminal. Also registered on SIGINT/SIGTERM/exit so the terminal is restored on unexpected process death.

Primary vs Alternate Buffer

By default mount() renders in the primary buffer: the UI occupies the bottom N rows and output committed via screen.commit() scrolls into native terminal scrollback (preserving search, selection, copy-paste). With altScreen: true the renderer takes over the full screen; commit() is a no-op.


Intrinsic Elements

All elements are JSX intrinsics. Colors accept any Bun.ColorInput value (CSS names, hex, rgb, etc.). Full prop reference lives in jsx-runtime.d.ts.

  • <box> — flexbox container with layout, sizing, spacing, visual, and mouse-event props, plus ref.
  • <text> — styled text with wrapping/truncation and the usual SGR styling props.
  • <input> — controlled text input with built-in keyboard editing; the screen routes keypresses to the focused <input>.
  • <hyperlink> — OSC 8 terminal hyperlink with plain-text fallback.

Animation (ashlar/animation)

A linked animation is a paint-time color field: <Animated> runs an Effect as a filter over the region its children paint, recoloring glyph cells by screen position after the subtree paints. A shimmer (or wave, or pulse) therefore sweeps across heterogeneous children — text, a spinner glyph, nested boxes — as one continuous sequence, and the children stay plain (<text>, <Spinner>) with no animation-specific markup or wrappers.

tsx
1import { Shimmer } from "ashlar/animation";
2import { Spinner } from "ashlar/ui";
3
4<Shimmer baseColor="#666" highlightColor="#fff">
5 <Spinner />
6 <text>{` ${verb}…`}</text>
7</Shimmer>;

Named wrappers — the three built-in effects, each running across all of its children as one linked region:

WrapperEffect
<Shimmer>A highlight crest sweeping by column, soft two-cell falloff
<Wave>A sine gradient scrolling along the columns between two colors
<Pulse>The whole region breathing between two colors in unison

Each takes its own color and timing props; all three also accept paused (freeze at the resting color) and time (share an external useClock signal).

<Animated effect={…}> — the generic form. The effect is data: the shimmer(), wave(), and pulse() factories return an Effect — (env: { t, paused }) => (x, y, w, h) => packedColor | undefined (return undefined to leave a cell's painted color untouched). New effects are just new factories; they need no new components. Effect colors may be accessors, so they can themselves animate — e.g. lerping baseColor/highlightColor toward red as a stream stalls (the stream-spinner demo does exactly this). paused freezes the effect at its resting color.

Hooks & helpers —

  • useClock(ms?) — a shared tick signal (a setInterval that increments a signal, auto-cleared on dispose). Components that need time-derived values should share one clock rather than each running a timer.
  • useStalled(time, getLength, opts?) — watches a length accessor against the clock and returns { isStalled(), intensity() }, a smoothed 0→1 ramp for fading a stalled stream's color (delayMs/rampMs tune the onset and slope).
  • Color utilities — lerpColor(from, to, t), lighten(color), and packColor(color) (any Bun.ColorInput → packed 24-bit int).

Mechanically, <Animated> sets a colorField on its <box>; the paint pipeline recolors that box's glyph cells each frame (see applyColorField in pipeline.ts). Because the field is a normal reactive prop, a clock tick marks the box dirty and the region repaints.


Components (ashlar/ui)

Higher-level components built on the renderer primitives.

  • <Select> — a keyboard- and mouse-navigable list. Uncontrolled by default, or pass index to drive the highlight from the parent; a children render prop customizes row rendering.
  • <ProgressBar> — a determinate bar driven by a 0–1 value. For an indeterminate "busy" state, reach for <Spinner> instead.
  • <Spinner> — a bare animated glyph. It carries no animation awareness — dropped inside an <Animated>/<Shimmer>/… region, the group's paint filter recolors its glyph along with the text beside it, so an effect sweeps across the spinner and its label as one. glyphs selects the frame sequence (a SPINNER_GLYPHS preset or your own).

Input System

useInput, useMouse, and usePaste subscribe to input inside any component under mount():

typescript
1import { useInput, useMouse, usePaste, scrollBy } from "ashlar";
2
3useInput((key) => {
4 if (key.name === "up") scrollBy(ref, -1);
5 if (key.name === "c" && key.ctrl) screen.unmount();
6});
7useMouse((event) => { if (event.type === "up") /* click at event.x,event.y */ });
8usePaste((text) => setValue((v) => v + text));

ParsedKey covers both legacy xterm/CSI sequences and the kitty keyboard protocol (CSI u). RawMouseEvent carries type, button, x/y (0-indexed), modifiers, and optional scroll. The bus is also exposed directly on screen.input (addKeyHandler / addMouseHandler / addPasteHandler, each returning an unsubscribe fn) for use outside components.

When mouse is enabled, clicks are hit-tested to the deepest node at the coordinates (cached screenRect per node). Click-and-drag selects frame-buffer text and copies it to the clipboard via OSC 52 on mouse-up.


Scrolling

Containers with overflow="scroll" scroll via mouse wheel (when mouse: true) and programmatically via refs:

tsx
1import { scrollBy, scrollTo, scrollToBottom, type TermNode } from "ashlar";
2
3let ref!: TermNode;
4<box overflow="scroll" height={15} ref={ref} borderStyle="round">
5 …
6</box>;
7
8scrollBy(ref, -1); // up one row
9scrollTo(ref, 0); // jump to top
10scrollToBottom(ref); // jump to bottom

Terminal Capabilities

terminalCaps is a synchronous capability object detected on import from environment variables (TERM_PROGRAM, TERM, KITTY_WINDOW_ID, WT_SESSION, COLORTERM, VTE_VERSION, tmux): hyperlinks, kittyKeyboard, mouse, syncOutput, trueColor. Defaults are conservative — sync output and hyperlinks are disabled inside tmux.

Several primitives come from Bun directly: Bun.stringWidth(), Bun.sliceAnsi(), Bun.wrapAnsi(), Bun.stripANSI(), Bun.color() — no string-width/slice-ansi/wrap-ansi/chalk dependencies, and no native deps (the Yoga port is pure TypeScript).


Development

sh
1bun install # install + symlink the demo workspace
2bun test # run the suite
3bun run typecheck # tsc --noEmit
4bun run fix # biome format + lint --write
5bun run demo # launch the demo gallery
6bun run build # emit dist/ for publishing

Dependencies

Runtime: solid-js (2.0). Dev: @babel/core, @solidjs/babel-plugin (2.0), @babel/preset-typescript, @biomejs/biome, typescript, bun-types.