Comprehensive skill for building terminal user interfaces with OpenTUI framework. This skill should be used when building TUIs, creating terminal applications, or working with the OpenTUI React/Solid bindings.
OpenTUI is a native terminal UI framework written in Zig with TypeScript bindings. It provides a component-based architecture for building interactive terminal applications with first-class React and SolidJS support.
OpenTUI (Open Terminal User Interface) is a library for building terminal user interfaces using a native Zig core. Key features include:
bun create tui my-app
cd my-app
bun run src/index.ts
Select a template (React, Solid, or vanilla TypeScript) during creation.
# Core package only
bun add @opentui/core
# With React
bun add @opentui/react @opentui/core react
# With Solid
bun add @opentui-ui/solid @opentui/core solid-js
For React projects using OpenTUI:
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true
}
}
The CliRenderer is the core of OpenTUI. It manages terminal output, handles input events, runs the rendering loop, and provides context for creating renderables.
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({
exitOnCtrlC: true,
targetFps: 30,
})
OpenTUI provides two APIs:
Renderable API: Object-oriented, imperative style
const box = new BoxRenderable(renderer, { width: 30, height: 10 })
box.add(text)
renderer.root.add(box)
Construct API: Declarative, functional style
renderer.root.add(
Box({ width: 30, height: 10 }, Text({ content: "Hello" }))
)
OpenTUI uses the Yoga layout engine with CSS Flexbox-like properties:
Box({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
height: 10,
padding: 2,
gap: 1,
})
renderer.keyInput.on("keypress", (key) => {
if (key.name === "escape") {
renderer.destroy()
process.exit(0)
}
})
import { RGBA } from "@opentui/core"
const red = RGBA.fromHex("#FF0000")
const blue = RGBA.fromInts(0, 0, 255, 255)
// Or use string colors directly
Text({ content: "Hello", fg: "#00FFFF" })
Box({ backgroundColor: "blue" })
Always call renderer.destroy() on exit to restore terminal state:
process.on("uncaughtException", () => {
renderer.destroy()
process.exit(1)
})
| Function | Description |
|---|---|
createCliRenderer(config?) | Create a new CLI renderer |
createRoot(renderer) | Create a React root (React binding) |
| Component | Description |
|---|---|
Text | Text display with styling |
Box | Container with borders and layout |
Input | Single-line text input |
Textarea | Multi-line text input |
Select | Vertical selection list |
TabSelect | Tab-based selection |
ScrollBox | Scrollable container |
ScrollBar | Scrollbar component |
Slider | Slider control |
Code | Syntax-highlighted code |
Markdown | Markdown rendering |
LineNumber | Line numbers with diff support |
Diff | Unified or split diff viewer |
FrameBuffer | Low-level frame buffer |
ASCIIFont | ASCII art text rendering |
| Hook | Description |
|---|---|
useRenderer() | Access the OpenTUI renderer |
useKeyboard(handler, options?) | Handle keyboard events |
useOnResize(callback) | Handle terminal resize |
useTerminalDimensions() | Get reactive dimensions |
useTimeline(options?) | Create animations |
Common events emitted by components:
change: Value changedinput: Input eventsubmit: Form submititemSelected: Item selected in listfocus: Component focusedblur: Component lost focusimport { createCliRenderer, Text } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.root.add(Text({ content: "Hello, OpenTUI!", fg: "#00FFFF" }))
import { createCliRenderer, Box, Text } from "@opentui/core"
const renderer = await createCliRenderer()
let count = 0
const display = Text({ content: "Count: 0", fg: "#FFFF00" })
renderer.keyInput.on("keypress", (key) => {
if (key.name === "up") {
count++
display.setContent(`Count: ${count}`)
renderer.requestRender()
} else if (key.name === "down") {
count--
display.setContent(`Count: ${count}`)
renderer.requestRender()
}
})
renderer.root.add(display)
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function LoginForm() {
return (
<box style={{ border: true, padding: 2, flexDirection: "column", gap: 1 }}>
<text fg="#FFFF00">Login</text>
<box title="Username" style={{ border: true, width: 30, height: 3 }}>
<input placeholder="Username..." />
</box>
<box title="Password" style={{ border: true, width: 30, height: 3 }}>
<input placeholder="Password..." />
</box>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<LoginForm />)
import { createCliRenderer, Box, Select } from "@opentui/core"
const renderer = await createCliRenderer()
const menu = Select({
width: 25,
height: 10,
options: [
{ name: "New File", description: "Create new (Ctrl+N)" },
{ name: "Open...", description: "Open file (Ctrl+O)" },
{ name: "Save", description: "Save (Ctrl+S)" },
{ name: "Exit", description: "Quit (Ctrl+Q)" },
],
})
menu.on("itemSelected", (index, option) => {
console.log("Selected:", option.name)
})
menu.focus()
renderer.root.add(Box({ border: true, borderStyle: "rounded" }, menu))
renderer.root.add(
Box({
flexDirection: "row",
width: "100%",
height: "100%",
padding: 1,
},
Box({
flexGrow: 1,
border: true,
borderStyle: "rounded",
}, Text({ content: "Left Panel" })),
Box({
width: 30,
border: true,
borderStyle: "rounded",
}, Text({ content: "Right" })),
)
)
const renderer = await createCliRenderer({
screenMode: "alternate-screen",
exitOnCtrlC: true,
targetFps: 30,
maxFps: 60,
useMouse: true,
autoFocus: true,
backgroundColor: "#000000",
})
Box({
width: 40,
height: 15,
backgroundColor: "#1a1a1a",
border: true,
borderStyle: "rounded",
borderColor: "#666666",
padding: 2,
margin: 1,
})
single: Single line (┌─┐│└─┘)double: Double line (╔═╗║╚═╝)rounded: Rounded corners (╭─╮│╰─╯)heavy: Heavy lines (┏━┓┃┗━┛)const mode = renderer.themeMode
renderer.on("theme_mode", (newMode) => {
console.log("Theme changed to:", newMode)
})
Terminal stays broken after crash:
reset command in terminalrenderer.destroy()Mouse not working:
useMouse: true in configInput not receiving keys:
input.focus() on the component| Variable | Description |
|---|---|
OTUI_USE_CONSOLE | Enable console capture (default: true) |
SHOW_CONSOLE | Show console overlay at startup |
OTUI_NO_NATIVE_RENDER | Skip native renderer |
OTUI_DEBUG | Capture raw stdin for debugging |
OTUI_SHOW_STATS | Show debug overlay at startup |
renderer.toggleDebugOverlay()
renderer.configureDebugOverlay({
enabled: true,
corner: "topRight",
})
requestRender() instead of continuous rendering when possiblerenderable.requestRender() for targeted updatesconst renderer = await createCliRenderer()
process.on("uncaughtException", () => {
renderer.destroy()
process.exit(1)
})
process.on("unhandledRejection", () => {
renderer.destroy()
process.exit(1)
})
function Panel({ title, children }) {
return (
<box
style={{
border: true,
borderStyle: "rounded",
padding: 1,
margin: 1,
}}
>
<text bold fg="#00FFFF">{title}</text>
{children}
</box>
)
}
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return <box><text>Hello React!</text></box>
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
import { createCliRenderer } from "@opentui/core"
import { createRoot as createSolidRoot } from "@opentui-ui/solid"
function App() {
return <box><text>Hello Solid!</text></box>
}
const renderer = await createCliRenderer()
createSolidRoot(renderer).render(<App />)
OpenTUI supports tree-sitter for code syntax highlighting. Set the worker path:
OTUI_TREE_SITTER_WORKER_PATH=/path/to/worker.js
import { BoxRenderable, type RenderContext, type BoxOptions } from "@opentui/core"
class CustomButton extends BoxRenderable {
constructor(ctx: RenderContext, options: BoxOptions & { label?: string }) {
super(ctx, options)
this.borderStyle = "rounded"
this.padding = 2
}
}
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function AnimatedBox() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({ duration: 2000, loop: true })
useEffect(() => {
timeline.add(
{ width },
{
width: 50,
duration: 2000,
ease: "linear",
onUpdate: (anim) => setWidth(anim.targets[0].width),
}
)
}, [])
return <box style={{ width, backgroundColor: "#6a5acd" }} />
}
renderer.setCursorPosition(10, 5, true)
renderer.setCursorStyle({ style: "block", blinking: true })
renderer.on("memory:snapshot", (snapshot) => {
console.log("Memory:", snapshot)
})
For detailed API documentation, see:
references/api-quickref.md - Quick API referencereferences/examples.md - Extended code examples