zs3-tui
v1.0.1
Published
typescript terminal framework
Maintainers
Readme
zs3-tui
A TypeScript framework for building interactive Terminal User Interface (TUI) applications. It provides a component model, automatic layout management, mouse support, theming, and clipboard integration — all over raw terminal escape sequences with zero runtime dependencies.
______ _____ _____ _____ _ _ ___
|__ / |/ ___/ |___ / |_ _|| | | ||_ _|
/ / \__ \ |_ \ | | | | | | | |
/ /__ ___) | ___) | | | | |_| | | |
/____| |____/ |____/ |_| \___/ |___|Features
- Component model — TextArea, List, Toolbar, Question, TextBox, InputText, all with a shared base class for focus, mouse selection, and theme subscription
- Flex-box layout —
LayoutManagerdistributes terminal space between components withsize/percent/flexconstraints, exactly like CSS flexbox - Full mouse support — click, double-click, drag-select, scroll wheel, and hover events routed automatically to the right component
- 9 built-in themes — dark, light, dracula, solarized, contrast, cyberpunk, matrix, monokai, nord; switch at runtime and all components redraw instantly
- Streaming output —
TextArea.appendDelta()redraws only the affected row, making it efficient for LLM token streaming - Modal dialogs — any component with
modal: trueblocks all input and dims the background; closing it restores focus automatically - Clipboard integration — drag to select text in any component,
Ctrl+Cto copy; right-click to paste; works on Windows, macOS, and Linux - Zero runtime dependencies — pure TypeScript on Node.js built-ins
Table of contents
- Installation
- Quick start
- Terminal
- LayoutManager
- ComponentManager
- Components
- ThemeManager
- Colors
- Icons
- Utilities
- Example: Process Monitor
Installation
npm install zs3-tuiNode.js ≥ 22 LTS is required. On Windows, use Node.js v22 LTS — v23+ has a ConPTY bug that prevents mouse events from reaching the process.
Quick start
import {
terminal, hideCursor, LayoutManager,
TextArea, List, theme,
} from "zs3-tui";
// Enter full-screen mode and enable mouse
terminal.start();
terminal.clear(theme.current.bgScreen);
terminal.mouse(true);
hideCursor();
// Create components
const log = new TextArea({ name: "log" });
const list = new List({ name: "items", selectionMode: "single" });
list.addLine("Item A");
list.addLine("Item B");
list.onClick = (index) => log.addLine(`Clicked item ${index}`);
// Lay them out side by side, each taking half the terminal
const root = new LayoutManager({ direction: "row" });
root.add(list, { flex: 1 });
root.add(log, { flex: 1 });
terminal.addResizeListener(size => root.resize(size));
root.resize(terminal.getSize());
// Main event loop
while (true) {
const ev = await terminal.waitForKey();
if (ev.key === "Escape") {
terminal.dispose();
process.exit(0);
}
}All components and utilities are exported from the single "zs3-tui" entry point.
Terminal
The Terminal class is the low-level layer: it manages stdin/stdout, dispatches keyboard and mouse events, and exposes helpers for writing colored text at absolute positions.
A pre-built singleton is exported as terminal.
import { terminal } from "zs3-tui";Lifecycle
| Method | Description |
|---|---|
| start() | Enters the alternate screen buffer and starts the stdin listener. |
| dispose() | Restores the previous screen, disables mouse, releases stdin. |
| clear(bgColor?) | Clears the screen. Pass a background color to fill it. |
| mouse(enabled) | Enables (true) or disables (false) SGR mouse reporting. |
| hideCursor() | Hides the terminal cursor. Also exported as a standalone helper. |
| showCursor() | Shows the terminal cursor. Also exported as a standalone helper. |
Writing
| Method / property | Description |
|---|---|
| write(text, pos?, colors?) | Writes text at absolute position {x, y} with optional fgColor/bgColor. |
| position(pos) | Moves the cursor to {x, y} without writing. |
| bgColor | Get/set the default background color applied to subsequent write() calls. |
| fgColor | Get/set the default foreground color. |
| setColorTransform(fn) | Applies a per-channel RGB transform to every write(). Pass null to remove. Useful for dim overlays. |
Size
const { width, height } = terminal.getSize();Events
// Subscribe to keyboard events
terminal.addKeyListener((ev: KeyEvent) => { /* ... */ });
terminal.removeKeyListener(handler);
// Subscribe to mouse events
terminal.addMouseListener((ev: MouseEvent) => { /* ... */ });
terminal.removeMouseListener(handler);
// Subscribe to resize events
terminal.addResizeListener((size: TerminalSize) => { /* ... */ });
terminal.removeResizeListener(handler);
// Subscribe to paste events
terminal.addPasteListener((text: string) => { /* ... */ });
terminal.removePasteListener(handler);
// Async helper — resolves on the next keypress
const ev = await terminal.waitForKey();KeyEvent
interface KeyEvent {
key: string; // Logical name: 'a', 'A', 'ArrowUp', 'Enter', 'Escape', 'F1' …
raw: string; // Raw bytes received from stdin
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean; // Always false (not detectable in a terminal)
}MouseEvent
interface MouseEvent {
x: number; // Absolute terminal column (0-indexed)
y: number; // Absolute terminal row (0-indexed)
button: 'left' | 'right' | 'middle' | 'none';
type: 'move' | 'down' | 'up' | 'scroll-up' | 'scroll-down';
drag: boolean; // true when button is held during a move
}LayoutManager
LayoutManager divides a rectangular area among its children using a flex-box–inspired algorithm. Children can be Component instances or nested LayoutManager instances.
import { LayoutManager } from "zs3-tui";
const root = new LayoutManager({ direction: 'column' }); // stack vertically
const content = new LayoutManager({ direction: 'row' }); // split horizontally
root.add(toolbar, { size: 5 }); // fixed 5 rows
root.add(content, { flex: 1 }); // takes remaining rows
content.add(list, { flex: 1 }); // half of the row width
content.add(area, { flex: 1 }); // other half
terminal.addResizeListener(size => root.resize(size));
root.resize(terminal.getSize());Constructor
new LayoutManager({ direction: 'row' | 'column' })LayoutConstraints
Each child is added with a LayoutConstraints object that controls how space is distributed:
| Property | Type | Description |
|---|---|---|
| size | number | Fixed size in the main axis (rows for column, columns for row). |
| percent | number | Percentage of the parent's main-axis size (0–100). |
| flex | number | Proportion of remaining space after fixed/percent children are placed. Defaults to 1 when neither size nor percent is given. |
| min | number | Minimum size (only valid for flex children). |
| max | number | Maximum size (only valid for flex children). |
Only one of size, percent, or flex should be set per child.
Public methods
| Method | Description |
|---|---|
| add(child, constraints) | Adds a Component or nested LayoutManager with the given constraints. |
| remove(child) | Removes a child and disposes it (or all nested components). |
| setSize(child, size) | Changes the size constraint of a child and recomputes the layout. Useful for dynamically growing components such as InputText. |
| setVisible(child, visible) | Shows or hides a child. Hidden children do not consume space. The area they occupied is erased. |
| resize(terminalSize) | Recomputes the layout to fill the full terminal size. Call this from a terminal.addResizeListener handler. |
| compute(x, y, width, height) | Recomputes the layout within an arbitrary rectangle. resize() calls this with (0, 0, width, height). |
| invalidate() | Re-runs compute() with the last known bounds (e.g. after setSize or setVisible). |
| getBounds(child) | Returns the last computed { x, y, width, height } for a child, or null. |
ComponentManager
ComponentManager is a singleton that owns the registry of all active components and routes terminal events to the correct one. It is instantiated automatically — you do not need to use it directly in most cases.
Event routing rules
- Keyboard / paste events → focused component only.
- Mouse events → component under the cursor (hit test). Coordinates are converted to component-local before dispatch.
- Resize events → every registered component.
- When a modal component is active, all input is restricted to it. Background components are re-rendered with a dark color transform so the modal appears clearly in front.
Public methods
| Method | Description |
|---|---|
| add(name, component) | Registers a component. If component.options.modal === true, activates modal mode. |
| remove(name) | Unregisters a component by name. Deactivates modal mode if it was the active modal. |
| get(name) | Returns the component registered under name, or undefined. |
| getAll() | Returns the full Map<string, Component> registry. |
| setFocus(name) | Transfers keyboard focus to the named component programmatically. |
| renderAll() | Re-renders every registered component. |
| renderIntersecting(position, size) | Re-renders every component whose bounding box intersects the given rectangle. |
Note: When you use
LayoutManager, components are registered and unregistered automatically. When you create components directly (without a layout manager), they self-register in their constructor.
Components
All components share a common ComponentOptions base type and inherit from the abstract Component class.
ComponentOptions
type ComponentOptions = {
box?: { left: boolean; right: boolean; top: boolean; bottom: boolean };
boxColor?: string; // ANSI color for the border
position?: { x: number; y: number };
size?: { width: number; height: number };
bgColor?: string;
fgColor?: string;
name?: string; // Unique name for the componentManager registry
selectable?: boolean; // Whether mouse text-selection is enabled. Default: true
modal?: boolean; // When true, blocks all input to other components
}Component (base class)
Abstract base class that all components extend. It provides:
- Theme subscription — components re-render automatically when the active theme changes.
- Mouse text selection — drag to highlight,
Ctrl+Cto copy to clipboard. Works in any component that overridesgetLines(). - Right-click paste — right-clicking reads the system clipboard and calls
handlePaste(). - Event callbacks — assignable properties fired by the component manager.
dispose()— erases the component's footprint and removes it from the registry.
Common event callbacks
All callbacks default to null and can be assigned at any time:
| Callback | Signature | Description |
|---|---|---|
| onKeyDown | (ev: KeyEvent) => void | Fired on each keypress while focused. |
| onMouseMove | (ev: MouseEvent) => void | Fired on mouse-move within the component. Coordinates are component-local. |
| onMouseDown | (ev: MouseEvent) => void | Fired on mouse button press. |
| onMouseUp | (ev: MouseEvent) => void | Fired on mouse button release. |
| onScroll | (ev: MouseEvent) => void | Fired on scroll-wheel events. |
| onMouseIn | () => void | Fired when the cursor enters the component area. |
| onMouseOut | () => void | Fired when the cursor leaves the component area. |
| onFocus | () => void | Fired when the component gains keyboard focus. |
| onBlur | () => void | Fired when the component loses keyboard focus. |
| onResize | (size: TerminalSize) => void | Fired when the terminal is resized. |
| onPaste | (text: string) => void | Fired after text is pasted into the component. |
Common methods
| Method | Description |
|---|---|
| dispose() | Erases the component area, unsubscribes from themes, and removes from the component registry. |
| render() | Redraws the component: content first, then the selection overlay if a drag-selection is active. |
| renderContent() | Abstract. Subclasses override this to draw their specific content. |
TextArea
A read-only, scrollable log area that displays colored lines. Supports streaming output via a live-buffer API.
import { TextArea } from "zs3-tui";
const area = new TextArea({ name: "log" });
area.addLine("Hello world", { fgColor: "\x1b[32m" }); // green textTextAreaOptions
Extends ComponentOptions with:
| Property | Type | Default | Description |
|---|---|---|---|
| scrollBarColor | string | theme scrollBarColor | ANSI color for the scroll bar. |
Public methods
| Method | Description |
|---|---|
| addLine(line, colors?) | Appends a line (splits on \n) and re-renders. |
| addLines(lines, colors?) | Appends multiple lines in one call and re-renders. |
| clear() | Removes all content and re-renders. |
| scrollTo(position?) | Scrolls to 'top' or 'bottom' (default: 'bottom'). |
| write(text, offset?, colors?) | Writes text at a local offset, bypassing the content array (direct terminal write). |
| appendDelta(text, colors?) | Streaming API: appends a text fragment to the live buffer. If text contains \n, completed lines are flushed to content[]. Only the affected screen row is redrawn when there is no newline — efficient for AI/LLM token streaming. |
| commitDelta() | Flushes the live buffer to content[] as a permanent line and ends streaming. |
| clearDelta() | Discards the live buffer without committing it (e.g. on aborted generation). |
Key bindings
| Key | Action |
|---|---|
| ↑ / ↓ | Scroll one line. |
| PageUp / PageDown | Scroll one page. |
| Drag | Select text. |
| Ctrl+C | Copy selection to clipboard. |
| Escape | Clear selection. |
List
A scrollable, interactive list with single or multiple selection. Built on top of TextArea.
import { List } from "zs3-tui";
const list = new List({ name: "items", selectionMode: "single" });
list.addLine("Item A");
list.addLine("Item B");
list.onClick = (index) => console.log(`Selected: ${index}`);
list.onDblClick = (index) => console.log(`Confirmed: ${index}`);ListOptions
Extends TextAreaOptions with:
| Property | Type | Default | Description |
|---|---|---|---|
| selectionMode | "single" \| "multiple" | "single" | Whether one or many items can be selected at once. |
| onClick | (index: number) => void | — | Called after a single-click selection change. |
| onDblClick | (index: number) => void | — | Called on double-click or Enter. |
Public methods
| Method | Description |
|---|---|
| addLine(line, colors?) | Appends an item to the list. (Inherited from TextArea.) |
| addLines(lines, colors?) | Appends multiple items. (Inherited from TextArea.) |
| clear() | Removes all items. (Inherited from TextArea.) |
| getSelected() | Returns the selected index (number \| null) in single mode, or a sorted number[] in multiple mode. |
| set onClick(fn) | Assigns the click handler (alternative to passing it in options). |
| set onDblClick(fn) | Assigns the double-click handler. |
Key bindings
| Key | Action |
|---|---|
| ↑ / ↓ | Move keyboard cursor. |
| PageUp / PageDown | Move cursor one page. |
| Space | Toggle selection on the cursor row. |
| Enter | Fire onDblClick on the cursor row. |
Toolbar
A bar of clickable buttons arranged horizontally or vertically. The toolbar grows automatically as buttons are added.
import { Toolbar, icon } from "zs3-tui";
const toolbar = new Toolbar({ direction: "horizontal", gap: 0, name: "main" });
toolbar
.addButton({ text: `${icon("power")} Exit`, name: "exit", action: () => exitProgram() })
.addButton({ text: `${icon("refresh")} Reload`, name: "refresh", action: () => refresh() });ToolbarOptions
Extends ComponentOptions with:
| Property | Type | Default | Description |
|---|---|---|---|
| direction | "horizontal" \| "vertical" | "horizontal" | Layout direction of the buttons. |
| gap | number | 1 | Empty cells between buttons and between buttons and the toolbar border. |
ButtonDescription
interface ButtonDescription {
text: string; // Button label (may include icon strings)
name: string; // Unique name within this toolbar
action: () => void; // Called when the button is clicked
}Public methods
| Method | Description |
|---|---|
| addButton(button) | Appends a button and resizes the toolbar to fit. Returns this for chaining. |
Question
A self-sizing form component with a question, a list of choices, and an optional free-text entry field.
import { Question, type QuestionResult, center } from "zs3-tui";
const size = { width: 44, height: 0 }; // height: 0 → computed automatically
const q = new Question({
position: center(size),
size,
question: "What is your preference?",
choices: ["Option A", "Option B", "Option C"],
selectionMode: "multiple",
allowFreeText: true,
freeTextLabel: "Other",
modal: true, // blocks input to all other components
});
q.onSubmit = (result: QuestionResult) => {
console.log(result.selected); // sorted array of selected indices
console.log(result.freeText); // contents of the free-text field
q.dispose();
};
q.onChange = (result) => { /* called on every change */ };QuestionOptions
Extends ComponentOptions with:
| Property | Type | Default | Description |
|---|---|---|---|
| question | string | required | The question text. Supports word-wrap and \n. |
| choices | string[] | required | The answer choices. |
| selectionMode | "single" \| "multiple" | "single" | Single or multi-choice. |
| allowFreeText | boolean | false | Adds a free-text input row after the choices. |
| freeTextLabel | string | "Altre" | Label shown before the free-text field. |
| scrollBarColor | string | theme | ANSI color for the scroll bar. |
When
size.heightis0, the height is computed automatically from the question text and number of choices.
QuestionResult
type QuestionResult = {
selected: number[]; // Sorted indices of selected choices
freeText: string; // Contents of the free-text field
freeTextSelected: boolean; // Whether the free-text option is active
}Public methods
| Method | Description |
|---|---|
| getResult() | Returns a QuestionResult snapshot of the current state. |
| dispose() | Closes the question and restores focus to the previously focused component. |
Callbacks
| Callback | Description |
|---|---|
| onSubmit | Fired when the user confirms (Enter outside free-text mode). |
| onChange | Fired on every selection or text change. |
Key bindings
Navigation mode:
| Key | Action |
|---|---|
| ↑ / ↓ | Move cursor between choices. |
| Space | Toggle selection. |
| Enter | Confirm (fires onSubmit). On the free-text row, enters text-editing mode. |
| Any printable char on the free-text row | Activates free-text mode and inserts the character. |
Free-text editing mode:
| Key | Action |
|---|---|
| ← / → | Move cursor within the field. |
| Ctrl+← / Ctrl+→ | Jump word by word. |
| Home / End | Beginning / end of the field. |
| Backspace / Delete | Erase a character. |
| ↑ / Escape | Exit free-text mode (keeps text). |
| Enter | Confirm and fire onSubmit. |
TextBox
A fixed-grid widget for placing characters at arbitrary local offsets. Content is stored in a sparse internal grid and replayed on every re-render.
import { TextBox } from "zs3-tui";
const box = new TextBox({
position: { x: 10, y: 5 },
size: { width: 24, height: 5 },
});
box.write("Hello, world!", { x: 2, y: 2 }, { fgColor: "\x1b[33m" });
TextBoxis useful for static overlays and toast notifications. Thewrite()offset is 1-indexed from the inside of the border:{x:1, y:1}is the top-left content cell.
Public methods
| Method | Description |
|---|---|
| write(text, offset?, colors?) | Writes text at offset (local, 1-indexed from inside the border) and stores it in the grid for re-renders. |
| clear() | Erases all written cells and re-renders. |
| dispose() | Erases the box and removes it from the registry. (Inherited from Component.) |
InputText
A multi-line text input with automatic word-wrap, scrolling, and a block cursor. The component grows vertically up to maxLines rows and then shows a scrollbar.
import { InputText } from "zs3-tui";
const input = new InputText({
name: "input",
maxLines: 4,
placeholder: "Type here… (Enter = submit, Alt+Enter = new line)",
});
input.onSubmit = (text) => sendMessage(text);
input.onTextChange = (text) => updateCharCount(text);
input.onHeightChange = (h) => layout.setSize(input, h); // update layout when the field growsInputTextOptions
Extends ComponentOptions with:
| Property | Type | Default | Description |
|---|---|---|---|
| maxLines | number | 4 | Maximum visual rows before the field scrolls. |
| placeholder | string | "" | Muted text shown when the field is empty and unfocused. |
| scrollBarColor | string | theme | ANSI color for the scroll bar. |
Public methods and properties
| Member | Description |
|---|---|
| value (get) | Returns the current text content. |
| value (set) | Replaces the text programmatically. Cursor is clamped to the new length. |
| clearText() | Clears the text, resets cursor and scroll, then re-renders. |
Callbacks
| Callback | Signature | Description |
|---|---|---|
| onSubmit | (text: string) => void | Fired when the user presses Enter. |
| onTextChange | (text: string) => void | Fired on every content change (typing, paste, delete). |
| onHeightChange | (newHeight: number) => void | Fired when the component's total height changes. Use this to call layout.setSize(input, h) so the layout adjusts. |
Key bindings
| Key | Action |
|---|---|
| ← / → | Move cursor by one character. |
| Ctrl+← / Ctrl+→ | Jump word by word. |
| ↑ / ↓ | Move cursor to the line above/below. |
| Home / End | Beginning / end of the current visual line. |
| Ctrl+Home / Ctrl+End | Beginning / end of the entire text. |
| Backspace / Delete | Erase one character. |
| Alt+Enter | Insert a literal newline. |
| Enter | Fire onSubmit. |
ThemeManager
Manages a set of named color themes and notifies components when the active theme changes. Components automatically re-render when the theme switches.
import { theme } from "zs3-tui";
// Switch to a different built-in theme
theme.selected = "dracula"; // dark | light | dracula | solarized | contrast | cyberpunk | matrix | monokai | nord
// Subscribe to theme changes (e.g. to clear the screen between renders)
const unsub = theme.subscribe(() => terminal.clear(theme.current.bgScreen));
// Call unsub() to stop receiving notifications.
// Read current colors
const { fgColor, bgColor, borderColor, focusColor, scrollBarColor, bgScreen } = theme.current;Public API
| Member | Description |
|---|---|
| selected (get) | Name of the currently active theme (ThemeName). |
| selected (set) | Switches the active theme and notifies all subscribers. |
| current (get) | Returns the resolved Theme object for the active theme. |
| getTheme(name) | Returns the resolved Theme for any built-in theme by name. |
| themes (get) | Returns all themes as Record<ThemeName, Theme>. |
| subscribe(cb) | Registers a callback; returns an unsubscribe function. |
Built-in themes
| Name | Style |
|---|---|
| dark | Dark background, cyan accent — default |
| light | Light background, Windows blue accent |
| dracula | Dracula palette with pink focus |
| solarized | Solarized dark palette |
| contrast | Pure black/white, yellow focus |
| cyberpunk | Black/purple background, neon magenta focus |
| matrix | Black/dark-green background, bright green focus |
| monokai | Monokai palette, green focus |
| nord | Nord palette, ice-blue focus |
Colors
The colors module exports ANSI escape-sequence constants and helpers for all color needs.
import { red, green, blue, bold, fg, bg, fg256, bg256 } from "zs3-tui";Standard 16-color constants
Foreground: black red green yellow blue magenta cyan white
Bright foreground: brightBlack brightRed brightGreen brightYellow brightBlue brightMagenta brightCyan brightWhite
Background: bgBlack bgRed bgGreen bgYellow bgBlue bgMagenta bgCyan bgWhite
Bright background: bgBrightBlack bgBrightRed bgBrightGreen bgBrightYellow bgBrightBlue bgBrightMagenta bgBrightCyan bgBrightWhite
Text styles: bold dim italic underline blink inverse hidden strikethrough reset
True-color and 256-color helpers
fg(r, g, b) // foreground RGB — e.g. fg(255, 128, 0) for orange
bg(r, g, b) // background RGB
fg256(n) // foreground 256-color palette index (0–255)
bg256(n) // background 256-color palette index (0–255)Named palette constants (foreground)
The module also exports named true-color constants that match the built-in themes:
Greys: snow offWhite silver pearl lightGray ashGray mediumGray gray slateGray darkGray dimGray charcoal almostBlack
Reds/pinks: crimson tomato coral salmon hotPink deepPink draculaPink orchid
Oranges/yellows: orange amber gold yellow255 lime
Greens: matrixGreen matrixGreenLight monokaiGreen emerald forestGreen mintGreen
Cyans: neonCyan cyanAccent iceBlue aqua turquoise skyBlue
Blues: windowsBlue solarizedBlue dodgerBlue cornflowerBlue royalBlue steelBlue purpleBlue nordSlate
Purples: neonMagenta violet deepViolet purple indigo lavender
Named palette constants (background)
bgSnow bgLightGray bgDark bgDarker bgBlackTrue bgDracula bgDraculaDark bgMatrix bgMatrixDark bgNord bgNordDark bgSolarized bgSolarizedDark bgMonokai bgMonokaiDark bgCyberpunk bgCyberpunkDark
Color conversion helpers
import { fgToBg, bgToFg } from "zs3-tui";
fgToBg(color) // converts a foreground ANSI sequence to its background equivalent
bgToFg(color) // converts a background ANSI sequence to its foreground equivalentIcons
The icon() function returns a Unicode character by logical name, making it easy to use symbols in button labels, list items, or any other text.
import { icon } from "zs3-tui";
toolbar.addButton({ text: `${icon("power")} Exit`, name: "exit", action: exit });Available icons
| Name | Char | Use |
|---|---|---|
| power | ⏻ | Exit / power off |
| settings | ⚙ | Settings / configuration |
| search | ⌕ | Search / filter |
| edit | ✎ | Edit / rename |
| save | ⊟ | Save to disk |
| refresh | ↺ | Reload / retry |
| close | ✕ | Close / discard |
| ok | ✓ | Success / done |
| error | ✗ | Error / invalid |
| warning | ⚠ | Warning |
| info | ℹ | Info |
| arrow-up | ▲ | Move / scroll up |
| arrow-down | ▼ | Move / scroll down |
| arrow-left | ◀ | Back / previous |
| arrow-right | ▶ | Forward / next |
| expand | ⊞ | Open / maximize |
| collapse | ⊟ | Close / minimize |
| panel-off | ▭ | Panel hidden |
| panel-on | ▬ | Panel visible |
| folder | ▤ | Folder / directory |
| file | ▢ | Regular file |
| dark | ▓ | Dark theme indicator |
| light | ░ | Light theme indicator |
| bullet | ● | List bullet |
| star | ★ | Favorite / pinned |
| dot | · | Separator / muted marker |
| circle | ○ | Empty state / inactive |
Utilities
center(size, relativeTo?)
Calculates the position that centers a component in the terminal (or in a given container).
import { center } from "zs3-tui";
const size = { width: 44, height: 10 };
const pos = center(size); // centered in the terminal
const pos2 = center(size, { width: 80, height: 24 }); // centered in a given box
// → { x: number, y: number }copyToClipboard(text)
Copies text to the system clipboard synchronously. Uses clip on Windows, pbcopy on macOS, and xclip on Linux.
import { copyToClipboard } from "zs3-tui";
copyToClipboard("text to copy");readClipboard()
Reads text from the system clipboard asynchronously.
import { readClipboard } from "zs3-tui";
const text = await readClipboard();randomId(length?)
Generates a random alphanumeric string. Defaults to 16 characters.
import { randomId } from "zs3-tui";
const id = randomId(); // e.g. "aB3kX9mPqZ1rWnYt"
const id6 = randomId(6); // e.g. "aB3kX9"Example: Process Monitor
The following example is a complete process monitor application. It demonstrates LayoutManager, TextArea, List, Toolbar, TextBox, and Question working together.
processes.ts
This module provides cross-platform helpers for reading the system process list.
import { execSync } from "child_process";
// A minimal process descriptor: name and PID
export interface Process {
name: string;
pid: number;
}
// Detailed process information fetched on demand when a process is selected
export interface ProcessInfo {
name: string;
pid: number;
ppid: number | null; // Parent PID
user: string | null;
status: string | null;
priority: string | null;
cpu: number | null; // CPU time in seconds
memoryRss: number | null; // Resident set size in bytes
memoryVirtual: number | null; // Virtual memory size in bytes
threadCount: number | null;
handleCount: number | null;
startTime: string | null;
executablePath: string | null;
commandLine: string | null;
}
// Returns the list of running processes using tasklist (Windows) or ps (Unix/macOS)
export function getProcesses(): Process[] {
if (process.platform === "win32") {
const output = execSync("tasklist /fo csv /nh", { encoding: "utf8" });
return output
.trim()
.split("\n")
.map((line) => {
const parts = line.split(",").map((p) => p.replace(/"/g, "").trim());
return { name: parts[0], pid: parseInt(parts[1], 10) };
})
.filter((p) => p.name && !isNaN(p.pid));
} else {
const output = execSync("ps -e -o pid=,comm=", { encoding: "utf8" });
return output
.trim()
.split("\n")
.map((line) => {
const [pid, ...nameParts] = line.trim().split(/\s+/);
return { name: nameParts.join(" "), pid: parseInt(pid, 10) };
})
.filter((p) => p.name && !isNaN(p.pid));
}
}
// Returns detailed info for a single PID, or null if the process no longer exists
export function getProcessInfo(pid: number): ProcessInfo | null {
try {
return process.platform === "win32"
? getProcessInfoWindows(pid)
: getProcessInfoUnix(pid);
} catch {
return null;
}
}index.ts
import {
terminal, hideCursor, LayoutManager,
TextArea, List, Toolbar, theme,
Question, type QuestionResult,
icon, TextBox, center,
} from "zs3-tui";
import { getProcesses, getProcessInfo, type Process } from "./processes.js";
// ─── Exit confirmation ────────────────────────────────────────────────────────
const exitProgram = () => {
// Compute the size without a height so the Question auto-sizes itself
const questionSize = { width: 44, height: 0 };
const question = new Question({
// center() returns {x, y} that places the component in the middle of the screen
position: center(questionSize),
size: questionSize,
question: "Are you sure you want to exit?",
choices: ["Yes", "No"],
selectionMode: "single",
allowFreeText: false,
modal: true, // blocks all input to background components while open
});
// onSubmit fires when the user presses Enter on a choice
question.onSubmit = (_result: QuestionResult) => {
if (_result.selected[0] === 0) {
// "Yes" was chosen: restore the terminal and exit
terminal.dispose();
process.exit(0);
} else {
// "No": just close the dialog and return to the app
question.dispose();
}
};
// Allow the user to close the dialog with Escape as well
question.onKeyDown = (ev) => {
if (ev.key === "Escape") question.dispose();
};
};
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
// Enter alternate screen buffer, enable mouse, hide cursor
terminal.start();
terminal.clear(theme.current.bgScreen);
terminal.mouse(true);
hideCursor();
// Re-clear the screen whenever the theme changes so colors are consistent
theme.subscribe(() => terminal.clear(theme.current.bgScreen));
// Create components
const detailsArea = new TextArea({ name: "details" });
const list = new List({ name: "processesList", selectionMode: "single" });
// Populate with a loading toast
const toastText = "loading...";
const toastSize = { width: toastText.length + 4, height: 3 };
const toast = new TextBox({ position: center(toastSize), size: toastSize });
toast.write(toastText, { x: 2, y: 1 });
const processes = getProcesses();
processes.forEach((proc) => list.addLine(`${proc.pid} - ${proc.name}`));
toast.dispose();
list.onClick = (index) => {
const proc = processes[index];
const procInfo = getProcessInfo(proc.pid);
if (procInfo) {
detailsArea.clear();
detailsArea.addLine(`Name: ${procInfo.name}`);
detailsArea.addLine(`PID: ${procInfo.pid}`);
}
};
const toolbar = new Toolbar({ gap: 0, direction: "horizontal", name: "actionsToolbar" });
toolbar
.addButton({ text: `${icon("power")} Exit`, name: "exit", action: () => exitProgram() })
.addButton({ text: `${icon("refresh")} Reload`, name: "refresh", action: () => {
list.clear();
getProcesses().forEach(p => list.addLine(`${p.pid} - ${p.name}`));
}});
// ─── Layout ──────────────────────────────────────────────────────────────
//
// root (column)
// ├── toolbar (size: 5) — fixed 5 rows at the top
// └── content (row, flex: 1) — remaining vertical space
// ├── list (flex: 1) — left half: process list
// └── detailsArea (flex: 1) — right half: process details
//
const root = new LayoutManager({ direction: "column" });
const content = new LayoutManager({ direction: "row" });
root.add(toolbar, { size: 5 });
root.add(content, { flex: 1 });
content.add(list, { flex: 1 });
content.add(detailsArea, { flex: 1 });
terminal.addResizeListener(size => root.resize(size));
root.resize(terminal.getSize());
// ─── Event loop ───────────────────────────────────────────────────────────
while (true) {
const ev = await terminal.waitForKey();
if (ev.key === "Escape") {
exitProgram();
break;
}
}
}
main().catch((error) => console.error(String(error)));License
MIT
