ashlar
v0.2.0
Published
Terminal UI framework for Solid 2.0 - universal renderer, pure-TS Yoga flexbox, differential ANSI output.
Readme
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
import { createSignal } from "solid-js";
import { mount, useInput } from "ashlar";
import { driver } from "ashlar/bun"; // see Runtimes & Drivers for other runtimes
function App() {
const [count, setCount] = createSignal(0);
useInput((key) => {
if (key.name === "up") setCount((c) => c + 1);
if (key.name === "down") setCount((c) => Math.max(0, c - 1));
if (key.name === "c" && key.ctrl) {
screen.unmount();
process.exit(0);
}
});
return (
<box flexDirection="column" padding={1}>
<text bold color="#ff6600">
My App
</text>
<box borderStyle="round" color="#666">
<text bold>{` Count: ${count()}`}</text>
</box>
</box>
);
}
const 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.
| Runtime | Driver |
| -------- | ------------------------------------------------------ |
| Bun | import { driver } from "ashlar/bun" — native Bun.* |
| Other JS | build 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:
npm install string-width wrap-ansi slice-ansiimport stringWidth from "string-width";
import wrapAnsi from "wrap-ansi";
import sliceAnsi from "slice-ansi";
import { mount, type TermDriver } from "ashlar";
const driver: TermDriver = { stringWidth, wrapAnsi, sliceAnsi };
mount(() => <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:
bun demo # from the repo rootThe 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.
| Demo | What it shows |
| ---------------- | ---------------------------------------------------------------- |
| alt-screen | Counter with keyboard input in the alternate screen buffer |
| input | Single- and multi-line <input> with submission |
| mouse | Hover/click buttons via mouse tracking |
| scroll | Scrollable list — keyboard navigation + mouse wheel |
| hyperlinks | OSC 8 web and file links with capability fallback |
| selection | Click-drag text selection copied to clipboard via OSC 52 |
| commit | screen.commit() finalizing output into native scrollback |
| stream-spinner | Animated shimmer spinner + token counter on a simulated stream |
| select | Keyboard- and mouse-navigable <Select> menu with custom rows |
| progress | Determinate <ProgressBar> — auto-advancing, manual, and styled |
Build Configuration
bunfig.toml — preloads the Solid transform plugin so .tsx/.jsx is
lowered through babel-preset-solid in universal mode:
preload = ["./scripts/preload.ts"]
[test]
preload = ["./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
babel-preset-solid 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
| Export | Path | Purpose |
| ------------------------------------ | ------------------------- | ------------------------------------------------------------ |
| . | src/index.ts | Main API |
| ./animation | src/animation/index.ts | Linked animation groups + effects (shimmer, wave, pulse) |
| ./ui | src/ui/index.ts | Higher-level components (Spinner, Select, ProgressBar) |
| ./bun | src/bun.ts | Text-metrics driver for Bun (native Bun.*) |
| ./preload | scripts/preload.ts | Bun plugin loader |
| ./bun-plugin | scripts/solid-plugin.ts | Plugin source |
| ./jsx-runtime, ./jsx-dev-runtime | jsx-runtime.d.ts | JSX 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
Solid 2.0 Signals (batched, settle on flush())
│
▼
Frame Scheduler (setInterval at target fps, calls flush())
│
▼
Solid Universal Renderer (targeted node mutations)
│
▼
Yoga Layout Engine (pure TS, recalculates dirty subtree)
│
▼
Frame Buffer (2D cell grid, paint positioned nodes)
│
▼
Diff Engine (compare current vs previous frame)
│
▼
ANSI Serializer (minimal escape sequences, SGR delta tracking)
│
▼
stdout.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.
function mount(code: () => TermNode, options?: ScreenOptions): Screen;ScreenOptions
| Option | Type | Default | Description |
| --------------- | ------------------------ | ----------------------- | -------------------------------------------- |
| driver | TermDriver | required on first mount | Text-metrics driver (see Runtimes & Drivers) |
| altScreen | boolean | false | Use alternate screen buffer |
| cols | number | stdout.columns | Terminal columns |
| rows | number | stdout.rows | Terminal rows |
| fps | number | 30 | Target frame rate |
| enableInput | boolean | true | Enable input handling |
| mouse | boolean | false | Enable mouse tracking |
| kittyKeyboard | boolean | auto | Enable kitty keyboard protocol |
| syncOutput | boolean | auto | Wrap frames in DEC 2026 markers |
| write | (data: string) => void | process.stdout.write | Custom output function |
Screen Interface
interface Screen {
root: TermNode;
input: InputBus;
mouse: boolean;
commit(content: () => TermNode): void;
refresh(): void;
resize(cols: number, rows: number): void;
setMouse(enabled: boolean): void;
unmount(): void;
}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 toprocess.stdoutresize 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, plusref.<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.
import { Shimmer } from "ashlar/animation";
import { Spinner } from "ashlar/ui";
<Shimmer baseColor="#666" highlightColor="#fff">
<Spinner />
<text>{` ${verb}…`}</text>
</Shimmer>;Named wrappers — the three built-in effects, each running across all of its children as one linked region:
| Wrapper | Effect |
| ----------- | -------------------------------------------------------------- |
| <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 (asetIntervalthat 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/rampMstune the onset and slope).- Color utilities —
lerpColor(from, to, t),lighten(color), andpackColor(color)(anyBun.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 passindexto drive the highlight from the parent; achildrenrender prop customizes row rendering.<ProgressBar>— a determinate bar driven by a 0–1value. 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.glyphsselects the frame sequence (aSPINNER_GLYPHSpreset or your own).
Input System
useInput, useMouse, and usePaste subscribe to input inside any component
under mount():
import { useInput, useMouse, usePaste, scrollBy } from "ashlar";
useInput((key) => {
if (key.name === "up") scrollBy(ref, -1);
if (key.name === "c" && key.ctrl) screen.unmount();
});
useMouse((event) => { if (event.type === "up") /* click at event.x,event.y */ });
usePaste((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:
import { scrollBy, scrollTo, scrollToBottom, type TermNode } from "ashlar";
let ref!: TermNode;
<box overflow="scroll" height={15} ref={ref} borderStyle="round">
…
</box>;
scrollBy(ref, -1); // up one row
scrollTo(ref, 0); // jump to top
scrollToBottom(ref); // jump to bottomTerminal 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
bun install # install + symlink the demo workspace
bun test # run the suite
bun run typecheck # tsgo --noEmit
bun run fix # biome format + lint --write
bun run demo # launch the demo gallery
bun run build # emit dist/ for publishingDependencies
Runtime: solid-js (2.0).
Dev: @babel/core, babel-preset-solid (2.0), @babel/preset-typescript,
@biomejs/biome, typescript/tsgo, bun-types.
