tui
Create distinctive, production-grade terminal user interfaces (TUIs) using OpenTUI. Use this skill when the user asks to build CLI tools, terminal dashboards, interactive console applications, or any interface that runs in the terminal.
$ 설치
git clone https://github.com/garymjr/dotfiles /tmp/dotfiles && cp -r /tmp/dotfiles/dot_pi/agent/skills/tui ~/.claude/skills/dotfiles// tip: Run this command in your terminal to install the skill
name: tui description: Create distinctive, production-grade terminal user interfaces (TUIs) using OpenTUI. Use this skill when the user asks to build CLI tools, terminal dashboards, interactive console applications, or any interface that runs in the terminal.
This skill guides creation of distinctive, production-grade terminal user interfaces that avoid generic "boring CLI" patterns. Build real working TUIs with OpenTUI that are functional, beautiful, and memorable.
Critical Prerequisites
TUI Design Thinking
Before coding, understand the context and commit to a CLEAR design direction:
- Purpose: What problem does this TUI solve? Who uses it? Is it a single-use tool or daily driver?
- Tone: Pick a direction: clean/minimal, data-dense/dashboard-like, playful/interactive, brutalist/raw, retro-terminal, modern/polished. Each tone has different trade-offs in complexity vs. usability.
- Constraints: Terminal size considerations, input methods (keyboard shortcuts, mouse), performance requirements.
- Differentiation: What makes this TUI memorable? What's the interaction pattern that stands out?
CRITICAL: Choose a clear design direction and execute it. Terminal interfaces benefit from restraint and clarity - don't overcomplicate simple tasks, but don't under-deliver on complex ones.
OpenTUI Fundamentals
OpenTUI provides a single core package with multiple ways to build:
- @opentui/core: The main library with primitives like
BoxRenderable,TextRenderable,InputRenderable, and declarative constructs likeBox(),Text(),Input(). This is what you use.
Installation:
bun install @opentui/core
Two Approaches: Imperative vs Declarative
Imperative (Renderables): Create instances with new and compose via .add():
import { createCliRenderer, BoxRenderable, TextRenderable } from '@opentui/core';
const renderer = await createCliRenderer();
const box = new BoxRenderable(renderer, { id: 'my-box', width: 20, height: 10 });
const text = new TextRenderable(renderer, { content: 'Hello' });
box.add(text);
renderer.root.add(box);
renderer.start();
Declarative (Constructs/VNodes): Use function calls that return VNodes:
import { createCliRenderer, Box, Text } from '@opentui/core';
const renderer = await createCliRenderer();
const myBox = Box(
{ id: 'my-box', width: 20, height: 10 },
Text({ content: 'Hello' })
);
renderer.root.add(myBox);
renderer.start();
Choose based on:
- Use Declarative (Constructs) for most TUIs - cleaner, composable, easier to reason about
- Use Imperative (Renderables) when you need direct control over instances or are migrating from low-level code
IMPORTANT: Both approaches require createCliRenderer() and renderer.root.add() - nothing renders without these.
Terminal Aesthetics Guidelines
Focus on:
- Typography: Use monospace fonts (your terminal handles this), but leverage text styling (bold, dim, italic, underline) strategically. Don't over-style - terminal clutter is worse than terminal minimalism.
- Color & Theme: Commit to a cohesive palette. Use
RGBA.fromHex()or color strings sparingly - 2-4 accent colors work best. Ensure high contrast for readability. Support dark themes by default; light themes are optional. - Layout: Use Yoga flexbox-like properties (
flexDirection,justifyContent,alignItems,flexGrow,padding) to create hierarchy. Respect terminal width (typically 80-120 chars) - design for 80 as baseline, expand gracefully. - Motion: Use
renderAfterhooks for custom animations. Keep them subtle - terminal motion should feel responsive, not flashy. - Navigation: Design for keyboard-first. Arrow keys for navigation, Enter for selection, Esc for cancel/back. Use mnemonics (e.g., press 'q' to quit) for frequent actions. Document key bindings visibly.
- State Management: Keep state minimal and local. For complex TUIs, use simple objects or arrays to track state, and call
renderer.requestRender()after updates.
NEVER use generic TUI patterns like unstyled text dumps, confusing keyboard shortcuts, invisible state changes, or poor handling of terminal resize.
Essential OpenTUI Patterns
1. Basic Setup (Required for Every TUI)
import { createCliRenderer } from '@opentui/core';
// Create the renderer
const renderer = await createCliRenderer({
exitOnCtrlC: true, // Automatically exit on Ctrl+C
targetFps: 30, // Target framerate for animations
});
// Set background color for the entire terminal
renderer.setBackgroundColor('#001122');
// ... add your renderables here ...
// Start the rendering loop
renderer.start();
2. Keyboard Input Handling
import { type KeyEvent } from '@opentui/core';
// Access the keyboard handler
renderer.keyInput.on('keypress', (key: KeyEvent) => {
console.log('Key name:', key.name);
console.log('Ctrl pressed:', key.ctrl);
console.log('Shift pressed:', key.shift);
console.log('Alt pressed:', key.meta);
console.log('Option pressed:', key.option);
if (key.name === 'escape') {
// Handle escape
} else if (key.ctrl && key.name === 'c') {
// Handle Ctrl+C (if exitOnCtrlC is false)
} else if (key.name === 'q') {
// Quit the application
process.exit(0);
}
});
3. Box Layout (Declarative)
import { Box, Text } from '@opentui/core';
// A container with flexbox layout
const container = Box({
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
padding: 1,
backgroundColor: '#333366',
border: true,
borderStyle: 'double',
borderColor: '#FFFFFF',
title: 'My Panel',
titleAlignment: 'center',
});
renderer.root.add(container);
4. Text with Styling
import { Text, TextAttributes, t, bold, underline, fg } from '@opentui/core';
// Simple styled text
const text1 = Text({
content: 'Important Message',
fg: '#FFFF00',
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
});
// Using the template literal for complex styled text
const text2 = Text({
content: t`${bold("Important")} ${fg("#FF0000")(underline("Alert"))}`,
});
renderer.root.add(text1);
renderer.root.add(text2);
5. Input Fields
import { InputRenderable, InputRenderableEvents } from '@opentui/core';
const nameInput = new InputRenderable(renderer, {
id: 'name-input',
width: 30,
height: 3,
placeholder: 'Enter your name...',
placeholderColor: '#666666',
backgroundColor: '#001122',
textColor: '#FFFFFF',
cursorColor: '#FFFF00',
focusedBackgroundColor: '#1a1a1a',
position: 'absolute',
left: 10,
top: 5,
});
// Listen for changes
nameInput.on(InputRenderableEvents.CHANGE, (value) => {
console.log('Input changed:', value);
});
// Listen for Enter key
nameInput.on(InputRenderableEvents.ENTER, (value) => {
console.log('Submitted:', value);
});
renderer.root.add(nameInput);
nameInput.focus();
6. Select Menu
import { SelectRenderable, SelectRenderableEvents, type SelectOption } from '@opentui/core';
const menu = new SelectRenderable(renderer, {
id: 'menu',
width: 30,
height: 8,
options: [
{ name: 'New File', description: 'Create a new file' },
{ name: 'Open File', description: 'Open an existing file' },
{ name: 'Save', description: 'Save current file' },
{ name: 'Exit', description: 'Exit the application' },
],
position: 'absolute',
left: 5,
top: 3,
});
menu.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log('Selected:', option.name);
});
renderer.root.add(menu);
menu.focus();
// Default keybindings: up/k and down/j to navigate, enter to select
7. Focus Management and Delegation (Declarative)
When using constructs (VNodes), focus management can be tricky with nested components. Use delegate to specify which descendant should receive method calls:
import { Box, Text, Input, delegate } from '@opentui/core';
// A labeled input component
function LabeledInput(props: { id: string; label: string; placeholder: string }) {
return delegate(
{
focus: `${props.id}-input`, // Focus should go to the input, not the container
},
Box(
{ flexDirection: 'row', id: `${props.id}-labeled-outer` },
Text({ content: props.label + ' ' }),
Input({
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
backgroundColor: 'white',
textColor: 'black',
cursorColor: 'blue',
focusedBackgroundColor: 'orange',
}),
),
);
}
// Now you can focus it directly
const usernameInput = LabeledInput({ id: 'username', label: 'Username:', placeholder: 'Enter username...' });
usernameInput.focus(); // This works thanks to delegate!
renderer.root.add(usernameInput);
8. Colors (RGBA)
import { RGBA, parseColor } from '@opentui/core';
// Multiple ways to create colors
const red1 = RGBA.fromInts(255, 0, 0, 255); // RGB integers (0-255)
const red2 = RGBA.fromValues(1.0, 0.0, 0.0, 1.0); // Float values (0.0-1.0)
const red3 = RGBA.fromHex('#FF0000'); // Hex string
const red4 = parseColor('#FF0000'); // Parse any color format
const transparent = RGBA.fromValues(1.0, 1.0, 1.0, 0.5); // Semi-transparent
Common TUI Components
Build these patterns as needed:
List View (Scrollable)
// Use SelectRenderable with many options, it handles scrolling automatically
const list = new SelectRenderable(renderer, {
id: 'list',
width: 40,
height: 10,
options: Array.from({ length: 50 }, (_, i) => ({
name: `Item ${i + 1}`,
description: `Description for item ${i + 1}`,
})),
position: 'absolute',
left: 5,
top: 5,
});
renderer.root.add(list);
Form with Validation
const nameInput = new InputRenderable(renderer, {
id: 'name',
width: 30,
placeholder: 'Name (min 2 chars)',
position: 'absolute',
left: 5,
top: 2,
});
const emailInput = new InputRenderable(renderer, {
id: 'email',
width: 30,
placeholder: 'Email address',
position: 'absolute',
left: 5,
top: 6,
});
function validateName(value: string): boolean {
return value.length >= 2;
}
function validateEmail(value: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
nameInput.on(InputRenderableEvents.ENTER, (value) => {
if (validateName(value)) {
console.log('Name is valid!');
} else {
console.log('Name must be at least 2 characters');
}
});
renderer.root.add(nameInput);
renderer.root.add(emailInput);
nameInput.focus();
Dashboard Layout
import { Box, Text } from '@opentui/core';
const mainLayout = Box({
flexDirection: 'column',
width: '100%',
height: '100%',
padding: 1,
});
const header = Box({
height: 3,
border: true,
marginBottom: 1,
justifyContent: 'center',
backgroundColor: '#3b82f6',
});
const content = Box({
flexDirection: 'row',
flexGrow: 1,
});
const sidebar = Box({
width: 20,
border: true,
marginRight: 1,
backgroundColor: '#64748b',
});
const main = Box({
flexGrow: 1,
border: true,
backgroundColor: '#333333',
});
const footer = Box({
height: 3,
border: true,
marginTop: 1,
justifyContent: 'center',
backgroundColor: '#1e40af',
});
// Assemble the layout
header.add(Text({ content: 'Dashboard', attributes: TextAttributes.BOLD }));
mainLayout.add(header);
sidebar.add(Text({ content: 'Sidebar' }));
content.add(sidebar);
main.add(Text({ content: 'Main Content Area' }));
content.add(main);
mainLayout.add(content);
footer.add(Text({ content: 'Press q to quit' }));
mainLayout.add(footer);
renderer.root.add(mainLayout);
Confirmation Modal
import { Box, Text } from '@opentui/core';
let modalVisible = false;
let confirmCallback: (() => void) | null = null;
function showModal(message: string, onConfirm: () => void) {
modalVisible = true;
confirmCallback = onConfirm;
const modal = Box({
position: 'absolute',
width: 40,
height: 8,
left: Math.floor((renderer.terminalWidth - 40) / 2),
top: Math.floor((renderer.terminalHeight - 8) / 2),
border: true,
borderStyle: 'double',
backgroundColor: '#444',
zIndex: 1000,
id: 'modal',
});
modal.add(Text({ content: message, padding: 1 }));
modal.add(Text({ content: '[Enter] Yes [Esc] No', justifyContent: 'center' }));
renderer.root.add(modal);
}
function hideModal() {
modalVisible = false;
const modal = renderer.root.getRenderable('modal');
if (modal) {
renderer.root.remove('modal');
}
}
// In your key handler:
renderer.keyInput.on('keypress', (key) => {
if (modalVisible) {
if (key.name === 'return' && confirmCallback) {
confirmCallback();
hideModal();
} else if (key.name === 'escape') {
hideModal();
}
}
});
Best Practices
-
Handle Terminal Resize: Use flexible layouts with
width: '100%'and flex properties. The renderer emits 'resize' events:renderer.on('resize', (width, height) => { console.log(`Terminal resized to ${width}x${height}`); // Adjust layouts if needed }); -
Exit Gracefully: Always provide a way to quit (Ctrl+C or 'q'). Use
exitOnCtrlC: truein createCliRenderer options for automatic handling. -
Error Handling: Show error messages clearly, don't crash silently. Use the built-in console overlay for debugging:
const renderer = await createCliRenderer({ consoleOptions: { position: 'bottom', sizePercent: 20, startInDebugMode: false, }, }); // Toggle console with '?' renderer.keyInput.on('keypress', (key) => { if (key.name === 'question_mark') { renderer.console.toggle(); } }); console.log('This appears in the overlay'); console.error('Errors are color-coded red'); -
Performance: Batch updates, avoid excessive re-renders. Call
renderer.requestRender()when you've updated multiple renderables. -
Accessibility: High contrast, clear focus states, keyboard-only navigation. Focus indicators are automatic when using Input/Select renderables.
-
Cleanup: When done, remove renderables:
export function destroy(renderer: CliRenderer) { renderer.root.getRenderable('main-group')?.destroyRecursively(); renderer.requestRender(); }
Quick Start Template
import { createCliRenderer, Box, Text, InputRenderable, InputRenderableEvents, type KeyEvent } from '@opentui/core';
async function main() {
const renderer = await createCliRenderer({
exitOnCtrlC: true,
targetFps: 30,
});
renderer.setBackgroundColor('#001122');
// Create a simple input form
const container = Box({
flexDirection: 'column',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
});
renderer.root.add(container);
const title = Text({
content: 'OpenTUI Demo',
attributes: TextAttributes.BOLD,
fg: '#00FF00',
marginBottom: 2,
});
container.add(title);
const input = new InputRenderable(renderer, {
id: 'demo-input',
width: 30,
height: 3,
placeholder: 'Type something...',
backgroundColor: '#001122',
textColor: '#FFFFFF',
cursorColor: '#FFFF00',
});
renderer.root.add(input);
input.focus();
const output = Text({
content: '',
marginTop: 1,
});
container.add(output);
// Handle input
input.on(InputRenderableEvents.CHANGE, (value) => {
output.content = `You typed: ${value}`;
});
// Keyboard shortcuts
renderer.keyInput.on('keypress', (key: KeyEvent) => {
if (key.name === 'q') {
process.exit(0);
}
});
// Add instructions
const instructions = Text({
content: 'Press Ctrl+C or q to quit',
marginTop: 2,
fg: '#666666',
});
container.add(instructions);
renderer.start();
}
main();
Common Pitfalls to Avoid
-
Forgetting to call renderer.root.add(): Renderables/VNodes won't appear without this.
-
Forgetting to call renderer.start(): The rendering loop won't run without this.
-
Trying to focus nested inputs without delegate(): Use the
delegate()helper to focus elements inside containers. -
Not handling terminal resize: Use flexible layouts or listen to resize events.
-
Overusing colors: Stick to 2-4 accent colors for a cohesive look.
-
Not providing a way to quit: Always have Ctrl+C or 'q' to exit.
-
Ignoring focus states: Users need to know what element is active.
-
Using JSX syntax: OpenTUI uses function calls
Box({...}, children), NOT<Box>...</Box>.
When to Use This Skill
Use when building:
- Interactive CLI tools with menus or forms
- Terminal dashboards for monitoring
- Interactive wizards or configuration tools
- File managers or navigation interfaces
- Any application that lives primarily in the terminal
Start simple. Add complexity only when needed. Remember: a working TUI that's simple beats a broken one that's ambitious.
