@crow-agent/tui
v0.2.2
Published
Terminal UI toolkit with differential rendering and keyboard-first components.
Readme
@crow-agent/tui
Build interactive terminal applications in TypeScript: chat interfaces, dashboards,
editors, and installers. One shared TUI contract drives two renderers — a
scrollback-preserving main-screen renderer and a full-viewport alternate-screen
renderer with app-owned scrolling. Frames are diffed and flushed inside
synchronized output, so updates land atomically without flicker.
Install
npm install @crow-agent/tuiThe package ships ESM only (dist/, type declarations included). Node 22 or newer
is required.
Hello, terminal
import {
Editor,
ProcessTerminal,
Text,
TuiMainScreen,
matchesKey,
type TUI,
} from "@crow-agent/tui";
const screen: TUI = new TuiMainScreen(new ProcessTerminal());
screen.addChild(new Text("Type something, then hit Enter."));
const editor = new Editor(screen, myEditorTheme);
editor.onSubmit = (line) => {
screen.addChild(new Text(`Heard: ${line}`));
screen.requestRender();
};
screen.addChild(editor);
screen.setFocus(editor);
screen.addInputListener((data) => {
// Raw mode swallows Ctrl+C, so intercept it for a clean exit.
if (matchesKey(data, "ctrl+c")) {
screen.stop();
process.exit(0);
}
});
screen.start();Everything below builds on three ideas:
- Components render themselves:
render(width)returns one string per line. - The TUI owns the frame loop: add children, call
requestRender(), handle input. - Terminals are pluggable:
ProcessTerminaltalks to stdio;VirtualTerminal(backed by@xterm/headless) drives tests headlessly.
Renderers
TUI is the only contract components ever see. Choose the concrete renderer once,
at construction:
| Renderer | Buffer | Scrolling | Best for |
| --- | --- | --- | --- |
| TuiMainScreen | Normal buffer, scrollback kept | The terminal's | CLIs, installers, simple prompts |
| TuiAltScreen | Alternate buffer, terminal-height viewport | The app's | Chat UIs, dashboards, full-screen apps |
import { TuiAltScreen, TuiMainScreen, type TUI } from "@crow-agent/tui";
const screen: TUI = new TuiMainScreen(terminal);
// or, for a viewport app:
// const screen: TUI = new TuiAltScreen(terminal);
screen.addChild(widget);
screen.removeChild(widget);
screen.start(); // enters raw mode, draws the first frame
screen.stop(); // restores the terminal; alt-screen prints the final document
screen.requestRender();How frames are drawn
TuiMainScreen picks the cheapest correct strategy per update: print everything on
the first frame; clear and repaint when the width changed or an edit landed above
the viewport; otherwise move the cursor to the first changed line and repaint from
there down.
TuiAltScreen keeps a fixed-height viewport. With no layout root it scrolls a
single document; with setLayoutRoot() it manages independent regions (see
Layout). Changed rows are repainted in place, streaming output follows
the bottom while pinned, and a manual scroll position is preserved while content
grows underneath.
Both wrap every update in synchronized output (\x1b[?2026h … \x1b[?2026l).
Components
Every component implements this contract:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
invalidate(): void;
}Rules that keep rendering predictable:
render(width)returns exactly one string per terminal line, and no line may be wider thanwidth— the TUI raises otherwise. Reach fortruncateToWidth()orwrapTextWithAnsi()to guarantee it.- Every rendered line gets a full SGR + OSC 8 reset appended, so styles never leak
across lines. For multi-line styled text, re-apply styles per line (or wrap with
wrapTextWithAnsi(), which carries them). invalidate()drops cached render state. Components without a cache leave it empty — but it must exist.
The library ships these building blocks:
| Component | What it does |
| --- | --- |
| Text | Word-wrapped multi-line text with padding and an optional background painter |
| TruncatedText | A single line, truncated to the viewport — status lines, headers |
| Input | Single-line input with horizontal scrolling and word-wise editing |
| Editor | Multi-line editor: autocomplete, file completion, paste markers, scrolling |
| Markdown | Markdown rendering with themes and optional syntax highlighting |
| Loader / CancellableLoader | Animated spinner; the cancellable one exposes an AbortSignal on Escape |
| SelectList | Keyboard/mouse navigable option list with filtering |
| SettingsList | Settings panel with value cycling and submenus |
| MouseRegion | Adds pointer behavior to any component without changing its rendering |
| Spacer | Blank lines for vertical rhythm |
| Image | Inline images via Kitty or iTerm2 graphics protocols, with text fallback |
| Box / Container | Padding + background (Box); plain grouping (Container) |
| VStack / HStack | Constrained layout regions for the alternate-screen viewport |
| ScrollView | App-owned scrolling for one layout region |
A few notes on the interactive ones:
Text(content, padX?, padY?, bgPaint?)—setText()swaps content,setCustomBgFn()swaps the background painter. Blank content renders nothing.Input—onSubmit,setValue()/getValue(); click-to-position the cursor in alt-screen mode.Editor(tui, theme, options?)— needs theTUIfor height-aware scrolling.onSubmit/onChange,disableSubmitto pause submissions,setPaddingX(). Pastes longer than 10 lines collapse into a[paste #1 +50 lines]marker.SelectList(items, maxVisible, theme)—onSelect,onCancel,onSelectionChange,setFilter().SettingsList(items, maxVisible, theme, onChange, onCancel)— rows either cycle throughvaluesonEnter/Spaceor open asubmenu;updateValue()pokes a row programmatically.
Default key bindings
Input and Editor share the familiar readline-style bindings:
Entersubmit ·Shift+Enter/Ctrl+Enter/Alt+Enternewline (editor)Ctrl+A/Ctrl+Eline start/end ·Ctrl+K/Ctrl+Ukill to end/start of lineCtrl+W/Alt+Backspacekill word back ·Alt+D/Alt+Deletekill word forwardCtrl+Left/Ctrl+RightandAlt+Left/Alt+Rightword hopCtrl+]jump forward to a typed character,Ctrl+Alt+]jump backward (editor)Tabautocomplete, arrows/Backspace/Delete as expected
The registry is global and configurable — see Keybindings.
Layout
Layout only exists on TuiAltScreen, where the app owns scrolling. VStack and
HStack carve the viewport into constrained regions; a ScrollView makes one
region scrollable. (TuiMainScreen deliberately has none of this — the terminal
owns its scrollback.)
import { Container, ScrollView, Text, VStack, isViewportTUI } from "@crow-agent/tui";
const transcript = new Container();
const dock = new VStack([editor, new Text("hint bar")]);
if (isViewportTUI(screen)) {
screen.setLayoutRoot(
new VStack([
{
component: new ScrollView(transcript, {
follow: "end", // pin to the bottom while new rows stream in
primary: true, // receives viewport keys + wheel over dead areas
overscroll: "chain", // hand unused wheel delta to outer scrollers
}),
basis: 0,
grow: 1,
minSize: 1,
},
{ component: dock, basis: "auto", shrink: 1, minSize: 1 },
]),
);
}Stack slots accept basis, grow, shrink, minSize, maxSize, and a
responsive visible(termWidth, termHeight) predicate. Geometry is rebuilt per
frame while stateful components and their render caches survive.
The primary scroll view gets the alternate-screen keyboard navigation (with four
rows of page overlap), wheel routing under the pointer, and jumps between OSC 133
semantic prompt markers. Its scrollbar supports hover expansion, thumb dragging,
and track-click jumps. Ctrl+Shift+F toggles a bordered search panel over the
transcript with clickable arrows, match highlighting styles, and Escape to close.
A scrollToEndIndicator label can sit centered on the last row while a
follow: "end" view is scrolled up — clicking it resumes following.
Overlays
Overlays float a component above the current content — dialogs, menus, popups — without replacing it.
const handle = screen.showOverlay(dialog); // centered, max 80 columns
const handle2 = screen.showOverlay(dialog, {
width: 60, // fixed columns…
// width: "80%", // …or a share of the terminal
minWidth: 40,
maxHeight: "50%",
anchor: "bottom-right", // default "center"; see values below
offsetX: 2,
offsetY: -1,
// row: "25%", col: "50%", // percentage positioning…
// row: 5, col: 10, // …or absolute positioning
margin: { top: 1, right: 2, bottom: 1, left: 2 },
visible: (w, h) => w >= 100, // re-evaluated every frame
nonCapturing: true, // show without stealing focus
});Anchor values: center, top-left, top-right, bottom-left, bottom-right,
top-center, bottom-center, left-center, right-center. Resolution order:
minWidth floors the computed width; absolute row/col beat percentage
row/col, which beat anchor; margin clamps inside the terminal; visible
decides per frame whether it draws at all.
handle.hide(); // remove permanently
handle.setHidden(true); // stash temporarily; setHidden(false) brings it back
handle.focus(); // focus and raise to the visual front
handle.unfocus(); // release to the normal fallback
handle.unfocus({ target: panel }); // release to a specific component
handle.unfocus({ target: null }); // release and leave focus empty
handle.isFocused();
handle.isHidden();
handle.getBounds(); // last rendered terminal-relative bounds
screen.hideOverlay(); // dismiss the topmost overlay
screen.hasOverlay(); // any visible overlay active?Keyboard input
Raw terminal bytes arrive at the focused component's handleInput(). Use
matchesKey() with the Key helper (Kitty keyboard protocol aware) instead of
hand-rolling escape-sequence parsing:
import { Key, matchesKey } from "@crow-agent/tui";
if (matchesKey(data, Key.ctrl("c"))) shutdown();
else if (matchesKey(data, Key.enter)) submit();
else if (matchesKey(data, Key.escape)) cancel();
else if (matchesKey(data, Key.up)) moveUp();Prefer the Key.* builders for editor autocomplete (Key.ctrl("c"),
Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p"), Key.enter,
Key.space, …); plain strings like "ctrl+c" work too.
Keybindings
The alternate-screen viewport actions (scroll one line / half page / page, jump to top/bottom, toggle search, …) live in a global registry:
import { getKeybindings, setKeybindings, TUI_KEYBINDINGS } from "@crow-agent/tui";
const bindings = getKeybindings();
setKeybindings({ ...bindings, scrollDown: ["ctrl+n"] });Ctrl-modified viewport navigation is routed to the focused component first, so
an Editor keeps Ctrl+N for its own use while unfocused views still scroll.
Mouse input
TuiAltScreen normalizes SGR mouse reports and hit-tests components and overlays.
Each event carries component-local x/y, absolute screenX/screenY, bounds,
button, modifiers, click count, and wheel delta. (TuiMainScreen never captures
the mouse — the terminal owns its scrollback.)
import type { TuiMouseEvent, TuiMouseEventResult } from "@crow-agent/tui";
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type === "click" && event.button === "left") {
this.open = !this.open;
return { handled: true };
}
if (event.type === "press" && event.button === "left") {
return { handled: true, capture: true, focus: true };
}
if (event.type === "drag") {
this.slideTo(event.x, event.y);
return { handled: true, render: true };
}
return undefined;
}Result flags: handled suppresses the default behavior; capture keeps drag and
release routed to this component; focus takes keyboard focus. The render flag
controls repainting — press/click/drag/wheel repaint by default, move/release do
not. Return render: true when a hover visibly changed something, or
render: false for a handled no-op; requests are coalesced and output stays
differential.
Unhandled gestures fall back to the viewport defaults: wheel scrolls the nearest
ScrollView (chaining leftover delta outward), primary-button drags select text,
OSC 8 hyperlinks open before parent click handlers run, and unhandled right-click
keeps the configured paste behavior. A click only fires when press and release
complete without a drag.
MouseRegion wraps any component to add pointer behavior without touching its
rendering, and Container/Box route events to children from the last frame's
recorded geometry — hit-testing never rerenders children.
Text selection and clipboard
Primary-button drags select visible text; on release it is copied via OSC 52
unless TuiAltScreenOptions.copyOnSelect is false. Double-click selects a word
(slash/hyphen runs coalesce), triple-click a line, and dragging past the
viewport's edge auto-scrolls while extending the selection. Word and grapheme
boundaries respect CJK, emoji, and combining marks. An injected
copySelection handler can take over copying (and report failures with a flash
message); copySelectionToClipboard() copies programmatically.
Focus and IME
Components that draw a text cursor and need IME support implement Focusable:
import { CURSOR_MARKER, type Component, type Focusable } from "@crow-agent/tui";
class Prompt implements Component, Focusable {
focused = false; // the TUI sets this on focus changes
render(width: number): string[] {
const marker = this.focused ? CURSOR_MARKER : "";
return [`> ${this.before}${marker}\x1b[7m${this.at}\x1b[27m${this.after}`];
}
invalidate(): void {}
}On focus the TUI sets focused = true, scans the output for CURSOR_MARKER (a
zero-width APC sequence), and parks the hardware cursor there — kept hidden
unless showHardwareCursor is enabled — so IME candidate windows anchor
correctly. Editor and Input already do this. A container holding an input must
itself be Focusable and forward focused to the child, or CJK candidate windows
land in the wrong place.
Colors and styling
Colors are values, not strings: parse, convert, and mix them before rendering.
import {
colorToRgb,
foregroundAnsi,
getTerminalColorMode,
mixColors,
parseColor,
rgbColor,
styleText,
} from "@crow-agent/tui";
const accent = parseColor("oklch(70% 0.12 220)");
const surface = parseColor("#20242a");
const ink = mixColors(accent, surface, 0.2);
const label = styleText("Ready", { fg: ink, bg: surface, bold: true }, getTerminalColorMode());A Color is an indexed ANSI color, an sRGB color, an OKLCH color, or an OKHSL
color. Everything converts to sRGB, so mixColors() always works; indices 0–15
follow the user's terminal palette and are therefore approximations.
styleText() chooses truecolor or 256-color sequences from the requested mode.
OKHSL deserves a callout: saturation is relative to the sRGB gamut ceiling at that
hue and lightness, so every OKHSL value is in-gamut and equal saturation reads
equally vivid across hues. parseColor("okhsl(250 60% 55%)") parses it,
okhslColor() builds it in code, and colorToOkhsl() reads any color's channels
back.
Conversions are not cached, and OKLCH colors outside the sRGB gamut are the priciest to convert. For a color used on every frame, convert once and reuse the cheap form:
const { r, g, b } = colorToRgb(mixColors(accent, surface, 0.2));
const ink = rgbColor(r, g, b);
const inkCode = foregroundAnsi(ink, getTerminalColorMode());The theme pipeline uses the OKHSL color method: userMessageBg and dim text are
derived in OKHSL space (perceptually even steps) rather than hand-picked hex
triples.
Inline images
Image renders where the terminal speaks the Kitty graphics protocol (Kitty,
Ghostty, WezTerm) or iTerm2 inline images, and degrades to a styled text
placeholder elsewhere. PNG, JPEG, GIF, and WebP are supported; dimensions are read
from the image headers.
const image = new Image(
base64Bytes,
"image/png",
{ fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` },
{ maxWidthCells: 60, filename: "screenshot.png" },
);In the alternate-screen viewport, Kitty images are cropped to the visible rows as
they scroll. iTerm2's protocol cannot delete or crop a placement, so alt-screen
renders those as text placeholders to avoid stale images lingering over repaints;
TuiMainScreen keeps rendering iTerm2 images normally.
Autocomplete
CombinedAutocompleteProvider serves slash commands and file paths to the
Editor:
import { CombinedAutocompleteProvider } from "@crow-agent/tui";
editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
[
{ name: "help", description: "Show help" },
{ name: "clear", description: "Clear screen" },
],
process.cwd(),
),
);Type / for commands, Tab for path completion. It understands ~/, ./,
../, and the @ prefix (which filters to attachable files).
Utilities
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@crow-agent/tui";
visibleWidth("\x1b[31mHello\x1b[0m"); // 5 — ANSI codes ignored
truncateToWidth("Hello World", 8); // "Hello..." — codes kept, closed properly
truncateToWidth("Hello World", 8, ""); // "Hello Wo" — no ellipsis
wrapTextWithAnsi("a fairly long line", 10); // ["a fairly", "long line"]stripTerminalSequences() removes ANSI/OSC noise for searching or measuring, and
findAltScreenSearchMatches() powers the transcript search panel.
Writing components
Keep render() honest: every line within width, styles re-applied per line,
caches dropped in invalidate().
import { matchesKey, Key, truncateToWidth, type Component } from "@crow-agent/tui";
class Picker implements Component {
private cursor = 0;
private options = ["Alpha", "Beta", "Gamma"];
onPick?: (index: number) => void;
onCancel?: () => void;
handleInput(data: string): void {
if (matchesKey(data, Key.up)) this.cursor = Math.max(0, this.cursor - 1);
else if (matchesKey(data, Key.down)) {
this.cursor = Math.min(this.options.length - 1, this.cursor + 1);
} else if (matchesKey(data, Key.enter)) this.onPick?.(this.cursor);
else if (matchesKey(data, Key.escape)) this.onCancel?.();
}
render(width: number): string[] {
return this.options.map((option, i) =>
truncateToWidth(`${i === this.cursor ? "> " : " "}${option}`, width),
);
}
invalidate(): void {}
}For expensive renders, memoize on width and invalidate on state changes:
class Cached implements Component {
private text = "";
private seenWidth?: number;
private seenLines?: string[];
render(width: number): string[] {
if (this.seenLines && this.seenWidth === width) return this.seenLines;
const lines = [truncateToWidth(this.text, width)];
this.seenWidth = width;
this.seenLines = lines;
return lines;
}
invalidate(): void {
this.seenWidth = undefined;
this.seenLines = undefined;
}
}Terminals
Anything implementing Terminal can drive a TUI:
interface Terminal {
start(onInput: (data: string) => void, onResize: () => void): void;
stop(): void;
write(data: string): void;
get columns(): number;
get rows(): number;
moveBy(lines: number): void;
hideCursor(): void;
showCursor(): void;
clearLine(): void;
clearFromCursor(): void;
clearScreen(): void;
}ProcessTerminal—process.stdin/process.stdout.VirtualTerminal— headless, backed by@xterm/headless; the test suite's workhorse.
Native clipboard
Clipboard access on macOS, Windows, and X11 goes through small native addons that
are prebuilt into native/<platform>/prebuilds/. To rebuild them:
npm --prefix packages/tui run build:native:darwin
npm --prefix packages/tui run build:native:linux
npm --prefix packages/tui run build:native:win32Each platform folder has its own README with toolchain details.
Debugging
# Capture the raw ANSI stream written to stdout
CROW_TUI_WRITE_LOG=/tmp/tui-ansi.log node tests/chat-simple.tsCROW_TUI_DEBUG and CROW_TUI_DEBUG_REDRAW add progressively noisier internal
logging.
Demos and development
test/chat-simple.ts is a complete chat demo (markdown messages, spinner,
autocompletion); test/image-test.ts <file.png> exercises inline images. Both
are manual smoke tests — run them directly:
node tests/chat-simple.ts
node tests/image-test.ts /tmp/screenshot.pngThe automated suite runs on node --test:
npm --prefix packages/tui test