@jawcode-dev/tui
Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.
Features
- Differential Rendering: Three-strategy rendering system that only updates what changed
- Synchronized Output: Uses CSI 2026 for atomic screen updates (no flicker)
- Bracketed Paste Mode: Handles large pastes correctly with markers for >10 line pastes
- Component-based: Simple Component interface with render() method
- Theme Support: Components accept theme interfaces for customizable styling (the coding-agent bundles a red-claw crustacean theme built on these interfaces)
- Built-in Components: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
- Inline Images: Renders images in terminals that support Kitty or iTerm2 graphics protocols
- Autocomplete Support: File paths and slash commands
Quick Start
import { TUI, Text, Editor, ProcessTerminal } from "@jawcode-dev/tui";
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
tui.addChild(new Text("Welcome to my app!"));
const editor = new Editor(editorTheme);
editor.onSubmit = (text) => {
console.log("Submitted:", text);
tui.addChild(new Text(`You said: ${text}`));
};
tui.addChild(editor);
tui.start();
Core API
TUI
Main container that manages components and rendering.
const tui = new TUI(terminal);
tui.addChild(component);
tui.removeChild(component);
tui.start();
tui.stop();
tui.requestRender();
tui.onDebug = () => console.log("Debug triggered");
Component Interface
All components implement:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
invalidate?(): void;
}
render(width) | Returns an array of strings, one per line. Each line must not exceed width or the TUI will error. Use truncateToWidth() or manual wrapping to ensure this. |
handleInput?(data) | Called when the component has focus and receives keyboard input. The data string contains raw terminal input (may include ANSI escape sequences). |
invalidate?() | Called to clear any cached render state. Components should re-render from scratch on the next render() call. |
Built-in Components
Container
Groups child components.
const container = new Container();
container.addChild(component);
container.removeChild(component);
Box
Container that applies padding and background color to all children.
const box = new Box(
1,
1,
(text) => chalk.bgGray(text),
);
box.addChild(new Text("Content"));
box.setBgFn((text) => chalk.bgBlue(text));
Text
Displays multi-line text with word wrapping and padding.
const text = new Text(
"Hello World",
1,
1,
(text) => chalk.bgGray(text),
);
text.setText("Updated text");
text.setCustomBgFn((text) => chalk.bgBlue(text));
TruncatedText
Single-line text that truncates to fit viewport width. Useful for status lines and headers.
const truncated = new TruncatedText(
"This is a very long line that will be truncated...",
0,
0,
);
Input
Single-line text input with horizontal scrolling.
const input = new Input();
input.onSubmit = (value) => console.log(value);
input.setValue("initial");
input.getValue();
Key Bindings:
Enter - Submit
Ctrl+A / Ctrl+E - Line start/end
Ctrl+W or Alt+Backspace - Delete word backwards
Ctrl+U - Delete to start of line
Ctrl+K - Delete to end of line
Ctrl+Left / Ctrl+Right - Word navigation
Alt+Left / Alt+Right - Word navigation
- Arrow keys, Backspace, Delete work as expected
Editor
Multi-line text editor with autocomplete, file completion, and paste handling.
interface SymbolTheme {
cursor: string;
ellipsis: string;
boxRound: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
horizontal: string;
vertical: string;
};
boxSharp: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
horizontal: string;
vertical: string;
teeDown: string;
teeUp: string;
teeLeft: string;
teeRight: string;
cross: string;
};
table: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
horizontal: string;
vertical: string;
teeDown: string;
teeUp: string;
teeLeft: string;
teeRight: string;
cross: string;
};
quoteBorder: string;
hrChar: string;
spinnerFrames: string[];
}
interface EditorTheme {
borderColor: (str: string) => string;
selectList: SelectListTheme;
symbols: SymbolTheme;
}
const editor = new Editor(theme);
editor.onSubmit = (text) => console.log(text);
editor.onChange = (text) => console.log("Changed:", text);
editor.disableSubmit = true;
editor.setAutocompleteProvider(provider);
editor.borderColor = (s) => chalk.blue(s);
Features:
- Multi-line editing with word wrap
- Slash command autocomplete (type
/)
- File path autocomplete (press
Tab)
- Large paste handling (>10 lines creates
[paste #1 +50 lines] marker)
- Horizontal lines above/below editor
- Fake cursor rendering (hidden real cursor)
Key Bindings:
Enter - Submit
Shift+Enter, Ctrl+Enter, or Alt+Enter - New line (terminal-dependent, Alt+Enter most reliable)
Tab - Autocomplete
Ctrl+K - Delete line
Alt+D / Alt+Delete - Delete word forward
Ctrl+A / Ctrl+E - Line start/end
Ctrl+- - Undo last edit
- Arrow keys, Backspace, Delete work as expected
Markdown
Renders markdown with syntax highlighting and theming support.
interface MarkdownTheme {
heading: (text: string) => string;
link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string;
codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string;
quote: (text: string) => string;
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
bold: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
symbols: SymbolTheme;
}
interface DefaultTextStyle {
color?: (text: string) => string;
bgColor?: (text: string) => string;
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
underline?: boolean;
}
const md = new Markdown(
"# Hello\n\nSome **bold** text",
1,
1,
theme,
defaultStyle,
2,
);
md.setText("Updated markdown");
Features:
- Headings, bold, italic, code blocks, lists, links, blockquotes
- HTML tags rendered as plain text
- Optional syntax highlighting via
highlightCode
- Padding support
- Render caching for performance
Loader
Animated loading spinner.
const loader = new Loader(
tui,
(s) => chalk.cyan(s),
(s) => chalk.gray(s),
"Loading...",
);
loader.start();
loader.setMessage("Still loading...");
loader.stop();
CancellableLoader
Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
const loader = new CancellableLoader(
tui,
(s) => chalk.cyan(s),
(s) => chalk.gray(s),
"Working...",
);
loader.onAbort = () => done(null);
doAsyncWork(loader.signal).then(done);
Properties:
signal: AbortSignal - Aborted when user presses Escape
aborted: boolean - Whether the loader was aborted
onAbort?: () => void - Callback when user presses Escape
SelectList
Interactive selection list with keyboard navigation.
interface SelectItem {
value: string;
label: string;
description?: string;
}
interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
symbols: SymbolTheme;
}
const list = new SelectList(
[
{ value: "opt1", label: "Option 1", description: "First option" },
{ value: "opt2", label: "Option 2", description: "Second option" },
],
5,
theme,
);
list.onSelect = (item) => console.log("Selected:", item);
list.onCancel = () => console.log("Cancelled");
list.onSelectionChange = (item) => console.log("Highlighted:", item);
list.setFilter("opt");
Controls:
- Arrow keys: Navigate
- Enter: Select
- Escape: Cancel
SettingsList
Settings panel with value cycling and submenus.
interface SettingItem {
id: string;
label: string;
description?: string;
currentValue: string;
values?: string[];
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
}
interface SettingsListTheme {
label: (text: string, selected: boolean) => string;
value: (text: string, selected: boolean) => string;
description: (text: string) => string;
cursor: string;
hint: (text: string) => string;
}
const settings = new SettingsList(
[
{ id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
{ id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
],
10,
theme,
(id, newValue) => console.log(`${id} changed to ${newValue}`),
() => console.log("Cancelled"),
);
settings.updateValue("theme", "light");
Controls:
- Arrow keys: Navigate
- Enter/Space: Activate (cycle value or open submenu)
- Escape: Cancel
Spacer
Empty lines for vertical spacing.
const spacer = new Spacer(2);
Image
Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
interface ImageTheme {
fallbackColor: (str: string) => string;
}
interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
}
const image = new Image(
base64Data,
"image/png",
theme,
options,
);
tui.addChild(image);
Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
Autocomplete
CombinedAutocompleteProvider
Supports both slash commands and file paths.
import { CombinedAutocompleteProvider } from "@jawcode-dev/tui";
import { getProjectDir } from "@jawcode-dev/utils";
const provider = new CombinedAutocompleteProvider(
[
{ name: "help", description: "Show help" },
{ name: "clear", description: "Clear screen" },
{ name: "delete", description: "Delete last message" },
],
getProjectDir(),
);
editor.setAutocompleteProvider(provider);
Features:
- Type
/ to see slash commands
- Press
Tab for file path completion
- Works with
~/, ./, ../, and @ prefix
- Filters to attachable files for
@ prefix
Key Detection
Helper functions for detecting keyboard input (supports Kitty keyboard protocol):
import {
isEnter,
isEscape,
isTab,
isShiftTab,
isArrowUp,
isArrowDown,
isArrowLeft,
isArrowRight,
isCtrlA,
isCtrlC,
isCtrlE,
isCtrlK,
isCtrlO,
isCtrlP,
isCtrlLeft,
isCtrlRight,
isAltLeft,
isAltRight,
isShiftEnter,
isAltEnter,
isShiftCtrlO,
isShiftCtrlD,
isShiftCtrlP,
isBackspace,
isDelete,
isHome,
isEnd,
} from "@jawcode-dev/tui";
if (isCtrlC(data)) {
process.exit(0);
}
Differential Rendering
The TUI uses three rendering strategies:
- First Render: Output all lines without clearing scrollback
- Width Changed or Change Above Viewport: Clear screen and full re-render
- Normal Update: Move cursor to first changed line, clear to end, render changed lines
All updates are wrapped in synchronized output (\x1b[?2026h ... \x1b[?2026l) for atomic, flicker-free rendering.
Terminal Interface
The TUI works with any object implementing the Terminal interface:
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;
}
Built-in implementations:
ProcessTerminal - Uses process.stdin/stdout
VirtualTerminal - For testing (uses @xterm/headless)
Utilities
import { Ellipsis, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@jawcode-dev/tui";
const width = visibleWidth("\x1b[31mHello\x1b[0m");
const truncated = truncateToWidth("Hello World", 8);
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, Ellipsis.Omit);
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
Creating Custom Components
When creating custom components, each line returned by render() must not exceed the width parameter. The TUI will error if any line is wider than the terminal.
Handling Input
Use the key detection utilities to handle keyboard input:
import { isEnter, isEscape, isArrowUp, isArrowDown, isCtrlC, isTab, isBackspace } from "@jawcode-dev/tui";
import type { Component } from "@jawcode-dev/tui";
class MyInteractiveComponent implements Component {
private selectedIndex = 0;
private items = ["Option 1", "Option 2", "Option 3"];
onSelect?: (index: number) => void;
onCancel?: () => void;
handleInput(data: string): void {
if (isArrowUp(data)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} else if (isArrowDown(data)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
} else if (isEnter(data)) {
this.onSelect?.(this.selectedIndex);
} else if (isEscape(data) || isCtrlC(data)) {
this.onCancel?.();
}
}
render(width: number): string[] {
return this.items.map((item, i) => {
const prefix = i === this.selectedIndex ? "> " : " ";
return truncateToWidth(prefix + item, width);
});
}
}
Handling Line Width
Use the provided utilities to ensure lines fit:
import { visibleWidth, truncateToWidth } from "@jawcode-dev/tui";
import type { Component } from "@jawcode-dev/tui";
class MyComponent implements Component {
private text: string;
constructor(text: string) {
this.text = text;
}
render(width: number): string[] {
return [truncateToWidth(this.text, width)];
const line = this.text;
const visible = visibleWidth(line);
if (visible > width) {
return [truncateToWidth(line, width)];
}
return [line + " ".repeat(width - visible)];
}
}
ANSI Code Considerations
visibleWidth(), truncateToWidth(), and wrapTextWithAnsi() correctly handle ANSI escape codes:
visibleWidth() ignores ANSI codes when calculating width (via Bun.stringWidth)
truncateToWidth() preserves ANSI codes and properly closes them when truncating
wrapTextWithAnsi() preserves ANSI codes while word-wrapping and trimming line ends
import chalk from "chalk";
const styled = chalk.red("Hello") + " " + chalk.blue("World");
const width = visibleWidth(styled);
const truncated = truncateToWidth(styled, 8);
Caching
For performance, components should cache their rendered output and only re-render when necessary:
class CachedComponent implements Component {
private text: string;
private cachedWidth?: number;
private cachedLines?: string[];
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const lines = [truncateToWidth(this.text, width)];
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
Example
See test/chat-simple.ts for a complete chat interface example with:
- Markdown messages with custom background colors
- Loading spinner during responses
- Editor with autocomplete and slash commands
- Spacers between messages
Run it:
npx tsx test/chat-simple.ts
Development
npm install
npm run check
npx tsx test/chat-simple.ts