opentui-dev
OpenTUI Development Skill
This skill provides comprehensive guidance for building terminal user interfaces (TUIs) using OpenTUI, a TypeScript library for creating component-based terminal applications with React or Core API.
When to use this skill
Use this skill when:
- Building a new terminal user interface (TUI) or CLI application
- Working with OpenTUI-based projects
- Creating interactive terminal components (inputs, selects, lists, etc.)
- Implementing keyboard navigation and event handling in terminals
- Adding syntax highlighting or code display in terminal UIs
- Creating diff viewers, text editors, or terminal-based forms
- Styling terminal applications with borders, colors, and flexbox layouts
- Debugging or enhancing OpenTUI applications
- User mentions: TUI, terminal UI, CLI app, OpenTUI, terminal components
What is OpenTUI
OpenTUI is a TypeScript framework for building terminal user interfaces with:
- Component-based architecture: Similar to web frameworks, compose UIs from reusable components
- React integration: Use familiar React patterns with JSX for declarative terminal UIs
- Flexbox layouts: CSS Flexbox-like layout system powered by Yoga layout engine
- Rich components: Built-in inputs, selects, code viewers, diff viewers, and more
- Type-safe: Full TypeScript support with strict mode
- High performance: Native Zig rendering layer for optimal performance
Core Packages
@opentui/core- Core library with imperative API and primitives (standalone)@opentui/react- React reconciler for declarative UI development@opentui/solid- SolidJS reconciler (alternative framework)
Quick Start
Creating a new OpenTUI React project
bun create tui --template react
cd my-tui-app
bun install
bun run index.tsx
Manual installation
bun install @opentui/core @opentui/react react
Basic "Hello World" example
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return (
<box style={{ padding: 2 }}>
<text fg="#00FF00">Hello, OpenTUI!</text>
</box>
)
}
const renderer = await createCliRenderer({ useAlternateScreen: true })
createRoot(renderer).render(<App />)
renderer.start()
TypeScript Configuration
OpenTUI requires specific TypeScript settings:
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true
}
}
Key settings:
jsxImportSource: "@opentui/react"- Use OpenTUI's JSX factorymoduleResolution: "bundler"- Required for Bunstrict: true- Enable strict type checking
Core Concepts
1. Renderer
The renderer manages terminal output, input events, and the rendering loop.
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({
exitOnCtrlC: true, // Exit on Ctrl+C (default: true)
targetFps: 30, // Target frame rate
maxFps: 60, // Maximum frame rate
useAlternateScreen: true, // Use alternate screen (full-screen)
useMouse: true, // Enable mouse support
backgroundColor: "#000000", // Background color
useConsole: true, // Enable console overlay
})
// Start the render loop
renderer.start()
Rendering Modes:
- Live Mode: Call
renderer.start()for continuous rendering at target FPS - On-Demand: Without
start(), renders only when changes occur
2. Layout System (Flexbox)
OpenTUI uses Yoga for CSS Flexbox-like layouts:
<box
flexDirection="row" // row | column | row-reverse | column-reverse
justifyContent="center" // flex-start | flex-end | center | space-between | space-around
alignItems="center" // flex-start | flex-end | center | stretch | baseline
gap={2} // Gap between children
flexGrow={1} // Grow factor
flexShrink={1} // Shrink factor
flexBasis="auto" // Base size
flexWrap="wrap" // no-wrap | wrap | wrap-reverse
>
{/* Children */}
</box>
Size Properties:
width={40} // Fixed width in characters
height={10} // Fixed height in lines
width="50%" // Percentage of parent
width="auto" // Auto-size based on content
minWidth={20} // Minimum width
maxWidth={80} // Maximum width
Spacing:
padding={2} // All sides
paddingLeft={1} // Individual sides
margin={1} // Margin (all sides)
marginTop={2} // Individual margins
Position:
position="relative" // relative | absolute
top={5} // Position from top
left={10} // Position from left
zIndex={999} // Stack order
3. Colors
Colors in OpenTUI use RGBA format or hex strings:
import { RGBA } from "@opentui/core"
// Hex strings (most common)
<text fg="#FFFFFF" bg="#000000">Text</text>
// RGBA objects
const red = RGBA.fromHex("#FF0000")
const blue = RGBA.fromInts(0, 0, 255, 255) // RGB 0-255
const transparent = RGBA.fromValues(1.0, 1.0, 1.0, 0.5) // RGBA 0.0-1.0
4. Text Attributes
Style text with bold, italic, underline, etc.:
import { TextAttributes } from "@opentui/core"
<text attributes={TextAttributes.BOLD}>Bold text</text>
<text attributes={TextAttributes.BOLD | TextAttributes.ITALIC}>Bold italic</text>
// Available attributes:
// BOLD, DIM, ITALIC, UNDERLINE, BLINK, INVERSE, HIDDEN, STRIKETHROUGH
React Components
Box Component
Container with borders, backgrounds, and layout:
<box
title="Panel Title"
border // Show border
borderStyle="single" // single | double | rounded | bold | classic
borderColor="#FFFFFF"
backgroundColor="#1a1a1a"
focused={true} // Highlight when focused
padding={2}
flexDirection="column"
width={40}
height={10}
>
<text>Content</text>
</box>
Border Styles:
single- Single line (─│┌┐└┘)double- Double line (═║╔╗╚╝)rounded- Rounded corners (─│╭╮╰╯)bold- Bold/thick lineclassic- ASCII style (+-|)
Text Component
Display text with styling:
// Simple text
<text fg="#FFFFFF" bg="#000000">Simple text</text>
// Rich text with children
<text>
<span fg="#FF0000">Red text</span>
{" "}
<strong>Bold</strong>
{" "}
<em>Italic</em>
{" "}
<u>Underlined</u>
<br />
<a href="https://example.com">Link</a>
</text>
// Formatted content
<text content="Preformatted content" fg="#00FF00" />
Input Component
Single-line text input:
const [value, setValue] = useState("")
<input
placeholder="Type here..."
value={value}
focused={true} // Must be focused to receive input
onInput={setValue} // Called on every keystroke
onSubmit={(val) => { // Called on Enter key
console.log("Submitted:", val)
}}
style={{
backgroundColor: "#1a1a1a",
focusedBackgroundColor: "#2a2a2a",
}}
/>
Textarea Component
Multi-line text editor:
import { useRef } from "react"
import type { TextareaRenderable } from "@opentui/core"
const textareaRef = useRef<TextareaRenderable>(null)
<textarea
ref={textareaRef}
placeholder="Type here..."
focused={true}
initialValue="Initial text"
style={{ width: 60, height: 10 }}
/>
// Access value: textareaRef.current?.getText()
Select Component
List selection with scrolling:
import type { SelectOption } from "@opentui/core"
const options: SelectOption[] = [
{ name: "Option 1", description: "First option", value: "opt1" },
{ name: "Option 2", description: "Second option", value: "opt2" },
{ name: "Option 3", description: "Third option", value: "opt3" },
]
<select
options={options}
focused={true}
onChange={(index, option) => {
console.log(`Selected ${index}:`, option)
}}
showScrollIndicator
style={{ height: 10 }}
/>
Navigation:
- Up/Down arrows - Move selection
- Enter - Confirm selection
- Auto-scrolls to keep selection visible
Tab Select Component
Horizontal tab selection:
<tab-select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
{ name: "Help", description: "Documentation" },
]}
focused={true}
onChange={(index, option) => {
console.log("Tab changed:", option.name)
}}
tabWidth={20}
/>
Scrollbox Component
Scrollable container for long content:
<scrollbox
focused={true}
style={{
height: 20,
rootOptions: { backgroundColor: "#24283b" },
wrapperOptions: { backgroundColor: "#1f2335" },
viewportOptions: { backgroundColor: "#1a1b26" },
contentOptions: { backgroundColor: "#16161e" },
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
{/* Long scrollable content */}
<text>Line 1</text>
<text>Line 2</text>
{/* ... many more lines */}
</scrollbox>
Code Component
Display code with syntax highlighting:
import { SyntaxStyle, RGBA } from "@opentui/core"
const syntaxStyle = SyntaxStyle.fromStyles({
keyword: { fg: RGBA.fromHex("#ff6b6b"), bold: true },
string: { fg: RGBA.fromHex("#51cf66") },
comment: { fg: RGBA.fromHex("#868e96"), italic: true },
function: { fg: RGBA.fromHex("#4dabf7") },
number: { fg: RGBA.fromHex("#ffd43b") },
})
<code
content={codeString}
filetype="typescript" // javascript, python, rust, go, etc.
syntaxStyle={syntaxStyle}
/>
Line Number Component
Code with line numbers and diff highlights:
import { useRef, useEffect } from "react"
import type { LineNumberRenderable } from "@opentui/core"
const lineNumberRef = useRef<LineNumberRenderable>(null)
useEffect(() => {
// Highlight added line
lineNumberRef.current?.setLineColor(1, "#1a4d1a")
lineNumberRef.current?.setLineSign(1, { after: " +", afterColor: "#22c55e" })
// Highlight deleted line
lineNumberRef.current?.setLineColor(5, "#4d1a1a")
lineNumberRef.current?.setLineSign(5, { after: " -", afterColor: "#ef4444" })
// Add diagnostic warning
lineNumberRef.current?.setLineSign(10, { before: "⚠️", beforeColor: "#f59e0b" })
}, [])
<line-number
ref={lineNumberRef}
fg="#6b7280"
bg="#161b22"
minWidth={3}
showLineNumbers={true}
>
<code content={codeContent} filetype="typescript" />
</line-number>
Diff Component
Unified or split diff viewer:
<diff
oldContent={oldCode}
newContent={newCode}
filetype="typescript"
syntaxStyle={syntaxStyle}
viewMode="unified" // unified | split
/>
ASCII Font Component
Display ASCII art text:
<ascii-font
text="OPENTUI"
font="block" // tiny | block | slick | shade
color={RGBA.fromHex("#00FF00")}
/>
React Hooks
useRenderer()
Access the renderer instance:
import { useRenderer } from "@opentui/react"
const renderer = useRenderer()
// Show/hide console overlay
renderer.console.toggle()
renderer.console.show()
renderer.console.hide()
useKeyboard(handler, options?)
Handle keyboard events:
import { useKeyboard } from "@opentui/react"
import type { KeyEvent } from "@opentui/core"
useKeyboard((key: KeyEvent) => {
console.log("Key:", key.name)
console.log("Modifiers:", { ctrl: key.ctrl, shift: key.shift, alt: key.meta })
if (key.name === "escape") {
process.exit(0)
}
if (key.ctrl && key.name === "s") {
// Save action
}
}, { release: false }) // Set release: true for key release events
Key Event Properties:
name- Key name (e.g., "a", "enter", "escape", "up", "down")ctrl- Ctrl key pressedshift- Shift key pressedmeta- Alt/Meta key pressed (Linux/Windows)option- Option key pressed (macOS)sequence- Raw key sequence
useTerminalDimensions()
Get current terminal size (auto-updates on resize):
import { useTerminalDimensions } from "@opentui/react"
const { width, height } = useTerminalDimensions()
return (
<box style={{ width, height }}>
<text>{`Terminal: ${width}x${height}`}</text>
</box>
)
useOnResize(callback)
Handle terminal resize events:
import { useOnResize } from "@opentui/react"
useOnResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
useTimeline(options?)
Create animations:
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
autoplay: true,
})
useEffect(() => {
timeline.add(
{ width },
{
width: 50,
duration: 2000,
ease: "linear",
onUpdate: (animation) => {
setWidth(animation.targets[0].width)
},
}
)
}, [])
return <box style={{ width }}><text>Animating...</text></box>
Common Patterns
Multi-view Application
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
type View = "home" | "settings" | "help"
function App() {
const [view, setView] = useState<View>("home")
useKeyboard((key) => {
if (key.name === "1") setView("home")
if (key.name === "2") setView("settings")
if (key.name === "3") setView("help")
if (key.name === "escape") process.exit(0)
})
return (
<box style={{ flexDirection: "column", width: "100%", height: "100%" }}>
<Header view={view} />
{view === "home" && <HomeView />}
{view === "settings" && <SettingsView />}
{view === "help" && <HelpView />}
<Footer />
</box>
)
}
Modal Pattern
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
function App() {
const [showModal, setShowModal] = useState(false)
useKeyboard((key) => {
if (showModal) return // Modal handles its own keys
if (key.name === "m") {
setShowModal(true)
}
})
return (
<>
<MainContent />
{showModal && (
<Modal onClose={() => setShowModal(false)} />
)}
</>
)
}
function Modal({ onClose }: { onClose: () => void }) {
const [input, setInput] = useState("")
useKeyboard((key) => {
if (key.name === "escape") onClose()
})
return (
<box
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 60,
height: 10,
backgroundColor: "#1a1a1a",
border: true,
borderColor: "#FFFFFF",
}}
>
<input
placeholder="Enter value..."
value={input}
focused={true}
onInput={setInput}
onSubmit={(val) => {
console.log("Submitted:", val)
onClose()
}}
/>
</box>
)
}
Focus Management
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
type FocusPanel = "left" | "right"
function App() {
const [focused, setFocused] = useState<FocusPanel>("left")
useKeyboard((key) => {
if (key.name === "tab") {
setFocused(focused === "left" ? "right" : "left")
}
})
return (
<box style={{ flexDirection: "row", width: "100%", height: "100%" }}>
<box
title="Left Panel"
style={{ flexGrow: 1, border: true }}
focused={focused === "left"}
>
<text>Content</text>
</box>
<box
title="Right Panel"
style={{ flexGrow: 1, border: true }}
focused={focused === "right"}
>
<text>Content</text>
</box>
</box>
)
}
List with Selection
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
function FileList({ files }: { files: string[] }) {
const [selected, setSelected] = useState(0)
useKeyboard((key) => {
if (key.name === "up") {
setSelected(Math.max(0, selected - 1))
}
if (key.name === "down") {
setSelected(Math.min(files.length - 1, selected + 1))
}
if (key.name === "enter") {
console.log("Selected:", files[selected])
}
})
return (
<box style={{ flexDirection: "column" }}>
{files.map((file, idx) => (
<text
key={file}
fg={idx === selected ? "#00FF00" : "#FFFFFF"}
bg={idx === selected ? "#333333" : undefined}
>
{idx === selected ? "> " : " "}
{file}
</text>
))}
</box>
)
}
Responsive Layout
import { useTerminalDimensions } from "@opentui/react"
function App() {
const { width, height } = useTerminalDimensions()
// Show simplified layout on small terminals
const isSmall = width < 80
return (
<box style={{ flexDirection: isSmall ? "column" : "row", width, height }}>
<Sidebar width={isSmall ? width : 30} />
<MainContent width={isSmall ? width : width - 30} />
</box>
)
}
Loading Spinner
import { useState, useEffect } from "react"
function Spinner() {
const [frame, setFrame] = useState(0)
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
useEffect(() => {
const interval = setInterval(() => {
setFrame((f) => (f + 1) % frames.length)
}, 80)
return () => clearInterval(interval)
}, [])
return <text fg="#00FFFF">{frames[frame]} Loading...</text>
}
Code Style Guidelines
Import Order
- External dependencies (React, etc.)
- OpenTUI packages
- Internal utilities
- Components
- Type imports (with
typekeyword)
import { useState, useEffect } from "react"
import { useKeyboard, useRenderer } from "@opentui/react"
import { RGBA, TextAttributes } from "@opentui/core"
import { formatTime } from "./utils"
import { Header } from "./components/Header"
import type { AppState, View } from "./types"
Naming Conventions
- camelCase: variables, functions, parameters
- PascalCase: components, classes, types/interfaces
- UPPER_CASE: constants
// Good
const userInput = "text"
function handleSubmit() {}
interface UserData {}
const MAX_RETRIES = 3
// Bad
const UserInput = "text"
function HandleSubmit() {}
interface userData {}
Component Structure
interface ComponentProps {
title: string
items: string[]
}
export function Component({ title, items }: ComponentProps) {
// 1. Hooks
const [selected, setSelected] = useState(0)
const renderer = useRenderer()
// 2. Event handlers
useKeyboard((key) => {
if (key.name === "escape") process.exit(0)
})
// 3. Effects
useEffect(() => {
// Side effects
}, [])
// 4. Helper functions
const handleSelect = (index: number) => {
setSelected(index)
}
// 5. JSX return
return (
<box>
<text>{title}</text>
{/* Content */}
</box>
)
}
Best Practices
Performance
- Minimize re-renders: Use
useMemoanduseCallbackfor expensive computations - Avoid frequent updates: Throttle or debounce rapid state changes
- Use keys properly: Provide stable keys for lists to optimize reconciliation
- Optimize large lists: Use virtualization or pagination for long lists
Focus Management
- Only one focused component: Ensure only one input/select has
focused={true}at a time - Keyboard routing: Check focus state before handling keyboard events
- Visual feedback: Use
focusedprop on boxes to show focus state with borders
Layout Tips
- Use flexbox: Leverage
flexDirection,justifyContent,alignItemsfor layouts - Percentage widths: Use
"50%"for responsive layouts - flexGrow/flexShrink: Use for responsive sizing
- Absolute positioning: Use sparingly for modals/overlays
Debugging
- Console overlay: Use
renderer.console.show()instead ofconsole.log - Toggle console: Press backtick (`) to toggle console visibility
- Environment variables: Set
OTUI_DEBUG=truefor debug mode - Stats overlay: Set
OTUI_SHOW_STATS=trueto see performance stats
Error Handling
- Graceful degradation: Handle terminal resize and small screens
- Input validation: Validate user input before processing
- Error boundaries: Wrap components in error boundaries for production
Common Gotchas
- Console captured:
console.loggoes to overlay, userenderer.console.show()to view - Focus required: Inputs/selects need
focused={true}to receive events - Bun only: OpenTUI is designed for Bun runtime, not Node.js
- Zig for builds: Only needed when modifying native code, not for TypeScript changes
- Alternate screen: Default is full-screen mode, use
useAlternateScreen: falseto disable - Key names: Use lowercase names ("a", "enter", "escape"), not "A", "Enter", "Escape"
Troubleshooting
Application not rendering
- Ensure you called
renderer.start()aftercreateRoot().render() - Check terminal supports ANSI escape codes
- Try disabling alternate screen:
useAlternateScreen: false
Keyboard input not working
- Ensure component has
focused={true} - Check if modal or overlay is capturing input
- Verify
useKeyboardhook is registered
Layout issues
- Check parent has explicit dimensions (
width,height) - Use
flexGrowfor flexible sizing - Ensure
flexDirectionis set correctly - Use browser DevTools-like thinking (Yoga = Flexbox)
Colors not showing
- Verify terminal supports 24-bit color
- Use hex format:
"#RRGGBB" - Check color values are valid (0-255 for RGB)
Reference Documentation
For detailed API references and advanced topics, see:
- Component API Reference - Complete component props and options
- Core API Reference - Low-level Core package API
- Layout Guide - In-depth Yoga/Flexbox layout guide
- Styling Guide - Colors, text attributes, and styling patterns
Examples
The OpenTUI repository contains many examples:
- Basic counter app
- Login form with validation
- File explorer with keyboard navigation
- Code viewer with syntax highlighting
- Git diff viewer
- Multi-view dashboard
- Modal dialogs
- Tab navigation
Resources
- GitHub: https://github.com/anomalyco/opentui
- NPM Core: https://www.npmjs.com/package/@opentui/core
- NPM React: https://www.npmjs.com/package/@opentui/react
- Awesome OpenTUI: https://github.com/msmps/awesome-opentui
- Create TUI: https://github.com/msmps/create-tui
Development Commands
# Install dependencies
bun install
# Run application
bun run index.tsx
# Type check
bun --bun tsc --noEmit
# Build project (if applicable)
bun run build
Next Steps
After implementing a basic OpenTUI application:
- Add keyboard shortcuts: Implement common shortcuts (Ctrl+Q, etc.)
- Create themes: Extract colors to theme objects for consistency
- Add help screen: Show keyboard shortcuts and commands
- Implement error handling: Gracefully handle edge cases
- Optimize performance: Profile and optimize render performance
- Add tests: Write unit tests for components and logic
- Document usage: Add README with screenshots and usage guide
More from gitarbor/gitarbor-tui
skills-creator
Create and manage Agent Skills following the agentskills.io specification. Use when users want to create, validate, or modify skills for AI agents.
39unit-test
Create comprehensive unit tests using Bun's test runner. Use when the user asks to create or fix unit tests or create test files.
5opencode-agents
Create and configure custom OpenCode agents (primary and subagents) with specialized prompts, tools, permissions, and models. Use when the user wants to create, modify, or configure OpenCode agents, or mentions agent modes, tool permissions, or task delegation.
4theme-manager
Update and maintain GitArbor TUI themes, add new themes, modify existing themes, and ensure theme consistency. Use when working with colors, themes, src/theme.ts, or when user mentions theming, color schemes, or visual customization.
4nuxt-website
Build and maintain Vue 3/Nuxt 4 marketing websites. Use when working with website pages, components, layouts, SEO, Nuxt configuration, or when user mentions 'website', 'marketing site', 'landing page', 'docs site', Vue, or Nuxt.
4