| name | terminaltui |
| description | Framework for building TUI websites and applications. Use when a user wants to create a terminal-based website, build a TUI application, or convert an existing website to TUI. Trigger on: "TUI", "terminal website", "terminal UI", "terminal app", "npx site", "terminaltui", converting websites to terminal, building CLI/terminal interfaces. |
terminaltui — TUI Website & Application Framework
What It Is
terminaltui is a TypeScript framework that turns any website into a fully interactive terminal (TUI) experience. Projects use Next.js-style file-based routing — a config.ts for global settings plus a pages/ directory where each file is a route. The result is an interactive terminal app navigable by keyboard that can be published to npm so anyone can run it with npx my-site, or hosted over SSH so anyone can connect with ssh host -p PORT.
Quick Start
npx terminaltui init [template]
npx terminaltui dev
npx terminaltui serve --port 2222
npx terminaltui build
Minimal project:
import { defineConfig } from "terminaltui";
export default defineConfig({
name: "My Site",
theme: "cyberpunk",
});
import { markdown } from "terminaltui";
export const metadata = { label: "Home", icon: "◆" };
export default function Home() {
return [markdown("Hello world!")];
}
Project structure:
my-site/
config.ts # theme, banner, global settings
pages/ # one file per route
home.ts
package.json # must have "type": "module"
tsconfig.json
File-Based Routing
terminaltui uses Next.js-style file-based routing. Each page is its own file, layouts nest automatically, and menus are auto-generated from the filesystem.
Project Structure
my-site/
├── config.ts # Theme, site name, global settings
├── pages/
│ ├── layout.ts # Root layout (wraps all pages)
│ ├── home.ts # Home page
│ ├── about.ts # /about
│ └── projects/
│ ├── index.ts # /projects
│ └── [slug].ts # /projects/:slug
├── api/
│ └── stats.ts # GET /api/stats
├── components/ # Reusable components
└── lib/ # Shared data/helpers
config.ts
Uses defineConfig() instead of createSite(). Contains only global settings — no pages, no content.
import { defineConfig } from "terminaltui";
export default defineConfig({
name: "My Site",
theme: "cyberpunk",
banner: { font: "ANSI Shadow" },
boot: { spinner: true },
menu: {
order: ["home", "projects", "about", "contact"],
labels: { projects: "Our Work", about: "About Us" },
},
onInit: async () => { },
onExit: () => { },
onError: (err) => { },
});
Omit menu entirely to let the framework auto-generate it from pages/.
Page Files
Every .ts file in pages/ becomes a page. Default export is a function returning ContentBlock[].
import { text, card } from "terminaltui";
export default function About() {
return [
card({ title: "About Me", body: "Full-stack developer based in..." }),
];
}
Metadata export — optional, controls menu label, order, transition, visibility:
import { card, row, col } from "terminaltui";
export const metadata = {
label: "Projects",
order: 2,
transition: "slide",
icon: "code",
hidden: false,
};
export default function Projects() {
return [
row([
col([card({ title: "Project A" })], { span: 6 }),
col([card({ title: "Project B" })], { span: 6 }),
]),
];
}
Async pages — for data fetching:
import { card, row, col, text } from "terminaltui";
export default async function Dashboard() {
const stats = await fetch("/api/stats").then(r => r.json());
return [
row([
col([card({ title: "Revenue", content: [text(stats.revenue)] })], { span: 6 }),
col([card({ title: "Users", content: [text(String(stats.users))] })], { span: 6 }),
]),
];
}
Dynamic routes — [param] in filename, params passed to function:
import { card, text, badge } from "terminaltui";
export const metadata = { hidden: true };
export default async function ProjectDetail({ params }: { params: { slug: string } }) {
const project = await fetch(`/api/projects/${params.slug}`).then(r => r.json());
return [
card({ title: project.name, content: [badge({ text: project.status }), text(project.description)] }),
];
}
Page Visibility
Control which pages appear in the menu:
metadata.hidden = true — page exists and is navigable but excluded from auto-generated menu
- Pages without
hidden: true appear in the menu by default
- Dynamic route pages (
[slug].ts) should always be hidden: true
- In
defineConfig({ menu }):
menu.order: ["home", "about"] — reorder the menu by page name
menu.exclude: ["secret"] — explicitly hide specific pages
menu.items: [{ id, label, icon }] — fully manual menu (overrides auto-generation)
Layout Files
A layout.ts in any directory wraps all sibling and descendant pages. Receives children (the rendered page content).
import { columns, panel, menu } from "terminaltui";
import type { ContentBlock } from "terminaltui";
export default function RootLayout({ children }: { children: ContentBlock[] }) {
return [
columns([
panel({ width: "25%", content: [menu({ source: "auto" })] }),
panel({ width: "75%", content: children }),
]),
];
}
import { columns, panel, menu, text } from "terminaltui";
import type { ContentBlock } from "terminaltui";
export default function DashboardLayout({ children }: { children: ContentBlock[] }) {
return [
text("Dashboard"),
columns([
panel({ width: "20%", content: [menu({ items: [
{ label: "Overview", page: "dashboard" },
{ label: "Analytics", page: "dashboard/analytics" },
{ label: "Settings", page: "dashboard/settings" },
]})] }),
panel({ width: "80%", content: children }),
]),
];
}
Nesting: Layouts compose from outside in. For /dashboard/analytics:
RootLayout -> DashboardLayout -> AnalyticsPage
If no layout.ts exists at a level, pages use the nearest parent layout.
API Routes
Export named functions matching HTTP methods. File path maps to endpoint.
export async function GET() {
return { revenue: "$1.2M", users: 45231 };
}
export async function POST(request: { body: any }) {
const { name, email, message } = request.body;
return { success: true };
}
export async function GET({ params }: { params: { id: string } }) {
return projects.find(p => p.id === params.id) ?? { error: "Not found" };
}
export async function DELETE({ params }: { params: { id: string } }) {
return { success: true };
}
Route mapping: api/stats.ts -> /api/stats, api/projects/[id].ts -> /api/projects/:id.
Auto-Generated Menu
When config.ts omits menu, the framework scans pages/ and builds the menu automatically.
Rules:
- Every
.ts file directly in pages/ becomes a top-level menu item
- Directories with
index.ts become a top-level menu item (name from directory)
- Sub-pages inside directories (other than
index.ts) are NOT in the top menu
home.ts is always first
layout.ts files are never menu items
metadata.hidden = true pages are excluded
- Dynamic route files (
[param].ts) are excluded
Ordering: metadata.order (lowest first), then alphabetical for unordered items.
Labels: metadata.label > metadata.icon + titlecased filename > titlecased filename (about.ts -> "About", our-team.ts -> "Our Team").
Manual override in config.ts:
export default defineConfig({
name: "My Site",
menu: {
items: [
{ label: "Home", page: "home", icon: "terminal" },
{ label: "Work", page: "projects" },
{ label: "About Me", page: "about" },
],
},
});
menu() Component
Use menu({ source: "auto" }) in any page or layout to render the auto-generated menu:
import { hero, menu } from "terminaltui";
export default function Home() {
return [
hero({ title: "My Site", subtitle: "Welcome" }),
menu({ source: "auto" }),
];
}
Important: The framework renders the navigation menu automatically on the home screen. Do NOT add menu({ source: 'auto' }) to your home.ts -- it creates a duplicate menu.
If home.ts doesn't exist, the framework auto-generates a home page with hero() + menu({ source: "auto" }).
Focus & Scroll Model — CRITICAL FOR GOOD UX
TUI navigation is fundamentally up/down arrow keys moving a focus cursor between items. The viewport scrolls to follow the focused item. Understanding which components are focusable is essential for building good TUI experiences.
Default layout philosophy: Use vertical scrolling with flat card layouts. Use divider("Label") to visually separate sections rather than nesting them inside containers like tabs().
Focusability per Component
| Component | Focusable? | Behavior |
|---|
card() | Yes — individually | Each card is a separate focus target. Best for browsable lists. |
link() | Yes — individually | Opens URL on Enter. |
hero() | Yes — individually | Opens CTA URL on Enter (if cta set). |
accordion() | Yes — per item | Each accordion item is separately focusable. Enter toggles open/close. |
tabs() | Yes — as one block | Enter cycles through tabs. Not ideal for many sections. |
textInput() | Yes — individually | Enter starts editing, Escape exits. |
textArea() | Yes — individually | Same as textInput but multi-line. |
select() | Yes — individually | Enter opens dropdown, arrow keys pick option. |
checkbox() | Yes — individually | Enter/Space toggles. |
toggle() | Yes — individually | Enter/Space toggles. |
radioGroup() | Yes — individually | Enter starts selection, arrows move between options. |
numberInput() | Yes — individually | Left/Right changes value. |
searchInput() | Yes — individually | Type to filter, arrows to pick result, Enter to select. |
chat() | Yes — individually | Enter starts typing, sends message on Enter, Escape exits. |
button() | Yes — individually | Enter triggers action. |
timeline() | Yes — per item | Each timeline item is focusable but display-only (no action on Enter). |
markdown() | No | Passive text. Not focusable. |
table() | No | Passive data display. Not focusable. |
list() | No | Passive list. Items not individually focusable. |
quote() | No | Passive text. Not focusable. |
progressBar() / skillBar() | No | Passive display. |
badge() | No | Inline label. Not focusable. |
divider() | No | Visual separator. Not focusable. |
spacer() | No | Vertical spacing. Not focusable. |
image() | No | Passive display. |
section() | No — wrapper | Children inherit their own focusability. |
form() | No — wrapper | Children (inputs, buttons) are individually focusable. |
dynamic() | No — wrapper | Children inherit their own focusability. |
columns() | No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
rows() | No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
grid() | No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
panel() | No — wrapper | Used inside layout components. Children inherit focusability. |
TUI UX Patterns — What to Use When
| UX Need | Use | Avoid |
|---|
| Scrollable list of items | Flat card() blocks | timeline(), list() |
| Sectioned long page | divider("Label") + cards below | tabs() (forces horizontal switching) |
| Toggle between views of same data | tabs() | n/a |
| Dense reference data | table() | Many cards for tabular data |
| Expandable FAQ / details | accordion() | Long markdown() blocks |
| Work history / education | Individual card() blocks with period as subtitle | timeline() (items aren't actionable) |
| Skills / tech stack | skillBar() or list() (passive reference) | Cards (overkill for simple data) |
| Dashboard with sidebar | columns() — sidebar panel + main panel | Flat layout (loses spatial structure) |
| Monitoring grid | grid() with metric panels | Single-column cards (wastes space) |
| Split editor/preview | columns([panel({…}), panel({…})]) | Tabs (can't see both at once) |
| Log viewer + controls | rows() — controls on top, logs below | Interleaved cards |
Bad → Good Patterns
tabs([
{ label: "Experience", content: [timeline([
{ title: "Engineer", subtitle: "Acme", period: "2023–now" }
])] },
{ label: "Education", content: [timeline([...])] },
])
divider("Experience"),
card({ title: "Senior Engineer", subtitle: "Acme Corp — 2023–present", body: "Leading platform team..." }),
card({ title: "Junior Dev", subtitle: "Startup — 2021–2023", body: "Built core features..." }),
divider("Education"),
card({ title: "BS Computer Science", subtitle: "State University — 2021" }),
When to use timeline(): Only when you want a visual connected-dot timeline aesthetic AND the items are passive (no action needed on Enter). For anything users need to browse, navigate, or interact with, use card() blocks instead.
When to use tabs(): Only for mutually exclusive views of the same data (e.g., "Grid view" vs "List view"). NOT for organizing sequential sections of a page — use divider("Label") for that. If two views should be visible simultaneously (e.g., Day 1 and Day 2 of a conference schedule), use columns([panel({…}), panel({…})]) instead.
Layout Mapping Guide
| Site Pattern | Layout | Example |
|---|
| Dashboard with sidebar navigation | columns() — narrow first panel (20-25%), wide main panel | Server dashboard |
| Dashboard with multiple data views | columns() + nested grid() | System monitor with CPU/Memory/Disk metrics |
| Pricing comparison (2-4 tiers) | columns() — one panel per tier | SaaS pricing page |
| Side-by-side content (text + skills) | columns([panel({…}), panel({…})]) | Portfolio about page |
| Day 1 / Day 2 schedule | columns([panel({…}), panel({…})]) | Conference schedule |
| Food menu (categories) | columns([panel({…}), panel({…})]) — dishes left, drinks right | Restaurant menu |
| Hours + location info | columns() — hours table left, address right | Restaurant/shop hours |
| Project/portfolio cards | grid({ cols: 2 }) — cards in a grid | Freelancer work page |
| Feature cards | grid({ cols: 2 }) | SaaS features page |
| Speaker/team bios | grid({ cols: 2 }) | Conference speakers |
| Sponsor logos by tier | grid({ cols: 3 }) per tier | Conference sponsors |
| Log viewer | columns() — service list left, log stream right | Server logs |
| Container table + details | rows([panel({…}), panel({…})]) — table top, details bottom | Container management |
| Precise multi-column layout | row() + col() — 12-column grid system | Complex dashboards |
| Responsive card grid | row() with xs:12, sm:6, lg:4 — cards reflow by terminal width | Portfolio, features |
| Centered narrow content | container({ maxWidth: 80 }) — centered with max width | Blog posts, forms |
12-Column Grid System
row(), col(), and container() provide a Bootstrap-style 12-column grid for precise layouts.
import { row, col, container } from "terminaltui";
row([
col([card({ title: "Left" })], { span: 6 }),
col([card({ title: "Right" })], { span: 6 }),
])
row([
col([menu], { span: 3 }),
col([mainContent], { span: 6 }),
col([aside], { span: 3 }),
])
row([
col([card1], { span: 4, sm: 6, xs: 12 }),
col([card2], { span: 4, sm: 6, xs: 12 }),
col([card3], { span: 4, sm: 12, xs: 12 }),
], { gap: 1 })
container([
row([
col([hero(...)], { span: 12 }),
]),
row([
col([sidebar], { span: 3 }),
col([content], { span: 9 }),
]),
], { maxWidth: 100, padding: 2 })
ColConfig options: span (1-12), offset (0-11), xs/sm/md/lg (responsive spans), padding.
RowConfig options: gap (between cols, default: 1).
ContainerConfig options: maxWidth, padding, center (default: true).
Responsive breakpoints: xs (<60 cols), sm (60-89), md (90-119), lg (>=120).
Spatial navigation works automatically — arrow keys move between col content based on screen position.
Full API Reference
Every function below is imported from "terminaltui".
defineConfig(config): FileBasedConfig
Top-level project config. Default-export from config.ts.
interface FileBasedConfig {
name: string;
handle?: string;
tagline?: string;
banner?: BannerConfig;
theme?: Theme | BuiltinThemeName;
borders?: BorderStyle;
animations?: AnimationConfig;
navigation?: NavigationConfig;
middleware?: MiddlewareFn[];
easterEggs?: EasterEggConfig;
footer?: string | ContentBlock;
statusBar?: boolean | StatusBarConfig;
menu?: MenuConfig;
serve?: ServeConfig;
env?: Record<string, unknown>;
artDir?: string | false;
onInit?: (app: AppContext) => Promise<void> | void;
onExit?: (app: AppContext) => Promise<void> | void;
onNavigate?: (from: string, to: string, params?: RouteParams) => void;
onError?: (error: Error, context: ErrorContext) => ContentBlock[] | void;
}
export default defineConfig({
name: "My Site",
handle: "@me",
tagline: "a cool terminal site",
banner: ascii("My Site", { font: "ANSI Shadow", gradient: ["#ff6b6b", "#4ecdc4"] }),
theme: "dracula",
borders: "rounded",
animations: { boot: true, exitMessage: "Goodbye!", speed: "normal" },
middleware: [requireEnv(["API_KEY"])],
onInit: async (app) => { },
onError: (err, ctx) => [markdown(`Error: ${err.message}`)],
});
Page files
A page is any .ts file under pages/. Default-export a function returning content blocks; optionally export metadata.
import { markdown, card } from "terminaltui";
export const metadata = {
label: "About Me",
icon: "◆",
order: 2,
hidden: false,
middleware: [],
};
export default function About() {
return [markdown("Hello!"), card({ title: "Hi", body: "..." })];
}
Common icons: "◆" "◈" "▣" "▤" "◉" "▸" "✦" "★" "●" "■" "▲" "♦"
Dynamic routes — pages/[param].ts
A bracketed filename creates a dynamic route. Params come in via the function arg:
export const metadata = { hidden: true };
export default async function Project({ params }: { params: { slug: string } }) {
const data = await fetchProject(params.slug);
return [card({ title: data.name, body: data.description })];
}
navigate(pageId: string, params?: RouteParams): void
Programmatic navigation from anywhere (event handlers, middleware, etc.).
navigate("home");
navigate("projects/[slug]", { slug: "my-app" });
Content Blocks
markdown(text: string): TextBlock
Renders text with markdown formatting (bold, italic, inline code, code blocks).
markdown("This is **bold** and *italic* with `code`.")
card(config): CardBlock
A bordered card with title, optional subtitle, body, tags, URL, and action.
interface CardBlock {
title: string;
subtitle?: string;
body?: string;
tags?: string[];
url?: string;
border?: BorderStyle;
action?: CardAction;
}
interface CardAction {
label?: string;
style?: "primary" | "secondary" | "danger";
confirm?: string;
onPress?: () => void | Promise<void>;
navigate?: string;
params?: RouteParams;
}
card({
title: "My Project",
subtitle: "★ 200",
body: "A brief description.",
tags: ["TypeScript", "Open Source"],
url: "https://github.com/user/repo",
action: { navigate: "project", params: { name: "my-project" } },
})
List-to-Detail navigation pattern: Use action.navigate on cards to link to detail pages. Mark detail pages as hidden so they don't appear in the menu.
export default function Blog() {
return [
card({ title: "First Post", action: { navigate: "blog-1" } }),
card({ title: "Second Post", action: { navigate: "blog-2" } }),
];
}
export const metadata = { hidden: true };
export default function BlogPost1() {
return [card({ title: "First Post", body: "Full content here..." })];
}
timeline(items: TimelineItem[]): TimelineBlock
Vertical timeline with connected entries. Great for work history, changelog, education.
interface TimelineItem {
title: string;
subtitle?: string;
period?: string;
description?: string;
}
timeline([
{ title: "Senior Engineer", subtitle: "Acme Corp", period: "2023 — present", description: "Leading platform team" },
{ title: "BS Computer Science", subtitle: "University", period: "2017 — 2021" },
])
table(headers: string[], rows: string[][]): TableBlock
A bordered data table.
table(
["Plan", "Price", "Features"],
[
["Free", "$0/mo", "Basic features"],
["Pro", "$10/mo", "Everything + priority support"],
]
)
list(items: string[], style?): ListBlock
A styled list. Style: "bullet" (default) | "number" | "dash" | "check" | "arrow".
list(["First item", "Second item", "Third item"], "check")
quote(text: string, attribution?: string): QuoteBlock
Block quote with optional attribution.
quote("The best way to predict the future is to invent it.", "— Alan Kay")
hero(config): HeroBlock
Large hero section with title, subtitle, CTA, and optional ASCII art.
interface HeroBlock {
title: string;
subtitle?: string;
cta?: { label: string; url: string };
art?: string;
}
hero({ title: "Welcome", subtitle: "Build terminal apps.", cta: { label: "Get Started →", url: "https://..." } })
gallery(items): GalleryBlock
Grid of cards. Items use the same shape as card() (without type).
gallery([
{ title: "Photo 1", body: "Description", tags: ["nature"] },
{ title: "Photo 2", body: "Description", tags: ["urban"] },
])
tabs(items): TabsBlock
Tabbed content. Each tab has a label and nested content blocks.
tabs([
{ label: "Frontend", content: [list(["React", "Vue", "Svelte"], "check")] },
{ label: "Backend", content: [list(["Node.js", "Python", "Go"], "check")] },
])
accordion(items): AccordionBlock
Collapsible sections. Same shape as tabs. Great for FAQs.
accordion([
{ label: "What is terminaltui?", content: [markdown("A framework for building terminal websites.")] },
{ label: "How do I deploy?", content: [markdown("Run `terminaltui build` then `npm publish`.")] },
])
link(label: string, url: string, options?: LinkOptions): LinkBlock
A clickable link. Opens in the user's browser when selected.
interface LinkOptions {
icon?: string;
}
link("GitHub", "https://github.com/user")
link("Email", "mailto:hello@example.com", { icon: "✉" })
progressBar(label: string, value: number, max?: number): ProgressBarBlock
Generic progress bar. Max defaults to 100. Always shows percent.
progressBar("Project Alpha", 7, 10)
progressBar("Completion", 65)
skillBar(label: string, value: number): ProgressBarBlock
Shorthand for progressBar(label, value, 100) with showPercent: true.
skillBar("TypeScript", 90)
skillBar("Rust", 75)
badge(text: string, color?: string): BadgeBlock
An inline badge/tag. Color is a hex string.
badge("v2.0")
badge("NEW", "#50fa7b")
image(path: string, options?): ImageBlock
Renders an image in the terminal.
image("./logo.png")
image("./photo.jpg", { width: 60, mode: "braille" })
Options: width?: number, mode?: "ascii" | "braille" | "blocks".
section(title: string, content: ContentBlock[]): SectionBlock
Groups content under a titled section header with a divider line.
section("Appetizers", [
card({ title: "Bruschetta", subtitle: "$12", body: "Toasted bread with tomatoes" }),
])
divider(style?, label?): DividerBlock
Horizontal divider line. Styles: "solid" | "dashed" | "dotted" | "double" | "label". If the first arg is not a known style, it becomes a label automatically.
divider()
divider("dashed")
divider("My Section")
divider("label", "Section")
spacer(lines?: number): SpacerBlock
Vertical whitespace. Defaults to 1 line.
spacer()
spacer(3)
dynamic(renderFn) / dynamic(deps, renderFn): DynamicBlock
Reactive content block that re-renders when state changes. Currently all dynamic blocks re-render on any state change. The deps array is accepted for forward compatibility.
dynamic(() => markdown(`Count: ${state.get("count")}`))
dynamic(["count"], () => markdown(`Count: ${state.get("count")}`))
asyncContent(config): AsyncContentBlock
Lazily-loaded async content.
asyncContent({
load: async () => {
const data = await fetchData();
return [card({ title: data.name, body: data.description })];
},
loading: "Loading data...",
fallback: [markdown("Failed to load.")],
})
Box Model
Every component uses a unified box model via computeBoxDimensions() from src/layout/box-model.ts. One function, one contract, one source of truth for width calculations.
+---------------- allocated width -----------------+
| margin |
| +------------ outer width -----------------+ |
| | border | |
| | +-------- inner width ---------------+ | |
| | | padding | | |
| | | +---- content width -----------+ | | |
| | | | | | | |
| | | | Text wraps here. | | | |
| | | | Children render here. | | | |
| | | | | | | |
| | | +------------------------------+ | | |
| | +------------------------------------+ | |
| +-------------------------------------------+ |
+--------------------------------------------------+
content = allocated - (margin * 2) - (border * 2) - (padding * 2)
Width cascade:
Terminal width (e.g. 120 cols)
-> createRenderContext(): ctx.width = Math.min(terminalWidth, 100)
-> renderContentPage(): blockWidth = ctx.width - 1 (focus prefix)
-> Component gets blockWidth as ctx.width
-> dims = computeBoxDimensions(ctx.width, COMPONENT_DEFAULTS.componentType)
-> Text wraps at dims.content
-> Child blocks receive dims.content as their width
API:
import { computeBoxDimensions, COMPONENT_DEFAULTS } from "terminaltui";
import type { BoxDimensions, BoxOptions } from "terminaltui";
const dims = computeBoxDimensions(80, { border: true, padding: 1 });
const cardDims = computeBoxDimensions(80, COMPONENT_DEFAULTS.card);
const widePad = computeBoxDimensions(80, { ...COMPONENT_DEFAULTS.card, padding: 2 });
Defaults quick reference:
| Component | Border | Padding | Margin | Chrome | Content at w=80 |
|---|
| card | 1 | 1 | 0 | 4 | 76 |
| text | 0 | 0 | 0 | 0 | 80 |
| hero | 0 | 0 | 0 | 0 | 80 |
| table | 1 | 0 | 0 | 2 | 78 |
| quote | 1 | 1 | 1 | 6 | 74 |
| timeline | 1 | 1 | 1 | 6 | 74 |
| accordion | 0 | 2 | 0 | 4 | 76 |
| tabs | 0 | 2 | 0 | 4 | 76 |
| textInput | 1 | 1 | 0 | 4 | 76 |
| select | 1 | 1 | 0 | 4 | 76 |
| button | 1 | 2 | 1 | 8 | 72 |
| badge | 0 | 0 | 0 | 0 | 80 |
| progressBar | 0 | 0 | 0 | 0 | 80 |
| divider | 0 | 0 | 0 | 0 | 80 |
| image | 1 | 0 | 0 | 2 | 78 |
Rules:
- Every component calls
computeBoxDimensions(). No exceptions.
- Layout components (columns, rows, grid, panel, row, col, container) divide width among children — they do NOT call
computeBoxDimensions() for themselves.
- Text always wraps at
dims.content.
- Child blocks receive
dims.content as their allocated width.
- No manual
ctx.width - N in component files. All chrome subtraction goes through the box model.
Layout Components
Layout components divide the terminal into panels — side-by-side, stacked, or in grids. Each panel is an independent area with its own content. Panels can have borders, titles, and content clipping.
Navigation: Tab/Shift+Tab switches between panels. Arrow keys navigate within the active panel. The active panel gets an accent-colored border.
Responsive: If the terminal is too narrow for side-by-side panels (<20 chars per panel), columns automatically collapse to vertical stacking.
columns(panels: PanelConfig[]): ColumnsBlock
Side-by-side panels. Each panel gets a width (percentage, fixed chars, or auto).
columns([
panel({ width: "60%", content: [
table(["Name", "Status"], [["nginx", "running"], ["postgres", "running"]]),
]}),
panel({ width: "40%", content: [
markdown("## Stats"),
progressBar("CPU", 45),
progressBar("Memory", 72),
]}),
])
rows(panels: PanelConfig[]): RowsBlock
Vertically stacked panels with fixed/flex heights.
rows([
panel({ height: "30%", content: [
markdown("## Active Containers"),
table(["Name", "Status"], [["nginx", "up"], ["postgres", "up"]]),
]}),
panel({ height: "70%", content: [
markdown("## Logs"),
markdown("12:00:01 [nginx] GET /health 200"),
markdown("12:00:02 [nginx] GET /users 200"),
]}),
])
Deprecated: split({ direction, ratio, first, second }) still works but is now a thin wrapper that returns a columns() (horizontal) or rows() (vertical) block. Will be removed in v2.0. Prefer the explicit form: columns([panel({ width: "30%", content: first }), panel({ width: "70%", content: second })]).
grid(config: GridConfig): GridBlock
N×M grid of panels. cols: number of columns. gap: character gap between cells (default 1).
grid({
cols: 2,
gap: 1,
items: [
panel({ title: "CPU", content: [progressBar("Usage", 45)] }),
panel({ title: "Memory", content: [progressBar("RAM", 72)] }),
panel({ title: "Disk", content: [progressBar("Usage", 31)] }),
panel({ title: "Network", content: [markdown("125 Mbps")] }),
],
})
panel(config: PanelConfig): PanelBlock
A single panel with optional border, title, padding, and content clipping. Used inside columns(), rows(), grid(), or standalone.
interface PanelConfig {
content: ContentBlock[];
width?: string | number;
height?: string | number;
title?: string;
border?: boolean | BorderStyle;
padding?: number;
scrollable?: boolean;
focusable?: boolean;
}
Nested Layouts
Layouts can be nested for complex dashboards:
columns([
panel({ width: "25%", title: "Navigation", content: [
link("Dashboard", "#"),
link("Logs", "#"),
link("Settings", "#"),
]}),
panel({ width: "75%", content: [
rows([
panel({ height: "60%", content: [
markdown("## Main Content"),
table(["Name", "Status"], [["nginx", "running"]]),
]}),
panel({ height: "40%", title: "Logs", content: [
markdown("Log output here..."),
]}),
]),
]}),
])
Sizing Reference
| Context | Property | Values |
|---|
| columns | width | "50%", 30 (chars), "auto" (default: equal split) |
| rows | height | "50%", 10 (rows), "auto" (default: equal split) |
| grid | cols | Number of columns |
| grid | gap | Gap in characters (default: 1) |
container(content: ContentBlock[], config?): ContainerBlock
Wrap content in a centered container with an optional max width and padding. Use as the outermost wrapper of a page when you want a Bootstrap-style centered layout.
container([
hero({ title: "Welcome" }),
row([
col([card({ title: "Left" })], { span: 6 }),
col([card({ title: "Right" })], { span: 6 }),
]),
], { maxWidth: 100, padding: 2, center: true })
interface ContainerConfig {
maxWidth?: number;
padding?: number;
center?: boolean;
}
row(cols: ColBlock[], config?): RowBlock
A 12-column grid row. Children must be col(...) blocks. Rows auto-wrap when the sum of effective spans exceeds 12 at the current breakpoint.
row([
col([statsCard], { span: 3, xs: 12 }),
col([chartCard], { span: 9, xs: 12 }),
], { gap: 1 })
interface RowConfig {
gap?: number;
}
col(content: ContentBlock[], config: ColConfig): ColBlock
A 12-column grid cell. span is required; xs/sm/md/lg override span at each breakpoint.
col([card({ title: "Stats" })], {
span: 4,
offset: 0,
xs: 12, sm: 6, md: 4, lg: 3,
})
interface ColConfig {
span: number;
offset?: number;
padding?: number;
xs?: number;
sm?: number;
md?: number;
lg?: number;
}
Breakpoints: xs (<60 cols), sm (60-89), md (90-119), lg (≥120). Spatial navigation works automatically across grid cells.
Removed in this release: box(). For a bordered region, use panel({ border: true, padding: 1, content: […] }). For padding/margin only, use container({ padding: 1, content: […] }).
menu(config: MenuConfig): MenuBlock
Inline menu block. The auto source resolves at render time from the file-based router's discovered pages.
import { menu } from "terminaltui";
menu({ source: "auto" })
menu({
source: "manual",
items: [
{ id: "home", label: "Home", icon: "◆" },
{ id: "projects", label: "Projects", icon: "▣" },
{ id: "contact", label: "Contact", icon: "◉" },
],
})
interface MenuConfig {
source: "auto" | "manual";
items?: MenuItemConfig[];
}
interface MenuItemConfig {
id: string;
label: string;
icon?: string;
hidden?: boolean;
}
The framework already renders the home menu automatically. Don't add menu({ source: "auto" }) to pages/home.ts — it'll duplicate the menu.
Input Components
All input components create interactive form elements. In navigation mode, press Enter on an input to enter edit mode; press Escape to return to navigation.
textInput(config): TextInputBlock
interface TextInputBlock {
id: string;
label: string;
placeholder?: string;
defaultValue?: string;
maxLength?: number;
validate?: (value: string) => string | null;
mask?: boolean;
transform?: (value: string) => string;
}
textInput({ id: "name", label: "Your Name", placeholder: "Enter name...", maxLength: 50 })
textInput({ id: "password", label: "Password", mask: true })
textArea(config): TextAreaBlock
interface TextAreaBlock {
id: string;
label: string;
placeholder?: string;
defaultValue?: string;
rows?: number;
maxLength?: number;
validate?: (value: string) => string | null;
}
textArea({ id: "bio", label: "Bio", placeholder: "Tell us about yourself...", rows: 4, maxLength: 500 })
select(config): SelectBlock
interface SelectBlock {
id: string;
label: string;
options: { label: string; value: string }[];
defaultValue?: string;
placeholder?: string;
onChange?: (value: string) => void;
}
select({
id: "color",
label: "Favorite Color",
options: [{ label: "Red", value: "red" }, { label: "Blue", value: "blue" }],
onChange: (val) => console.log("Selected:", val),
})
checkbox(config): CheckboxBlock
interface CheckboxBlock {
id: string;
label: string;
defaultValue?: boolean;
onChange?: (value: boolean) => void;
}
checkbox({ id: "agree", label: "I agree to the terms", onChange: (val) => console.log(val) })
toggle(config): ToggleBlock
interface ToggleBlock {
id: string;
label: string;
defaultValue?: boolean;
onLabel?: string;
offLabel?: string;
onChange?: (value: boolean) => void;
}
toggle({ id: "dark", label: "Dark Mode", onLabel: "ON", offLabel: "OFF", defaultValue: true })
radioGroup(config): RadioGroupBlock
interface RadioGroupBlock {
id: string;
label: string;
options: { label: string; value: string }[];
defaultValue?: string;
onChange?: (value: string) => void;
}
radioGroup({
id: "plan",
label: "Select Plan",
options: [{ label: "Free", value: "free" }, { label: "Pro", value: "pro" }],
defaultValue: "free",
onChange: (val) => console.log("Plan:", val),
})
numberInput(config): NumberInputBlock
interface NumberInputBlock {
id: string;
label: string;
defaultValue?: number;
min?: number;
max?: number;
step?: number;
}
numberInput({ id: "qty", label: "Quantity", defaultValue: 1, min: 1, max: 99, step: 1 })
searchInput(config): SearchInputBlock
interface SearchInputBlock {
id: string;
label?: string;
placeholder?: string;
items: { label: string; value: string; keywords?: string[] }[];
maxResults?: number;
action?: "navigate" | "callback";
onSelect?: (value: string) => void;
}
searchInput({
id: "search",
placeholder: "Search pages...",
items: [
{ label: "About", value: "about", keywords: ["bio", "info"] },
{ label: "Projects", value: "projects", keywords: ["work", "code"] },
],
action: "navigate",
})
chat(config): ChatBlock
Interactive chat widget that sends messages to an API endpoint. Supports conversation history, suggested questions, and a system prompt.
interface ChatBlock {
id: string;
endpoint: string;
placeholder?: string;
suggestedQuestions?: string[];
systemPrompt?: string;
maxHistory?: number;
}
chat({
id: "ai-chat",
endpoint: "/api/chat",
placeholder: "Ask a question...",
suggestedQuestions: ["What do you do?", "Tell me about projects"],
systemPrompt: "You are a helpful assistant.",
maxHistory: 50,
})
button(config): ButtonBlock
interface ButtonBlock {
label: string;
style?: "primary" | "secondary" | "danger";
onPress?: () => void | Promise<void>;
loading?: boolean;
}
button({ label: "Submit", style: "primary", onPress: async () => { } })
form(config): FormBlock
Groups input fields and a submit button. On submit, collects all field values by ID.
interface FormBlock {
id: string;
onSubmit: (data: Record<string, any>) => Promise<ActionResult> | ActionResult;
fields: ContentBlock[];
}
type ActionResult = { success: string } | { error: string } | { info: string };
form({
id: "contact",
onSubmit: async (data) => {
await sendEmail(data.name, data.email, data.message);
return { success: "Message sent!" };
},
fields: [
textInput({ id: "name", label: "Name" }),
textInput({ id: "email", label: "Email" }),
textArea({ id: "message", label: "Message", rows: 4 }),
button({ label: "Send", style: "primary" }),
],
})
State Management
createState(initial): StateContainer
Reactive state container. Changes trigger UI re-renders.
interface StateContainer<T> {
get(): T;
get<K extends keyof T>(key: K): T[K];
set<K extends keyof T>(key: K, value: T[K]): void;
update<K extends keyof T>(key: K, fn: (prev: T[K]) => T[K]): void;
batch(fn: () => void): void;
on<K extends keyof T>(key: K, handler: (newVal, oldVal) => void): Unsubscribe;
on(key: "*", handler: (key, newVal) => void): Unsubscribe;
}
const state = createState({ count: 0, name: "world" });
state.set("count", 1);
state.update("count", (prev) => prev + 1);
state.on("count", (newVal, oldVal) => console.log(`Changed: ${oldVal} -> ${newVal}`));
state.batch(() => {
state.set("count", 10);
state.set("name", "hello");
});
computed(fn): ComputedValue
Cached derived values. Call .invalidate() to force recalculation.
interface ComputedValue<T> {
get(): T;
invalidate(): void;
}
const total = computed(() => state.get("price") * state.get("quantity"));
console.log(total.get());
createPersistentState(options): StateContainer
State that persists to disk as JSON. Same API as createState.
interface PersistentStateOptions<T> {
path: string;
defaults: T;
}
const prefs = createPersistentState({
path: "./data/prefs.json",
defaults: { theme: "dracula", fontSize: 14 },
});
Data Fetching
fetcher(options): FetcherResult
Reactive data fetcher with caching, retry, and auto-refresh.
interface FetcherOptions<T> {
url?: string;
fetch?: () => Promise<T>;
method?: string;
headers?: Record<string, string>;
body?: any;
refreshInterval?: number;
cache?: boolean;
cacheTTL?: number;
retry?: number;
retryDelay?: number;
transform?: (data: any) => T;
onError?: (err: Error) => void;
}
interface FetcherResult<T> {
readonly data: T | null;
readonly loading: boolean;
readonly error: Error | null;
refresh(): Promise<void>;
mutate(data: T): void;
clear(): void;
destroy(): void;
}
const api = fetcher({ url: "https://api.example.com/data", refreshInterval: 30000, retry: 3 });
request(options) / request.get/post/put/delete/patch
Simple HTTP request helper.
interface RequestOptions {
url: string;
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
headers?: Record<string, string>;
body?: any;
timeout?: number;
}
interface RequestResult<T> {
data: T | null;
error: Error | null;
status: number;
ok: boolean;
}
const res = await request({ url: "https://api.example.com/data", method: "POST", body: { name: "test" } });
const res = await request.get("https://api.example.com/data");
const res = await request.post("https://api.example.com/data", { name: "test" });
const res = await request.put("https://api.example.com/data/1", { name: "updated" });
const res = await request.delete("https://api.example.com/data/1");
const res = await request.patch("https://api.example.com/data/1", { name: "patched" });
const res = await request.post("https://api.example.com/data", { name: "test" }, { Authorization: "Bearer sk-..." });
liveData(options): LiveDataConnection
Real-time data via WebSocket or Server-Sent Events.
const ws = liveData({
type: "websocket",
url: "wss://api.example.com/ws",
onMessage: (data) => { },
onConnect: () => console.log("Connected"),
onDisconnect: () => console.log("Disconnected"),
onError: (err) => console.error(err),
reconnect: true,
reconnectInterval: 5000,
protocols: [],
});
const sse = liveData({
type: "sse",
url: "https://api.example.com/events",
onMessage: (event) => { },
headers: { Authorization: "Bearer ..." },
});
ws.send("hello");
ws.close();
ws.connected;
API Routes
Define backend endpoints by dropping .ts files into your project's api/ directory. No Express, no external server — just Node's built-in http module on localhost. Each file becomes an HTTP endpoint; each named export (GET, POST, PUT, DELETE, PATCH) becomes a method handler.
export async function GET() {
return { uptime: process.uptime(), timestamp: Date.now() };
}
export async function GET(req) {
return { id: req.params.id, name: `Item ${req.params.id}` };
}
export async function POST(req) {
const { image, name } = req.body as any;
return { success: true, message: `Deployed ${name}` };
}
export async function GET(req) {
return { query: req.query.q, page: req.query.page };
}
Then call them from any page via fetcher:
import { dynamic, fetcher, markdown } from "terminaltui";
export const metadata = { label: "Dashboard" };
export default function Dashboard() {
return [
dynamic(["stats"], () => {
const stats = fetcher({ url: "/api/stats", refreshInterval: 5000 });
if (stats.loading) return markdown("Loading...");
return markdown(`Uptime: ${stats.data?.uptime}s`);
}),
];
}
How It Works
- When
terminaltui dev runs, a localhost HTTP server starts on a random port if any api/*.ts files are present
fetcher(), request.*(), and liveData() calls with relative URLs (starting with /api/) auto-route to this server
- The server only binds to
127.0.0.1 — never exposed to the network
- Projects without an
api/ directory skip the HTTP server entirely
ApiRequest Object
interface ApiMethodRequest {
params: Record<string, string>;
query: Record<string, string>;
body: unknown;
headers: Record<string, string>;
}
Supported Methods
GET, POST, PUT, DELETE, PATCH — defined as named exports in each api/ file.
Common API Route Patterns
Shell commands:
import { execSync } from "child_process";
export async function GET() {
return { uptime: execSync("uptime -p").toString().trim() };
}
File system:
import { readFileSync, readdirSync } from "fs";
export async function GET() {
const files = readdirSync(".").filter(f => !f.startsWith("."));
return { files };
}
Stateful in-memory data:
let counter = 0;
"GET /counter": async () => ({ count: ++counter }),
"POST /counter/reset": async () => { counter = 0; return { count: 0 }; },
CRUD with POST body:
const items: any[] = [];
"GET /items": async () => ({ items }),
"POST /items": async (req) => {
const item = { id: Date.now(), ...(req.body as any) };
items.push(item);
return { success: true, item };
},
"DELETE /items/:id": async (req) => {
const idx = items.findIndex(i => i.id === Number(req.params.id));
if (idx === -1) return { error: "not found" };
items.splice(idx, 1);
return { success: true };
},
Error handling (throw → 500):
"GET /risky": async () => {
const result = execSync("some-command").toString();
if (!result) throw new Error("Command produced no output");
return { result };
},
Using API Routes with fetcher/request
Relative URLs in fetcher(), request.*(), and liveData() auto-resolve to the local API server:
dynamic(["stats"], () => {
const data = fetcher({ url: "/stats", refreshInterval: 5000 });
if (data.loading) return markdown("Loading...");
if (data.error) return markdown(`Error: ${data.error.message}`);
return markdown(`Uptime: ${data.data.uptime}`);
}),
form({
id: "deploy",
onSubmit: async (data) => {
const res = await request.post("/deploy", { image: data.image });
if (res.ok) return { success: "Deployed!" };
return { error: (res.data as any)?.error || "Deploy failed" };
},
fields: [
textInput({ id: "image", label: "Docker Image" }),
button({ label: "Deploy", style: "primary" }),
],
}),
When to Use API Routes
| Scenario | Solution |
|---|
| Site needs system info (uptime, disk, CPU) | API route with execSync / os module |
| Dashboard with start/stop/restart actions | POST API routes |
| Contact form that sends email | API route calling email service |
| Live metrics that update on screen | API route + fetcher({ refreshInterval }) |
| Read/write local files | API routes with fs module |
| Docker container management | API routes wrapping docker CLI |
| Database queries | API routes with your DB driver |
Security
API routes have full Node.js capabilities (file system, shell commands, etc). They run on the user's machine and are only accessible from localhost. Never expose the API port to the network.
Hosting & Deployment (SSH) — New in 1.5.0
terminaltui apps can be hosted over SSH so any user with an SSH client can connect and use the app interactively, no install required.
CLI
terminaltui serve --port 2222
ssh localhost -p 2222
Each connection gets an independent TUIRuntime (own state, own focus, own color mode). The host key is auto-generated as Ed25519 at .terminaltui/host_key on first run.
serve config in your site
import { defineConfig } from "terminaltui";
export default defineConfig({
name: "My Site",
theme: "cyberpunk",
serve: {
port: 2222,
hostKeyPath: ".terminaltui/host_key",
maxConnections: 100,
colorMode: "auto",
openUrls: false,
auth: { passwords: { alice: "hunter2" } },
},
});
TerminalIO abstraction
The runtime no longer assumes process.stdin/process.stdout. It talks to a TerminalIO interface, with two implementations shipped:
import { ProcessTerminalIO, TUIRuntime, runSite } from "terminaltui";
import type { TerminalIO } from "terminaltui";
const io = new ProcessTerminalIO();
const runtime = new TUIRuntime(siteConfig, io);
await runtime.start();
SSHServer constructs an SSHTerminalIO per accepted session.
SSHServer — programmatic API
import { SSHServer, TUIRuntime } from "terminaltui";
import type { TerminalIO, ServeOptions } from "terminaltui";
const options: ServeOptions = {
port: 2222,
hostKeyPath: ".terminaltui/host_key",
maxConnections: 100,
auth: { passwords: { alice: "hunter2" } },
};
const server = new SSHServer(options, async (io: TerminalIO) => {
const runtime = new TUIRuntime(siteConfig, io);
await runtime.start();
return runtime;
});
await server.start();
runtime.isServeMode
true when the runtime is hosting an SSH session. Use it to gate code that should only run for local users.
if (runtime.isServeMode) {
}
Server-side caveats
Running on a server with potentially many users changes a few defaults:
openUrl() is a no-op in serve mode — the URL is shown as a notification instead of running open / xdg-open on the server. Override with serve.openUrls: true if you really mean it.
- Function-valued easter-egg commands are skipped — connected users can't trigger arbitrary server-side execution.
createPersistentState, file/network APIs, and any custom api/*.ts handlers run server-side with shared filesystem and network. Scope state by user explicitly if multi-tenant.
FileRouter — programmatic file-based routing
For embedding the file-based router in custom hosts:
import { FileRouter, detectProject } from "terminaltui";
const detection = detectProject(process.cwd());
if (detection.type === "file-based") {
const router = new FileRouter({
config: detection.config,
pagesDir: detection.pagesDir,
apiDir: detection.apiDir,
outDir: ".terminaltui",
});
await router.initialize();
const warnings = router.validate();
}
runFileBasedSite(projectDir, options?) is the all-in-one entry point that wires FileRouter into a TUIRuntime.
terminaltui create — Interactive Prompt Builder
Build a new TUI project from scratch using an interactive questionnaire:
terminaltui create
Asks 10 questions:
- Project name — becomes the
npx command name
- Description — what the site/app is about (detailed)
- Pages — list of pages (one per line)
- Content — real content to include, or "skip" to let AI generate it
- Theme — pick from 10 themes or "auto"
- Visual style — bold, minimal, retro, playful, professional
- ASCII art — scenes to include, or "auto"/"none"
- Interactive features — contact form, search, reservation, signup, newsletter, custom
- Animations — full, subtle, or none
- Extra instructions — anything else
Creates a project directory with:
TERMINALTUI_SKILL.md — full framework reference
TERMINALTUI_CREATE_PROMPT.md — tailored AI prompt from your answers
Then open Claude Code in that directory and paste the instructions.
When to use create vs init vs convert
| Command | Use when | What it does |
|---|
terminaltui init | You want a template with placeholder content | Scaffolds a project from a template |
terminaltui create | You want to describe something new and have AI build it | Generates a tailored AI prompt |
terminaltui convert | You already have a website to convert | Drops conversion docs into your project |
Themes
10 built-in themes. Use as string name or reference themes.themeName.
import { themes } from "terminaltui";
theme: themes.dracula
| Theme | Accent | Best For |
|---|
cyberpunk | #ff2a6d (hot pink) | Tech startups, gaming, futuristic |
dracula | #ff79c6 (pink) | General purpose, developer tools (default) |
nord | #88c0d0 (frost blue) | Corporate, professional, SaaS |
monokai | #f92672 (magenta) | Developer portfolios, coding tools |
solarized | #268bd2 (blue) | Academic, documentation, research |
gruvbox | #fe8019 (orange) | Restaurants, cafes, warm brands |
catppuccin | #f5c2e7 (pink) | Creative agencies, design portfolios |
tokyoNight | #7aa2f7 (blue) | Modern SaaS, product pages |
rosePine | #ebbcba (rose) | Music, art, personal blogs |
hacker | #00ff41 (green) | Security, infosec, Matrix-style |
Custom theme:
interface Theme {
accent: string;
accentDim: string;
text: string;
muted: string;
subtle: string;
success: string;
warning: string;
error: string;
border: string;
bg?: string;
}
theme: {
accent: "#e06c75", accentDim: "#be5046", text: "#abb2bf", muted: "#5c6370",
subtle: "#3e4452", success: "#98c379", warning: "#e5c07b", error: "#e06c75",
border: "#5c6370", bg: "#282c34",
}
Border Styles
type BorderStyle = "single" | "double" | "rounded" | "heavy" | "dashed" | "ascii" | "none";
Used in: defineConfig({ borders }), card({ border }), table({ border }).
ASCII Art System
ascii(text, options?): BannerConfig
Creates an ASCII art banner for the banner field of defineConfig().
Both forms are equivalent:
banner: ascii("My Site", { font: "ANSI Shadow", gradient: ["#ff6b6b", "#4ecdc4"] })
banner: { text: "My Site", font: "ANSI Shadow", gradient: ["#ff6b6b", "#4ecdc4"] }
interface BannerConfig {
text: string;
font?: string;
gradient?: string[];
align?: "left" | "center" | "right";
padding?: number;
shadow?: boolean;
border?: string | false;
width?: number;
}
banner: ascii("MY SITE", { font: "ANSI Shadow", gradient: ["#ff6b6b", "#4ecdc4"], shadow: true })
Fonts (14 built-in)
| Font | Height | Style |
|---|
"ANSI Shadow" | 6 | Clean block letters with shadow — modern default |
"Block" | 6 | Solid block characters — bold and heavy |
"Slant" | 6 | Classic italic/slanted — elegant |
"Calvin S" | 4 | Clean thin letters — professional, compact |
"Small" | 4 | Tiny but readable — space-constrained |
"Ogre" | 5 | Chunky and playful — fun, casual |
"DOS Rebel" | 10 | DOS-era block art — retro, nostalgic |
"Ghost" | 10 | Spooky hollow letters — horror, creative |
"Bloody" | 10 | Dripping horror letters — intense |
"Electronic" | 10 | Digital/LED style — tech, futuristic |
"Sub-Zero" | 10 | Icy/frozen appearance — cool, sharp |
"Larry 3D" | 10 | 3D perspective letters — eye-catching |
"Colossal" | 10 | Massive block letters — impactful |
"Isometric1" | 10 | Isometric 3D projection — unique |
Font names are case-sensitive. Use exactly as listed.
asciiArt.scene(type, options?): string[]
Pre-made decorative scenes. Returns string array.
type SceneType = "mountains" | "cityscape" | "forest" | "ocean" | "space"
| "clouds" | "coffee-cup" | "rocket" | "cat" | "robot" | "terminal"
| "vinyl-record" | "cassette" | "floppy-disk" | "gameboy";
interface SceneOptions { width?: number; color?: string; }
15 scenes:
- Landscapes:
mountains, cityscape, forest, ocean, space, clouds
- Objects:
coffee-cup, rocket, cat, robot, terminal
- Retro:
vinyl-record, cassette, floppy-disk, gameboy
const art = asciiArt.scene("mountains", { width: 60 });
getIcon(name, size?): string[] | undefined
Pre-made ASCII art icons. Size: "small" | "medium" | "large".
32 icons: laptop, briefcase, person, chain, chart, pen, music, star, globe, mail, code, terminal, folder, file, git, heart, check, cross, warning, film, camera, book, phone, pin, clock, users, cup, food, car, plane, fire, lightning
const icon = getIcon("terminal");
const icon = asciiArt.getIcon("terminal");
asciiArt.pattern(width, height, type, options?): string[]
Decorative fill patterns.
type PatternType = "dots" | "crosshatch" | "diagonal" | "waves" | "bricks"
| "circuit" | "rain" | "stars" | "confetti" | "static" | "braille-dots" | "grid";
interface PatternOptions { density?: number; seed?: number; }
12 patterns: dots, crosshatch, diagonal, waves, bricks, circuit, rain, stars, confetti, static, braille-dots, grid
const bg = asciiArt.pattern(40, 10, "circuit", { density: 0.5 });
Shapes (9)
All shapes return string[].
asciiArt.box(width: number, height: number, style?: "single"|"double"|"rounded"|"heavy"|"ascii"): string[]
asciiArt.circle(radius: number, fill?: string): string[]
asciiArt.diamond(size: number): string[]
asciiArt.triangle(height: number): string[]
asciiArt.heart(size: number): string[]
asciiArt.star(size: number): string[]
asciiArt.arrow(length: number, direction?: "right"|"left"|"up"|"down"): string[]
asciiArt.hexagon(size: number): string[]
asciiArt.line(length: number, style?: string): string[]
const box = asciiArt.box(20, 5, "rounded");
const heart = asciiArt.heart(5);
Data Visualization (5)
asciiArt.barChart(
data: { label: string; value: number }[],
options?: { width?: number; horizontal?: boolean; showValues?: boolean; maxBarWidth?: number }
): string[]
asciiArt.sparkline(data: number[], width?: number): string[]
asciiArt.heatmap(
data: number[][],
options?: { chars?: string; showScale?: boolean }
): string[]
asciiArt.pieChart(
data: { label: string; value: number }[],
radius?: number
): string[]
asciiArt.graph(data: number[], width?: number, height?: number): string[]
const chart = asciiArt.barChart([
{ label: "TypeScript", value: 85 },
{ label: "Rust", value: 70 },
], { width: 50 });
const spark = asciiArt.sparkline([1, 5, 3, 8, 2, 7], 30);
const heat = asciiArt.heatmap([[1,2,3],[4,5,6],[7,8,9]], { showScale: true });
const pie = asciiArt.pieChart([{ label: "A", value: 60 }, { label: "B", value: 40 }], 6);
const g = asciiArt.graph([10, 20, 15, 30, 25], 40, 10);
asciiImage(source, options?): Promise<string[]>
Convert images to ASCII art. Requires sharp peer dependency.
interface AsciiImageOptions {
width?: number;
height?: number;
mode?: "ascii" | "braille" | "blocks" | "shading";
charset?: string;
invert?: boolean;
color?: boolean;
dithering?: "none" | "floyd-steinberg" | "ordered";
threshold?: number;
}
const art = await asciiImage("./logo.png", { width: 40, mode: "braille", color: true });
Animations
interface AnimationConfig {
boot?: boolean;
exitMessage?: string;
speed?: "slow" | "normal" | "fast";
}
animations: {
boot: true,
exitMessage: "[ end of transmission ]",
speed: "normal",
}
Middleware
middleware(fn: MiddlewareFn): MiddlewareFn
redirect(pageId: string, params?: RouteParams): { redirect: string; params?: RouteParams }
type MiddlewareFn = (context: MiddlewareContext) => Promise<MiddlewareResult> | MiddlewareResult;
interface MiddlewareContext {
page: string;
params: RouteParams;
state: any;
}
type MiddlewareResult = void | undefined | { redirect: string; params?: RouteParams };
Built-in middleware:
requireEnv(vars: string[]): MiddlewareFn
rateLimit({ maxRequests, windowMs }): MiddlewareFn
defineConfig({
name: "My Site",
middleware: [requireEnv(["API_KEY"]), rateLimit({ maxRequests: 100, windowMs: 60000 })],
});
import { middleware, redirect, markdown } from "terminaltui";
export const metadata = {
label: "Admin",
middleware: [middleware(async (ctx) => {
if (!isAdmin(ctx.state)) return redirect("home");
})],
};
export default function Admin() { return [markdown("admin only")]; }
Environment & Config
.env files are auto-loaded on startup.
defineEnv() is for environment variables. Do not confuse with defineConfig() which is for site config (file-based routing).
import { defineEnv } from "terminaltui";
const env = defineEnv({
apiUrl: { env: "API_URL", default: "http://localhost:3000" },
apiKey: { env: "API_KEY", required: true },
debug: { env: "DEBUG", default: false, transform: (v) => v === "true" },
});
env.get("apiUrl");
env.get("apiKey");
env.get("debug");
interface ConfigField<T> {
env: string;
default?: T;
required?: boolean;
transform?: (value: string) => T;
}
Summary: defineConfig() = site config (file-based routing, theme, menu). defineEnv() = environment variables (API keys, URLs, feature flags).
Lifecycle Hooks
Set on SiteConfig. All receive an AppContext.
interface AppContext {
state: any;
navigate: (pageId: string, params?: RouteParams) => void;
}
interface ErrorContext {
page?: string;
params?: RouteParams;
phase: "render" | "middleware" | "action" | "fetch";
}
defineConfig({
name: "My Site",
onInit: async (app) => { },
onExit: async (app) => { },
onNavigate: (from, to, params) => { },
onError: (error, context) => {
return [markdown(`Error on ${context.page}: ${error.message}`)];
},
});
CLI Commands
terminaltui init [template]
terminaltui dev [path]
terminaltui serve [path]
terminaltui build
terminaltui test [--cols=N] [--sizes] [--verbose]
terminaltui art list|preview|create|validate
terminaltui validate
SSH Hosting (terminaltui serve)
Host any TUI app over SSH so users connect with ssh host -p PORT — zero install required.
terminaltui serve --port 2222
Flags:
--port <N> — SSH port (default: 2222)
--host-key <path> — host key path (default: .terminaltui/host_key, auto-generated)
--max-connections <N> — max simultaneous connections (default: 100)
Each SSH connection gets an independent TUI session with its own state. Requires ssh2 (npm install ssh2).
Navigation & Keybindings
Navigation mode (default):
| Key | Action |
|---|
Up / k | Move selection up |
Down / j | Move selection down |
Enter | Select item / enter page / enter edit mode on input |
Escape / Backspace | Go back / exit page |
1-9 | Jump to page by number (if numberJump: true) |
q / Ctrl+C | Quit |
Left / h | Previous panel (in layouts) / go back (non-layout pages) |
Right / l | Next panel (in layouts) / select (non-layout pages) |
Tab | Next panel (in layouts) / next focusable element |
Shift+Tab | Previous panel (in layouts) / previous focusable element |
Home | First item |
End | Last item |
Edit mode (when editing an input):
| Key | Action |
|---|
Escape | Exit edit mode, return to navigation |
Enter | Submit (in forms) / confirm selection |
Tab | Next field |
Up/Down | Navigate options (select, radio) |
Space | Toggle (checkbox, toggle) |
| Standard typing | Text input |
TUI Emulator (Testing)
Headless terminal emulator for automated testing. Think Puppeteer for terminal apps.
import { TUIEmulator } from "terminaltui";
const emu = await TUIEmulator.launch({
command: "terminaltui dev",
cwd: "./my-site",
cols: 80,
rows: 24,
timeout: 30000,
});
await emu.waitForBoot();
await emu.waitForText("About");
await emu.waitForTextGone("Loading...");
await emu.waitForIdle(500);
await emu.waitFor(() => emu.screen.contains("Ready"));
await emu.press("down");
await emu.press("down", { times: 3 });
await emu.pressSequence(["down", "down", "enter"]);
await emu.type("hello world");
await emu.navigateTo("About");
emu.screen.text();
emu.screen.ansi();
emu.screen.contains("text");
emu.screen.find("text");
emu.screen.currentPage();
emu.screen.menu();
emu.screen.cards();
emu.screen.links();
emu.assert.textVisible("About");
emu.screenshot();
emu.snapshot();
await emu.close();
Content Extraction Guide
Component Mapping
IMPORTANT: The "Best TUI Pattern" column takes priority. TUI navigation is up/down arrows — optimize for vertical scrolling and per-item focusability, not semantic content matching.
| Web Content | Semantic Match | Best TUI Pattern |
|---|
| Navigation / menu | Becomes pages array | pages array (automatic) |
| Hero / banner section | hero() | hero() — focusable if CTA set |
| Cards / grid of items | card() | Individual card() blocks (each focusable) |
| Pricing tables | table() | table() — passive, fine for dense data |
| Testimonials / reviews | quote() | quote() — passive, or card() if users need to browse |
| Work history / experience | timeline() | Individual card() blocks (each focusable, scrollable) |
| Education entries | timeline() | Individual card() blocks (subtitle=dates) |
| FAQ / collapsible sections | accordion() | accordion() — each item is focusable + expandable |
| Sectioned page content | tabs() | divider("Label") + content below (vertical scroll) |
| Toggle between views | tabs() | tabs() — only for mutually exclusive views |
| Blog post list | card() | Individual card() blocks (subtitle=date, body=excerpt) |
| Contact / social links | link() | link() per item (each focusable) |
| Stats / metrics / skills | skillBar() | skillBar() or progressBar() — passive display |
| Features list | card() | Individual card() blocks with tags |
| Menu items (restaurant) | section() + card() | divider("Category") + card() items (subtitle=price) |
| Documentation / prose | markdown() | markdown() — passive text |
| Data / comparison | table() | table() — passive dense data |
| Step-by-step instructions | list() | list("number") — passive, or accordion() for expandable |
| Search functionality | searchInput() | searchInput() with action: "navigate" |
| Contact forms | form() | form() with inputs + button |
| Interactive charts | asciiArt.barChart() | Custom block with dataviz functions |
| Real-time data | liveData() + dynamic() | dynamic() blocks with fetcher/state |
Theme Selection Guide
| Theme | Best For |
|---|
cyberpunk | Tech startups, gaming, futuristic — neon, electric |
dracula | Developer tools, general purpose — classic dark (default) |
nord | Corporate, professional, SaaS — clean, minimal |
monokai | Coding tools, dev portfolios — code-editor feel |
solarized | Academic, documentation, research — scholarly |
gruvbox | Restaurants, cafes, crafts — earthy, warm, retro |
catppuccin | Creative agencies, design — soft, pastel, friendly |
tokyoNight | Modern SaaS, product pages — sleek, contemporary |
rosePine | Music, art, creative portfolios — dreamy, elegant |
hacker | Security, CTF, infosec — green-on-black, Matrix |
ASCII Art Selection Guide
- Banners: Use
ascii() with a font. Short names (1-2 words) work best. "ANSI Shadow" for modern, "Calvin S" for compact, "Ogre" for fun, "Slant" for elegant.
- Hero art: Use
asciiArt.scene() — mountains for outdoors, cityscape for urban, rocket for startups, terminal for dev tools, coffee-cup for cafes.
- Decorative icons: Use
getIcon() — laptop for tech, music for bands, cup for restaurants, heart for personal, code for dev.
- Backgrounds/borders: Use
asciiArt.pattern() — circuit for tech, stars for night themes, waves for ocean.
- Data display: Use
asciiArt.barChart() for comparisons, asciiArt.sparkline() for trends, asciiArt.pieChart() for proportions, asciiArt.graph() for time series.
- Composition: Combine ASCII art using string-array helpers from your own code (e.g. zip with
padEnd); the framework no longer ships a dedicated compose API.
Complete Example: a small portfolio
A complete project is a config.ts plus one file per page under pages/. Below: a 3-page portfolio.
import { defineConfig, ascii } from "terminaltui";
export default defineConfig({
name: "Alex Chen",
handle: "@alexchen",
tagline: "full-stack engineer & open source contributor",
banner: ascii("Alex Chen", { font: "ANSI Shadow", gradient: ["#ff6b6b", "#4ecdc4"] }),
theme: "dracula",
borders: "rounded",
animations: { boot: true, exitMessage: "See you in the terminal!" },
});