
Opentui
- 52.8k installs
- 224 repo stars
- Updated July 1, 2026
- msmps/opentui-skill
opentui is an agent skill that generates correct, idiomatic OpenTUI code for terminal user interfaces using the core, React, or Solid reconciler.
About
opentui is a consolidated skill for building terminal user interfaces with OpenTUI, covering the core imperative API, the React reconciler, and the Solid reconciler. It uses decision trees to pick the right framework and component, then points to bundled per-framework references for API, configuration, patterns, and gotchas, plus cross-cutting guides for layout, keyboard input, animation, and testing. A developer uses it for any TUI task (dashboards, interactive CLIs, terminal apps) to write correct OpenTUI code instead of guessing APIs.
- Covers OpenTUI core, React, and Solid reconcilers
- Decision trees to pick framework and component
- 26 bundled reference files across frameworks and concepts
- Critical rules: create-tui, no process.exit(), nested text tags
- Runs on Bun with Zig native builds
Opentui by the numbers
- 52,841 all-time installs (skills.sh)
- +1,142 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #14 of 560 CLI & Terminal skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
opentui capabilities & compatibility
Free skill; no API key required (needs the Bun runtime).
- Capabilities
- tui development · opentui code · terminal ui · reconciler selection
- Use cases
- frontend
- Platforms
- macOS · Linux
- Pricing
- Free
What opentui says it does
Consolidated skill for building terminal user interfaces with OpenTUI.
1. **Use `create-tui` for new projects.** See framework `REFERENCE.md` quick starts.
OpenTUI runs on Bun and uses Zig for native builds.
npx skills add https://github.com/msmps/opentui-skill --skill opentuiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52.8k |
|---|---|
| repo stars | ★ 224 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 1, 2026 |
| Repository | msmps/opentui-skill ↗ |
How do I write correct OpenTUI terminal-UI code and pick between the core, React, and Solid reconcilers?
Build terminal user interfaces with OpenTUI using the core, React, or Solid reconciler.
Who is it for?
Developers building terminal UIs, interactive CLIs, or TUI dashboards who want idiomatic OpenTUI code and framework-choice guidance.
Skip if: Web or GUI frontends and non-OpenTUI terminal libraries; it is specific to the OpenTUI framework on Bun.
When should I use this skill?
The user is doing any OpenTUI or TUI development task including components, layout, keyboard handling, animations, or testing.
What you get
Produces idiomatic OpenTUI code with the right reconciler and components, following the skill's critical rules and references.
- OpenTUI terminal UI code
By the numbers
- 26 bundled reference files
- 3 frameworks covered (core, React, Solid)
- 4 critical rules
Files
OpenTUI Platform Skill
Consolidated skill for building terminal user interfaces with OpenTUI. Use decision trees below to find the right framework and components, then load detailed references.
Critical Rules
Follow these rules in all OpenTUI code:
1. Use `create-tui` for new projects. See framework REFERENCE.md quick starts. 2. `create-tui` options must come before arguments. bunx create-tui -t react my-app works, bunx create-tui my-app -t react does NOT. 3. Never call `process.exit()` directly. Use renderer.destroy() (see core/gotchas.md). 4. Text styling requires nested tags in React/Solid. Use modifier elements, not props (see components/text-display.md).
How to Use This Skill
Reference File Structure
Framework references follow a 5-file pattern. Cross-cutting concepts are single-file guides.
Each framework in ./references/<framework>/ contains:
| File | Purpose | When to Read |
|---|---|---|
REFERENCE.md | Overview, when to use, quick start | Always read first |
api.md | Runtime API, components, hooks | Writing code |
configuration.md | Setup, tsconfig, bundling | Configuring a project |
patterns.md | Common patterns, best practices | Implementation guidance |
gotchas.md | Pitfalls, limitations, debugging | Troubleshooting |
Cross-cutting concepts in ./references/<concept>/ have REFERENCE.md as the entry point.
Reading Order
1. Start with REFERENCE.md for your chosen framework 2. Then read additional files relevant to your task:
- Building components ->
api.md+components/<category>.md - Setting up project ->
configuration.md - Layout/positioning ->
layout/REFERENCE.md - Keyboard/input handling ->
keyboard/REFERENCE.md - Animations ->
animation/REFERENCE.md - Troubleshooting ->
gotchas.md+testing/REFERENCE.md
Example Paths
./references/react/REFERENCE.md # Start here for React
./references/react/api.md # React components and hooks
./references/solid/configuration.md # Solid project setup
./references/components/inputs.md # Input, Textarea, Select docs
./references/core/gotchas.md # Core debugging tipsRuntime Notes
OpenTUI runs on Bun and uses Zig for native builds. Read ./references/core/gotchas.md for runtime requirements and build guidance.
Quick Decision Trees
"Which framework should I use?"
Which framework?
├─ I want full control, maximum performance, no framework overhead
│ └─ core/ (imperative API)
├─ I know React, want familiar component patterns
│ └─ react/ (React reconciler)
├─ I want fine-grained reactivity, optimal re-renders
│ └─ solid/ (Solid reconciler)
└─ I'm building a library/framework on top of OpenTUI
└─ core/ (imperative API)"I need to display content"
Display content?
├─ Plain or styled text -> components/text-display.md
├─ Container with borders/background -> components/containers.md
├─ Scrollable content area -> components/containers.md (scrollbox)
├─ ASCII art banner/title -> components/text-display.md (ascii-font)
├─ Data table with borders/wrapping -> components/code-diff.md (TextTable)
├─ Code with syntax highlighting -> components/code-diff.md
├─ Diff viewer (unified/split) -> components/code-diff.md
├─ Line numbers with diagnostics -> components/code-diff.md
└─ Markdown content (streaming) -> components/code-diff.md (markdown)"I need user input"
User input?
├─ Single-line text field -> components/inputs.md (input)
├─ Multi-line text editor -> components/inputs.md (textarea)
├─ Select from a list (vertical) -> components/inputs.md (select)
├─ Tab-based selection (horizontal) -> components/inputs.md (tab-select)
└─ Custom keyboard shortcuts -> keyboard/REFERENCE.md"I need layout/positioning"
Layout?
├─ Flexbox-style layouts (row, column, wrap) -> layout/REFERENCE.md
├─ Absolute positioning -> layout/patterns.md
├─ Responsive to terminal size -> layout/patterns.md
├─ Centering content -> layout/patterns.md
└─ Complex nested layouts -> layout/patterns.md"I need animations"
Animations?
├─ Timeline-based animations -> animation/REFERENCE.md
├─ Easing functions -> animation/REFERENCE.md
├─ Property transitions -> animation/REFERENCE.md
└─ Looping animations -> animation/REFERENCE.md"I need to handle input"
Input handling?
├─ Keyboard events (keypress, release) -> keyboard/REFERENCE.md
├─ Focus management -> keyboard/REFERENCE.md
├─ Paste events -> keyboard/REFERENCE.md
├─ Mouse events -> components/containers.md
├─ Text selection & copy-on-select -> keyboard/REFERENCE.md (selection)
└─ Clipboard (OSC 52) -> keyboard/REFERENCE.md (clipboard)"I need to test my TUI"
Testing?
├─ Snapshot testing -> testing/REFERENCE.md
├─ Interaction testing -> testing/REFERENCE.md
├─ Test renderer setup -> testing/REFERENCE.md
└─ Debugging tests -> testing/REFERENCE.md"I need to debug/troubleshoot"
Troubleshooting?
├─ Runtime errors, crashes -> <framework>/gotchas.md
├─ Layout issues -> layout/REFERENCE.md + layout/patterns.md
├─ Input/focus issues -> keyboard/REFERENCE.md
└─ Repro + regression tests -> testing/REFERENCE.mdTroubleshooting Index
- Terminal cleanup, crashes ->
core/gotchas.md - Text styling not applying ->
components/text-display.md - Input focus/shortcuts ->
keyboard/REFERENCE.md - Layout misalignment ->
layout/REFERENCE.md - Flaky snapshots ->
testing/REFERENCE.md
For component naming differences and text modifiers, see components/REFERENCE.md.
Product Index
Frameworks
| Framework | Entry File | Description |
|---|---|---|
| Core | ./references/core/REFERENCE.md | Imperative API, all primitives |
| React | ./references/react/REFERENCE.md | React reconciler for declarative TUI |
| Solid | ./references/solid/REFERENCE.md | SolidJS reconciler for declarative TUI |
Cross-Cutting Concepts
| Concept | Entry File | Description |
|---|---|---|
| Layout | ./references/layout/REFERENCE.md | Yoga/Flexbox layout system |
| Components | ./references/components/REFERENCE.md | Component reference by category |
| Keyboard | ./references/keyboard/REFERENCE.md | Keyboard input handling |
| Animation | ./references/animation/REFERENCE.md | Timeline-based animations |
| Testing | ./references/testing/REFERENCE.md | Test renderer and snapshots |
Component Categories
| Category | Entry File | Components |
|---|---|---|
| Text & Display | ./references/components/text-display.md | text, ascii-font, styled text |
| Containers | ./references/components/containers.md | box, scrollbox, borders |
| Inputs | ./references/components/inputs.md | input, textarea, select, tab-select |
| Code & Diff | ./references/components/code-diff.md | code, line-number, diff, markdown, text-table |
Resources
Repository: https://github.com/anomalyco/opentui Core Docs: https://github.com/anomalyco/opentui/tree/main/packages/core/docs Examples: https://github.com/anomalyco/opentui/tree/main/packages/core/src/examples Awesome List: https://github.com/msmps/awesome-opentui
Animation System
OpenTUI provides a timeline-based animation system for smooth property transitions.
Overview
Animations in OpenTUI use:
- Timeline: Orchestrates multiple animations
- Animation Engine: Manages timelines and rendering
- Easing Functions: Control animation curves
When to Use
Use this reference when you need timeline-driven animations, easing curves, or progressive transitions.
Basic Usage
React
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function AnimatedBox() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
})
useEffect(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
}, [])
return (
<box
width={width}
height={3}
backgroundColor="#6a5acd"
/>
)
}Solid
import { useTimeline } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
function AnimatedBox() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
})
onMount(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
})
return (
<box
width={width()}
height={3}
backgroundColor="#6a5acd"
/>
)
}Core
import { createCliRenderer, Timeline, engine } from "@opentui/core"
const renderer = await createCliRenderer()
engine.attach(renderer)
const timeline = new Timeline({
duration: 2000,
autoplay: true,
})
timeline.add(
{ x: 0 },
{
x: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
box.setLeft(Math.round(anim.targets[0].x))
},
}
)
engine.addTimeline(timeline)Timeline Options
const timeline = useTimeline({
duration: 2000, // Total duration in ms
loop: false, // Loop the timeline
autoplay: true, // Start automatically
onComplete: () => {}, // Called when timeline completes
onPause: () => {}, // Called when timeline pauses
})Timeline Methods
// Add animation
timeline.add(target, properties, startTime?)
// Control playback
timeline.play() // Start/resume
timeline.pause() // Pause
timeline.restart() // Restart from beginning
// State
timeline.progress // Current progress (0-1)
timeline.duration // Total durationAnimation Properties
timeline.add(
{ value: 0 }, // Target object with initial values
{
value: 100, // Final value
duration: 1000, // Animation duration in ms
ease: "linear", // Easing function
delay: 0, // Delay before starting
onUpdate: (anim) => {
// Called each frame
const current = anim.targets[0].value
},
onComplete: () => {
// Called when this animation completes
},
},
0 // Start time in timeline (optional)
)Easing Functions
Available easing functions:
Linear
| Name | Description |
|---|---|
linear | Constant speed |
Quad (Power of 2)
| Name | Description |
|---|---|
easeInQuad | Slow start |
easeOutQuad | Slow end |
easeInOutQuad | Slow start and end |
Cubic (Power of 3)
| Name | Description |
|---|---|
easeInCubic | Slower start |
easeOutCubic | Slower end |
easeInOutCubic | Slower start and end |
Quart (Power of 4)
| Name | Description |
|---|---|
easeInQuart | Even slower start |
easeOutQuart | Even slower end |
easeInOutQuart | Even slower start and end |
Expo (Exponential)
| Name | Description |
|---|---|
easeInExpo | Exponential start |
easeOutExpo | Exponential end |
easeInOutExpo | Exponential start and end |
Back (Overshoot)
| Name | Description |
|---|---|
easeInBack | Pull back, then forward |
easeOutBack | Overshoot, then settle |
easeInOutBack | Both |
Elastic
| Name | Description |
|---|---|
easeInElastic | Elastic start |
easeOutElastic | Elastic end (bouncy) |
easeInOutElastic | Both |
Bounce
| Name | Description |
|---|---|
easeInBounce | Bounce at start |
easeOutBounce | Bounce at end |
easeInOutBounce | Both |
Patterns
Progress Bar
function ProgressBar({ progress }: { progress: number }) {
const [width, setWidth] = useState(0)
const maxWidth = 50
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ value: width },
{
value: (progress / 100) * maxWidth,
duration: 300,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].value))
},
}
)
}, [progress])
return (
<box flexDirection="column" gap={1}>
<text>Progress: {progress}%</text>
<box width={maxWidth} height={1} backgroundColor="#333">
<box width={width} height={1} backgroundColor="#00FF00" />
</box>
</box>
)
}Fade In
function FadeIn({ children }) {
const [opacity, setOpacity] = useState(0)
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ opacity: 0 },
{
opacity: 1,
duration: 500,
ease: "easeOutQuad",
onUpdate: (anim) => {
setOpacity(anim.targets[0].opacity)
},
}
)
}, [])
return (
<box style={{ opacity }}>
{children}
</box>
)
}Looping Animation
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>{frames[frame]} Loading...</text>
}Staggered Animation
function StaggeredList({ items }) {
const [visibleCount, setVisibleCount] = useState(0)
useEffect(() => {
let count = 0
const interval = setInterval(() => {
count++
setVisibleCount(count)
if (count >= items.length) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [items.length])
return (
<box flexDirection="column">
{items.slice(0, visibleCount).map((item, i) => (
<text key={i}>{item}</text>
))}
</box>
)
}Slide In
function SlideIn({ children, from = "left" }) {
const [offset, setOffset] = useState(from === "left" ? -20 : 20)
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ offset: from === "left" ? -20 : 20 },
{
offset: 0,
duration: 300,
ease: "easeOutCubic",
onUpdate: (anim) => {
setOffset(Math.round(anim.targets[0].offset))
},
}
)
}, [])
return (
<box position="relative" left={offset}>
{children}
</box>
)
}Performance Tips
Batch Updates
Timeline automatically batches updates within the render loop.
Use Integer Values
Round animated values for character-based positioning:
onUpdate: (anim) => {
setX(Math.round(anim.targets[0].x))
}Clean Up Timelines
Hooks automatically clean up, but for core:
// When done with timeline
engine.removeTimeline(timeline)Gotchas
Terminal Refresh Rate
Terminal UIs typically refresh at 60 FPS max. Very fast animations may appear choppy.
Character Grid
Animations are constrained to character cells. Sub-pixel positioning isn't possible.
Cleanup in Effects
Always clean up intervals and timelines:
useEffect(() => {
const interval = setInterval(...)
return () => clearInterval(interval)
}, [])See Also
- React API -
useTimelinehook reference - Solid API -
useTimelinehook reference - Core API -
AnimationEngineandTimelineclasses - Layout Patterns - Animated positioning and transitions
Code & Diff Components
Components for displaying code with syntax highlighting and diffs in OpenTUI.
Code Component
Display syntax-highlighted code blocks.
Basic Usage
// React
<code
code={`function hello() {
console.log("Hello, World!");
}`}
language="typescript"
/>
// Solid
<code
code={sourceCode}
language="javascript"
/>
// Core
const codeBlock = new CodeRenderable(renderer, {
id: "code",
code: sourceCode,
language: "typescript",
})Supported Languages
OpenTUI uses Tree-sitter for syntax highlighting. Common languages:
typescript,javascriptpythonrustgojsonhtml,cssmarkdownbash,shell
Styling
<code
code={sourceCode}
language="typescript"
backgroundColor="#1a1a2e"
showLineNumbers
/>onHighlight Callback
Intercept and modify syntax highlights before rendering:
// Core
const codeBlock = new CodeRenderable(renderer, {
id: "code",
code: sourceCode,
language: "typescript",
onHighlight: (highlights, context) => {
// Add custom highlights
highlights.push([10, 20, "custom.error", {}])
return highlights
},
})
// React/Solid
<code
code={sourceCode}
language="typescript"
onHighlight={(highlights, context) => {
// context: { content, filetype, syntaxStyle }
// Modify and return highlights array
return highlights.filter(h => h[2] !== "comment")
}}
/>Callback signature:
highlights: SimpleHighlight[]- Array of[start, end, scope, metadata]context: { content, filetype, syntaxStyle }- Highlighting context- Return modified highlights array or
undefinedto use original
Supports async callbacks for fetching additional highlight data.
onChunks Callback
Post-process rendered text chunks after syntax highlighting. Runs after onHighlight and receives fully resolved chunks:
// Core
const codeBlock = new CodeRenderable(renderer, {
id: "code",
code: sourceCode,
language: "typescript",
onChunks: (chunks, context) => {
// Transform chunks (e.g., add link detection)
return chunks
},
})
// React/Solid
<code
code={sourceCode}
language="typescript"
onChunks={(chunks, context) => {
// context: { content, filetype, syntaxStyle, highlights }
return chunks
}}
/>Link Detection Utility
Auto-detect URLs in code and add clickable hyperlinks:
import { detectLinks } from "@opentui/core"
<code
code={sourceCode}
language="typescript"
onChunks={(chunks, context) => detectLinks(chunks, context)}
/>detectLinks examines Tree-sitter highlights to find URL tokens and sets chunk.link on matching chunks. Supports async usage.
TextTable Component
Render data tables with borders, word wrapping, and selection support.
Basic Usage
// Core
import { TextTableRenderable, type TextTableContent } from "@opentui/core"
const content: TextTableContent = [
[[ { text: "Name" } ], [ { text: "Age" } ], [ { text: "Role" } ]],
[[ { text: "Alice" } ], [ { text: "30" } ], [ { text: "Engineer" } ]],
[[ { text: "Bob" } ], [ { text: "25" } ], [ { text: "Designer" } ]],
]
const table = new TextTableRenderable(renderer, {
id: "table",
content,
wrapMode: "word", // "none" | "char" | "word"
columnWidthMode: "content", // "content" | "fill"
cellPadding: 0,
border: true,
outerBorder: true,
borderStyle: "single", // single | double | rounded | bold
selectable: true, // Allow text selection
columnFitter: "balanced", // "proportional" | "balanced"
})Options
| Option | Type | Default | Description |
|---|---|---|---|
content | TextTableContent | - | 2D array of cell content |
wrapMode | `"none" \ | "char" \ | "word"` |
columnWidthMode | `"content" \ | "fill"` | "content" |
cellPadding | number | 0 | Padding inside cells |
border | boolean | true | Show inner borders |
outerBorder | boolean | true | Show outer borders |
borderStyle | string | "single" | Border style |
borderColor | `string \ | RGBA` | - |
selectable | boolean | false | Allow text selection |
columnFitter | `"proportional" \ | "balanced"` | "proportional" |
Cell Content Format
Each cell is an array of styled text chunks:
type TextTableCellContent = { text: string; fg?: RGBA; bg?: RGBA }[]
type TextTableContent = TextTableCellContent[][] // rows -> cells -> chunksSelection
table.getSelectedText() // Get selected text
table.hasSelection() // Check if text is selectedColumnar selection is supported: dragging vertically within a single column selects only that column's content.
Line Number Component
Code display with line numbers, highlighting, and diagnostics.
Basic Usage
// React
<line-number
code={sourceCode}
language="typescript"
/>
// Solid (note underscore)
<line_number
code={sourceCode}
language="typescript"
/>
// Core
const codeView = new LineNumberRenderable(renderer, {
id: "code-view",
code: sourceCode,
language: "typescript",
})Line Number Options
// React
<line-number
code={sourceCode}
language="typescript"
startLine={1} // Starting line number
showLineNumbers={true} // Display line numbers
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
startLine={1}
showLineNumbers={true}
/>Line Highlighting
Highlight specific lines:
// React
<line-number
code={sourceCode}
language="typescript"
highlightedLines={[5, 10, 15]} // Highlight these lines
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
highlightedLines={[5, 10, 15]}
/>Diagnostics
Show errors, warnings, and info on specific lines:
// React
<line-number
code={sourceCode}
language="typescript"
diagnostics={[
{ line: 3, severity: "error", message: "Unexpected token" },
{ line: 7, severity: "warning", message: "Unused variable" },
{ line: 12, severity: "info", message: "Consider using const" },
]}
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
diagnostics={[
{ line: 3, severity: "error", message: "Unexpected token" },
]}
/>Diagnostic severity levels:
error- Red indicatorwarning- Yellow indicatorinfo- Blue indicatorhint- Gray indicator
Diff Highlighting
Show added/removed lines:
<line-number
code={sourceCode}
language="typescript"
addedLines={[5, 6, 7]} // Green background
removedLines={[10, 11]} // Red background
/>Diff Component
Unified or split diff viewer with syntax highlighting.
Basic Usage
// React
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
/>
// Solid
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
/>
// Core
const diffView = new DiffRenderable(renderer, {
id: "diff",
oldCode: originalCode,
newCode: modifiedCode,
language: "typescript",
})Display Modes
// Unified diff (default)
<diff
oldCode={old}
newCode={new}
mode="unified"
/>
// Split/side-by-side diff
<diff
oldCode={old}
newCode={new}
mode="split"
/>Synchronized Scrolling (Split View)
In split view, enable synchronized scrolling between left and right panes:
// React/Solid
<diff
oldCode={old}
newCode={new}
mode="split"
syncScroll // Scrolling one pane syncs the other
/>
// Core
const diffView = new DiffRenderable(renderer, {
id: "diff",
diff: unifiedDiff,
view: "split",
syncScroll: true,
})
// Toggle at runtime
diffView.syncScroll = true
diffView.syncScroll = falseOptions
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified"
showLineNumbers
context={3} // Lines of context around changes
/>Styling
<diff
oldCode={old}
newCode={new}
addedLineColor="#2d4f2d" // Background for added lines
removedLineColor="#4f2d2d" // Background for removed lines
unchangedLineColor="transparent"
/>Line Highlighting API (Core)
Programmatically highlight specific lines in a diff:
// Set a single line's color
diffView.setLineColor(5, "#2d4f2d")
diffView.setLineColor(5, { gutter: "#333", content: "#2d4f2d" })
// Clear a single line's color
diffView.clearLineColor(5)
// Set multiple lines at once
diffView.setLineColors(new Map([
[1, "#2d4f2d"],
[2, "#4f2d2d"],
]))
// Highlight a range
diffView.highlightLines(10, 20, "#2d4f2d")
diffView.clearHighlightLines(10, 20)
// Clear all line colors
diffView.clearAllLineColors()The LineNumberRenderable also supports programmatic highlighting:
lineNumberView.highlightLines(5, 10, "#2d4f2d")
lineNumberView.clearHighlightLines(5, 10)
## Markdown Component
Render markdown content with syntax highlighting for code blocks.
### Basic Usage
// React <markdown content={markdownText} syntaxStyle={mySyntaxStyle} />
// Solid <markdown content={markdownText} syntaxStyle={mySyntaxStyle} />
// Core import { MarkdownRenderable } from "@opentui/core"
const md = new MarkdownRenderable(renderer, { id: "markdown", content: "# Hello\n\nThis is markdown.", syntaxStyle: mySyntaxStyle, })
### Options
<markdown content={markdownText} syntaxStyle={syntaxStyle} treeSitterClient={client} // Optional: custom tree-sitter client conceal={true} // Hide markdown syntax characters streaming={true} // Enable streaming mode for incremental updates tableOptions={{ // Customize markdown table rendering widthMode: "full", // "content" | "full" wrapMode: "word", // "none" | "char" | "word" cellPadding: 0, borders: true, outerBorder: true, borderStyle: "single", borderColor: "#555", selectable: true, // Tables are selectable by default }} />
### Custom Node Rendering
// Core const md = new MarkdownRenderable(renderer, { id: "markdown", content: "# Custom Heading", syntaxStyle, renderNode: (node, ctx, defaultRender) => { if (node.type === "heading") { // Return custom renderable for headings return new TextRenderable(ctx, { content: >> ${node.content} <<, }) } return null // Use default rendering }, })
### Streaming Mode
For real-time content like LLM output:
const [content, setContent] = useState("")
// Append text as it arrives useEffect(() => { llmStream.on("token", (token) => { setContent(c => c + token) }) }, [])
<markdown content={content} syntaxStyle={syntaxStyle} streaming={true} // Optimizes for incremental updates />
## Use Cases
### Code Editor
function CodeEditor() { const [code, setCode] = useState(function hello() { console.log("Hello!"); })
return ( <box flexDirection="column" height="100%"> <box height={1}> <text>editor.ts</text> </box> <textarea value={code} onChange={setCode} language="typescript" showLineNumbers flexGrow={1} focused /> </box> ) }
### Code Review
function CodeReview({ oldCode, newCode }) { return ( <box flexDirection="column" height="100%"> <box height={1} backgroundColor="#333"> <text>Changes in src/utils.ts</text> </box> <diff oldCode={oldCode} newCode={newCode} language="typescript" mode="split" showLineNumbers /> </box> ) }
### Syntax-Highlighted Preview
function MarkdownPreview({ content }) { // Extract code blocks from markdown const codeBlocks = extractCodeBlocks(content)
return ( <scrollbox height={20}> {codeBlocks.map((block, i) => ( <box key={i} marginBottom={1}> <code code={block.code} language={block.language} /> </box> ))} </scrollbox> ) }
### Error Display
function ErrorView({ errors, code }) { const diagnostics = errors.map(err => ({ line: err.line, severity: "error", message: err.message, }))
return ( <line-number code={code} language="typescript" diagnostics={diagnostics} highlightedLines={errors.map(e => e.line)} /> ) }
## Gotchas
### Solid Uses Underscores
// React <line-number />
// Solid <line_number />
### Language Required for Highlighting
// No highlighting (plain text) <code code={text} />
// With highlighting <code code={text} language="typescript" />
### Large Files
For very large files, consider:
- Pagination or virtual scrolling
- Loading only visible portion
- Using `scrollbox` wrapper
<scrollbox height={30}> <line-number code={largeFile} language="typescript" /> </scrollbox>
### Tree-sitter Loading
Syntax highlighting requires Tree-sitter grammars. If highlighting isn't working:
1. Check the language is supported
2. Verify grammars are installed
3. Check `OTUI_TREE_SITTER_WORKER_PATH` if using custom path
Container Components
Components for grouping and organizing content in OpenTUI.
Box Component
The primary container component with borders, backgrounds, and layout capabilities.
Basic Usage
// React/Solid
<box>
<text>Content inside box</text>
</box>
// Core
const box = new BoxRenderable(renderer, {
id: "container",
})
box.add(child)Borders
<box border>
Simple border
</box>
<box
border
borderStyle="single" // single | double | rounded | bold | none
borderColor="#FFFFFF"
>
Styled border
</box>
// Individual borders
<box
borderTop
borderBottom
borderLeft={false}
borderRight={false}
>
Top and bottom only
</box>Border Styles:
| Style | Appearance |
|---|---|
single | ┌─┐│ │└─┘ |
double | ╔═╗║ ║╚═╝ |
rounded | ╭─╮│ │╰─╯ |
bold | ┏━┓┃ ┃┗━┛ |
Title
<box
border
title="Settings"
titleAlignment="center" // left | center | right
>
Panel content
</box>Background
<box backgroundColor="#1a1a2e">
Dark background
</box>
<box backgroundColor="transparent">
No background
</box>Layout
Boxes are flex containers by default:
<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} // Space between children
>
<text>Item 1</text>
<text>Item 2</text>
</box>Spacing
<box
padding={2} // All sides
paddingTop={1}
paddingRight={2}
paddingBottom={1}
paddingLeft={2}
paddingX={2} // Horizontal (left + right)
paddingY={1} // Vertical (top + bottom)
margin={1}
marginTop={1}
marginX={2} // Horizontal (left + right)
marginY={1} // Vertical (top + bottom)
>
Spaced content
</box>Dimensions
<box
width={40} // Fixed width
height={10} // Fixed height
width="50%" // Percentage of parent
minWidth={20} // Minimum width
maxWidth={80} // Maximum width
flexGrow={1} // Grow to fill space
>
Sized box
</box>Mouse Events
<box
onMouseDown={(event) => {
console.log("Clicked at:", event.x, event.y)
}}
onMouseUp={(event) => {}}
onMouseMove={(event) => {}}
>
Clickable box
</box>Focusable Boxes
By default, Box elements are not focusable. Set the focusable prop to enable focus behavior:
// Make a box focusable - it can receive focus via mouse click
<box focusable border>
<text>Click to focus</text>
</box>
// Controlled focus state
const [focused, setFocused] = useState(false)
<box
focusable
focused={focused}
border
borderColor={focused ? "#00ff00" : "#888"}
>
<text>{focused ? "Focused!" : "Not focused"}</text>
</box>When a focusable Box is clicked, focus bubbles up from the click target to the nearest focusable parent. Use event.preventDefault() in onMouseDown to prevent auto-focus.
ScrollBox Component
A scrollable container for content that exceeds the viewport.
Basic Usage
// React
<scrollbox height={10}>
{items.map((item, i) => (
<text key={i}>{item}</text>
))}
</scrollbox>
// Solid
<scrollbox height={10}>
<For each={items()}>
{(item) => <text>{item}</text>}
</For>
</scrollbox>
// Core
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "list",
height: 10,
})
items.forEach(item => {
scrollbox.add(new TextRenderable(renderer, { content: item }))
})Focus for Keyboard Scrolling
<scrollbox focused height={20}>
{/* Use arrow keys to scroll */}
</scrollbox>Scrollbar Styling
// React
<scrollbox
style={{
rootOptions: {
backgroundColor: "#24283b",
},
wrapperOptions: {
backgroundColor: "#1f2335",
},
viewportOptions: {
backgroundColor: "#1a1b26",
},
contentOptions: {
backgroundColor: "#16161e",
},
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
{content}
</scrollbox>Scroll Position (Core)
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "list",
height: 20,
})
// Scroll programmatically
scrollbox.scrollTo(0) // Scroll to top
scrollbox.scrollTo(100) // Scroll to position
scrollbox.scrollBy(10) // Scroll relative
scrollbox.scrollToBottom() // Scroll to end
// Scroll a child into view (nearest alignment)
scrollbox.scrollChildIntoView("child-id") // Searches descendants by IDscrollChildIntoView(childId) scrolls the minimum amount needed to make the identified descendant visible. It mirrors Element.scrollIntoView({ block: "nearest" }) from the CSSOM View spec. Works with nested descendants and handles both horizontal and vertical scrolling.
Composition Patterns
Card Component
function Card({ title, children }) {
return (
<box
border
borderStyle="rounded"
padding={2}
marginBottom={1}
>
{title && (
<text fg="#00FFFF" bold>
{title}
</text>
)}
<box marginTop={title ? 1 : 0}>
{children}
</box>
</box>
)
}Panel Component
function Panel({ title, children, width = 40 }) {
return (
<box
border
borderStyle="double"
width={width}
backgroundColor="#1a1a2e"
>
{title && (
<box
borderBottom
padding={1}
backgroundColor="#2a2a4e"
>
<text bold>{title}</text>
</box>
)}
<box padding={2}>
{children}
</box>
</box>
)
}List Container
function List({ items, renderItem }) {
return (
<scrollbox height={15} focused>
{items.map((item, i) => (
<box
key={i}
padding={1}
backgroundColor={i % 2 === 0 ? "#222" : "#333"}
>
{renderItem(item, i)}
</box>
))}
</scrollbox>
)
}Nesting Containers
<box flexDirection="column" height="100%">
{/* Header */}
<box height={3} border>
<text>Header</text>
</box>
{/* Main area with sidebar */}
<box flexDirection="row" flexGrow={1}>
<box width={20} border>
<text>Sidebar</text>
</box>
<box flexGrow={1}>
<scrollbox height="100%">
{/* Scrollable content */}
</scrollbox>
</box>
</box>
{/* Footer */}
<box height={1}>
<text>Footer</text>
</box>
</box>Gotchas
Percentage Dimensions Need Parent Size
// WRONG - parent has no explicit size
<box>
<box width="50%">Won't work</box>
</box>
// CORRECT
<box width="100%">
<box width="50%">Works</box>
</box>FlexGrow Needs Sized Parent
// WRONG
<box>
<box flexGrow={1}>Won't grow</box>
</box>
// CORRECT
<box height="100%">
<box flexGrow={1}>Will grow</box>
</box>ScrollBox Needs Height
// WRONG - no height constraint
<scrollbox>
{items}
</scrollbox>
// CORRECT
<scrollbox height={20}>
{items}
</scrollbox>Borders Add to Size
Borders take up space inside the box:
<box width={10} border>
{/* Inner content area is 8 chars (10 - 2 for borders) */}
</box>Input Components
Components for user input in OpenTUI.
Input Component
Single-line text input field.
Basic Usage
// React
<input
value={value}
onChange={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
/>
// Solid
<input
value={value()}
onInput={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
/>
// Core
const input = new InputRenderable(renderer, {
id: "name",
placeholder: "Enter text...",
})
input.on(InputRenderableEvents.CHANGE, (value) => {
console.log("Value:", value)
})
input.focus()Styling
<input
width={30}
backgroundColor="#1a1a1a"
textColor="#FFFFFF"
cursorColor="#00FF00"
focusedBackgroundColor="#2a2a2a"
placeholderColor="#666666"
/>Events
// React
<input
onChange={(value) => console.log("Changed:", value)}
onFocus={() => console.log("Focused")}
onBlur={() => console.log("Blurred")}
/>
// Core
input.on(InputRenderableEvents.CHANGE, (value) => {})
input.on(InputRenderableEvents.FOCUS, () => {})
input.on(InputRenderableEvents.BLUR, () => {})Controlled Input
// React
function ControlledInput() {
const [value, setValue] = useState("")
return (
<input
value={value}
onChange={setValue}
focused
/>
)
}
// Solid
function ControlledInput() {
const [value, setValue] = createSignal("")
return (
<input
value={value()}
onInput={setValue}
focused
/>
)
}Textarea Component
Multi-line text input field.
Basic Usage
// React
<textarea
value={text}
onChange={(newText) => setText(newText)}
placeholder="Enter multiple lines..."
width={40}
height={10}
focused
/>
// Solid
<textarea
value={text()}
onInput={(newText) => setText(newText)}
placeholder="Enter multiple lines..."
width={40}
height={10}
focused
/>
// Core
const textarea = new TextareaRenderable(renderer, {
id: "editor",
width: 40,
height: 10,
placeholder: "Enter text...",
})Features
<textarea
showLineNumbers // Display line numbers
wrapText // Wrap long lines
readOnly // Disable editing
tabSize={2} // Tab character width
/>Syntax Highlighting
<textarea
language="typescript"
value={code}
onChange={setCode}
/>Select Component
List selection for choosing from options.
Basic Usage
// React
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
{ name: "Option 3", description: "Third option", value: "3" },
]}
onSelect={(index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Solid
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
]}
onSelect={(index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Core
const select = new SelectRenderable(renderer, {
id: "menu",
options: [
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
],
})
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
})
select.focus()Option Format
interface SelectOption {
name: string // Display text
description?: string // Optional description shown below
value?: any // Associated value
}Styling
<select
height={8} // Visible height
selectedIndex={0} // Initially selected
showScrollIndicator // Show scroll arrows
selectedBackgroundColor="#333"
selectedTextColor="#fff"
highlightBackgroundColor="#444"
/>Navigation
Default keybindings:
Up/k- Move upDown/j- Move downEnter- Select item
Events
Important: onSelect and onChange serve different purposes:
| Event | Trigger | Use Case |
|---|---|---|
onSelect | Enter key pressed - user confirms selection | Perform action with selected item |
onChange | Arrow keys - user navigates list | Preview, update UI as user browses |
// React/Solid
<select
onSelect={(index, option) => {
// Called when Enter is pressed - selection confirmed
console.log("User selected:", option.name)
performAction(option)
}}
onChange={(index, option) => {
// Called when navigating with arrow keys
console.log("Browsing:", option.name)
showPreview(option)
}}
/>
// Core
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
// Called when Enter is pressed
})
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
// Called when navigating with arrow keys
})Tab Select Component
Horizontal tab-based selection.
Basic Usage
// React
<tab-select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
{ name: "Help", description: "Documentation" },
]}
onSelect={(index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Solid (note underscore)
<tab_select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
]}
onSelect={(index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Core
const tabs = new TabSelectRenderable(renderer, {
id: "tabs",
options: [...],
tabWidth: 20,
})
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
})
tabs.focus()Events
Same pattern as Select - onSelect for Enter key, onChange for navigation:
<tab-select
onSelect={(index, option) => {
// Called when Enter is pressed - switch to tab
setActiveTab(index)
}}
onChange={(index, option) => {
// Called when navigating with arrow keys
showTabPreview(option)
}}
/>Styling
// React
<tab-select
tabWidth={20} // Width of each tab
selectedIndex={0} // Initially selected tab
/>
// Solid
<tab_select
tabWidth={20}
selectedIndex={0}
/>Navigation
Default keybindings:
Left/[- Previous tabRight/]- Next tabEnter- Select tab
Focus Management
Single Focused Input
function SingleInput() {
return <input placeholder="I'm focused" focused />
}Multiple Inputs with Focus State
// React
function Form() {
const [focusIndex, setFocusIndex] = useState(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
{fields.map((field, i) => (
<input
key={field}
placeholder={`Enter ${field}`}
focused={i === focusIndex}
/>
))}
</box>
)
}Focus Methods (Core)
input.focus() // Give focus
input.blur() // Remove focus
input.isFocused() // Check focus stateForm Patterns
Login Form
function LoginForm() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [focusField, setFocusField] = useState<"username" | "password">("username")
useKeyboard((key) => {
if (key.name === "tab") {
setFocusField(f => f === "username" ? "password" : "username")
}
if (key.name === "enter") {
handleLogin()
}
})
return (
<box flexDirection="column" gap={1} border padding={2}>
<box flexDirection="row" gap={1}>
<text>Username:</text>
<input
value={username}
onChange={setUsername}
focused={focusField === "username"}
width={20}
/>
</box>
<box flexDirection="row" gap={1}>
<text>Password:</text>
<input
value={password}
onChange={setPassword}
focused={focusField === "password"}
width={20}
/>
</box>
</box>
)
}Search with Results
function SearchableList({ items, onItemSelected }) {
const [query, setQuery] = useState("")
const [focusSearch, setFocusSearch] = useState(true)
const [preview, setPreview] = useState(null)
const filtered = items.filter(item =>
item.toLowerCase().includes(query.toLowerCase())
)
useKeyboard((key) => {
if (key.name === "tab") {
setFocusSearch(f => !f)
}
})
return (
<box flexDirection="column">
<input
value={query}
onChange={setQuery}
placeholder="Search..."
focused={focusSearch}
/>
<select
options={filtered.map(item => ({ name: item }))}
focused={!focusSearch}
height={10}
onSelect={(index, option) => {
// Enter pressed - confirm selection
onItemSelected(option)
}}
onChange={(index, option) => {
// Navigating - show preview
setPreview(option)
}}
/>
</box>
)
}Gotchas
Focus Required
Inputs must be focused to receive keyboard input:
// WRONG - won't receive input
<input placeholder="Type here" />
// CORRECT
<input placeholder="Type here" focused />Select Options Format
Options must be objects with name property:
// WRONG
<select options={["a", "b", "c"]} />
// CORRECT
<select options={[
{ name: "A", description: "Option A" },
{ name: "B", description: "Option B" },
]} />Solid Uses Underscores
// React
<tab-select />
// Solid
<tab_select />Value vs onInput (Solid)
Solid uses onInput instead of onChange:
// React
<input value={value} onChange={setValue} />
// Solid
<input value={value()} onInput={setValue} />OpenTUI Components
Reference for all OpenTUI components, organized by category. Components are available in all three frameworks (Core, React, Solid) with slight API differences.
When to Use
Use this reference when you need to find the right component category or compare naming across Core, React, and Solid.
Component Categories
| Category | Components | File |
|---|---|---|
| Text & Display | text, ascii-font, styled text | text-display.md |
| Containers | box, scrollbox, borders | containers.md |
| Inputs | input, textarea, select, tab-select | inputs.md |
| Code & Diff | code, line-number, diff, markdown, text-table | code-diff.md |
Component Chooser
Need a component?
├─ Styled text or ASCII art -> text-display.md
├─ Containers, borders, scrolling -> containers.md
├─ Forms or input controls -> inputs.md
└─ Code blocks, diffs, line numbers, markdown -> code-diff.mdComponent Naming
Components have different names across frameworks:
| Concept | Core (Class) | React (JSX) | Solid (JSX) |
|---|---|---|---|
| Text | TextRenderable | <text> | <text> |
| Box | BoxRenderable | <box> | <box> |
| ScrollBox | ScrollBoxRenderable | <scrollbox> | <scrollbox> |
| Input | InputRenderable | <input> | <input> |
| Textarea | TextareaRenderable | <textarea> | <textarea> |
| Select | SelectRenderable | <select> | <select> |
| Tab Select | TabSelectRenderable | <tab-select> | <tab_select> |
| ASCII Font | ASCIIFontRenderable | <ascii-font> | <ascii_font> |
| Code | CodeRenderable | <code> | <code> |
| Line Number | LineNumberRenderable | <line-number> | <line_number> |
| Diff | DiffRenderable | <diff> | <diff> |
| Markdown | MarkdownRenderable | <markdown> | <markdown> |
| TextTable | TextTableRenderable | N/A (Core only) | N/A (Core only) |
Note: Solid uses underscores (tab_select) while React uses hyphens (tab-select). TextTableRenderable is used internally by MarkdownRenderable for table rendering and is also available as a standalone Core component.
Common Properties
All components share these layout properties (see Layout):
// Positioning
position="relative" | "absolute"
left, top, right, bottom
// Dimensions
width, height
minWidth, maxWidth, minHeight, maxHeight
// Flexbox
flexDirection, flexGrow, flexShrink, flexBasis
justifyContent, alignItems, alignSelf
flexWrap, gap
// Spacing
padding, paddingTop, paddingRight, paddingBottom, paddingLeft
paddingX, paddingY // Axis shorthand (horizontal/vertical)
margin, marginTop, marginRight, marginBottom, marginLeft
marginX, marginY // Axis shorthand (horizontal/vertical)
// Display
display="flex" | "none"
overflow="visible" | "hidden" | "scroll"
zIndexQuick Examples
Core (Imperative)
import { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
const box = new BoxRenderable(renderer, {
id: "container",
border: true,
padding: 2,
})
const text = new TextRenderable(renderer, {
id: "greeting",
content: "Hello!",
fg: "#00FF00",
})
box.add(text)
renderer.root.add(box)React
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return (
<box border padding={2}>
<text fg="#00FF00">Hello!</text>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)Solid
import { render } from "@opentui/solid"
function App() {
return (
<box border padding={2}>
<text fg="#00FF00">Hello!</text>
</box>
)
}
render(() => <App />)See Also
- Core API - Imperative component classes
- React API - React component props
- Solid API - Solid component props
- Layout - Layout system details
Text & Display Components
Components for displaying text content in OpenTUI.
Text Component
The primary component for displaying styled text.
Basic Usage
// React/Solid
<text>Hello, World!</text>
// With content prop
<text content="Hello, World!" />
// Core
const text = new TextRenderable(renderer, {
id: "greeting",
content: "Hello, World!",
})Styling (React/Solid)
For React and Solid, use nested modifier tags for text styling:
<text fg="#FFFFFF" bg="#000000">
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
</text>Important: Do NOT usebold,italic,underline,dim,strikethroughas props on<text>— they don't work. Always use nested tags like<strong>,<em>,<u>, or<span>with styling.
Styling (Core) - Text Attributes
import { TextRenderable, TextAttributes } from "@opentui/core"
const text = new TextRenderable(renderer, {
content: "Styled",
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
})Available attributes:
TextAttributes.BOLDTextAttributes.DIMTextAttributes.ITALICTextAttributes.UNDERLINETextAttributes.BLINKTextAttributes.INVERSETextAttributes.HIDDENTextAttributes.STRIKETHROUGH
Text Selection
<text selectable>
This text can be selected by the user
</text>
<text selectable={false}>
This text cannot be selected
</text>For copy-on-selection and the full selection API, see keyboard/REFERENCE.md (selection).
Text Modifiers
Inline styling elements that must be used inside <text>:
Span
Inline styled text:
<text>
Normal text with <span fg="red">red text</span> inline
</text>Bold/Strong
<text>
<strong>Bold text</strong>
<b>Also bold</b>
</text>Italic/Emphasis
<text>
<em>Italic text</em>
<i>Also italic</i>
</text>Underline
<text>
<u>Underlined text</u>
</text>Line Break
<text>
Line one
<br />
Line two
</text>Link
<text>
Visit <a href="https://example.com">our website</a>
</text>Combined Modifiers
<text>
<span fg="#00FF00">
<strong>Bold green</strong>
</span>
and
<span fg="#FF0000">
<em><u>italic underlined red</u></em>
</span>
</text>Styled Text Template (Core)
The t template literal for complex styling:
import { t, bold, italic, underline, fg, bg, dim } from "@opentui/core"
const styled = t`
${bold("Bold")} and ${italic("italic")} text.
${fg("#FF0000")("Red text")} with ${bg("#0000FF")("blue background")}.
${dim("Dimmed")} and ${underline("underlined")}.
`
const text = new TextRenderable(renderer, {
content: styled,
})Style Functions
| Function | Description |
|---|---|
bold(text) | Bold text |
italic(text) | Italic text |
underline(text) | Underlined text |
dim(text) | Dimmed text |
strikethrough(text) | Strikethrough text |
fg(color)(text) | Set foreground color |
bg(color)(text) | Set background color |
ASCII Font Component
Display large ASCII art text banners.
Basic Usage
// React
<ascii-font text="TITLE" font="tiny" />
// Solid
<ascii_font text="TITLE" font="tiny" />
// Core
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "TITLE",
font: "tiny",
})Available Fonts
| Font | Description |
|---|---|
tiny | Compact ASCII font |
block | Block-style letters |
slick | Sleek modern style |
shade | Shaded 3D effect |
Styling
// React
<ascii-font
text="HELLO"
font="block"
color="#00FF00"
/>
// Core
import { RGBA } from "@opentui/core"
const title = new ASCIIFontRenderable(renderer, {
text: "HELLO",
font: "block",
color: RGBA.fromHex("#00FF00"),
})Example Output
Font: tiny
╭─╮╭─╮╭─╮╭╮╭╮╭─╮╶╮╶ ╶╮
│ ││─┘├┤ │╰╯││ │ │
╰─╯╵ ╰─╯╵ ╵╰─╯╶╯╶╰─╯
Font: block
█▀▀█ █▀▀█ █▀▀ █▀▀▄
█ █ █▀▀▀ █▀▀ █ █
▀▀▀▀ ▀ ▀▀▀ ▀ ▀Colors
Color Formats
// Hex colors
<text fg="#FF0000">Red</text>
<text fg="#F00">Short hex</text>
// Named colors
<text fg="red">Red</text>
<text fg="blue">Blue</text>
// Transparent
<text bg="transparent">No background</text>RGBA Class
The RGBA class from @opentui/core can be used in all frameworks (Core, React, Solid) for programmatic color manipulation:
import { RGBA } from "@opentui/core"
// From hex string (most common)
const red = RGBA.fromHex("#FF0000")
const shortHex = RGBA.fromHex("#F00") // Short form supported
// From integers (0-255 range for each channel)
const green = RGBA.fromInts(0, 255, 0, 255) // r, g, b, a
const semiGreen = RGBA.fromInts(0, 255, 0, 128) // 50% transparent
// From normalized floats (0.0-1.0 range)
const blue = RGBA.fromValues(0.0, 0.0, 1.0, 1.0) // r, g, b, a
const overlay = RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // Dark semi-transparent
// Common use cases
const backgroundColor = RGBA.fromHex("#1a1a2e")
const textColor = RGBA.fromHex("#FFFFFF")
const borderColor = RGBA.fromInts(122, 162, 247, 255) // Tokyo Night blue
const shadowColor = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% blackWhen to use each method:
fromHex()- When working with design specs or CSS colorsfromInts()- When you have 8-bit color values (0-255)fromValues()- When doing color math or interpolation (normalized 0.0-1.0)
Using RGBA in React/Solid
// React or Solid - RGBA works with color props
import { RGBA } from "@opentui/core"
const primaryColor = RGBA.fromHex("#7aa2f7")
function MyComponent() {
return (
<box backgroundColor={primaryColor} borderColor={primaryColor}>
<text fg={RGBA.fromHex("#c0caf5")}>Styled with RGBA</text>
</box>
)
}Most props that accept color strings ("#FF0000", "red") also accept RGBA objects directly.
Text Wrapping
Text wraps based on parent container:
<box width={40}>
<text>
This long text will wrap when it reaches the edge of the
40-character wide parent container.
</text>
</box>Dynamic Content
React
function Counter() {
const [count, setCount] = useState(0)
return <text>Count: {count}</text>
}Solid
function Counter() {
const [count, setCount] = createSignal(0)
return <text>Count: {count()}</text>
}Core
const text = new TextRenderable(renderer, {
id: "counter",
content: "Count: 0",
})
// Update later
text.setContent("Count: 1")Gotchas
Text Modifiers Outside Text
// WRONG - modifiers only work inside <text>
<box>
<strong>Won't work</strong>
</box>
// CORRECT
<box>
<text>
<strong>This works</strong>
</text>
</box>Empty Text
// May cause layout issues
<text></text>
// Better - use space or conditional
<text>{content || " "}</text>Color Format
// WRONG
<text fg="FF0000">Missing #</text>
// CORRECT
<text fg="#FF0000">With #</text>Core API Reference
Renderer
createCliRenderer(config?)
Creates and initializes the CLI renderer.
import { createCliRenderer, type CliRendererConfig } from "@opentui/core"
const renderer = await createCliRenderer({
targetFPS: 60, // Target frames per second
exitOnCtrlC: true, // Exit process on Ctrl+C
consoleOptions: { // Debug console overlay
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
onDestroy: () => {}, // Cleanup callback
})CliRenderer Instance
renderer.root // Root renderable node
renderer.width // Terminal width in columns
renderer.height // Terminal height in rows
renderer.keyInput // Keyboard event emitter
renderer.console // Console overlay controller
renderer.start() // Start render loop
renderer.stop() // Stop render loop
renderer.destroy() // Cleanup and exit alternate screen
renderer.requestRender() // Request a re-render
renderer.setCursorStyle(options) // Set cursor style
renderer.setCursorColor(color) // Set cursor color
renderer.setMousePointer(style) // Set mouse pointer shapeCursor & Mouse Pointer
import { type CursorStyleOptions, type MousePointerStyle } from "@opentui/core"
// Set cursor style (options object)
renderer.setCursorStyle({
style: "block", // "block" | "line" | "underline" | "default"
blinking: true, // Cursor blink
color: RGBA.fromHex("#FF0000"), // Cursor color
cursor: "pointer", // Mouse pointer shape
})
// Set mouse pointer shape (OSC 22)
renderer.setMousePointer("pointer")
// Available: "default" | "pointer" | "text" | "crosshair" | "move" | "not-allowed"Renderer Events
renderer.on("resize", (width, height) => {}) // Terminal resized
renderer.on("focus", () => {}) // Terminal window gained focus
renderer.on("blur", () => {}) // Terminal window lost focus
renderer.on("theme_mode", (mode) => {}) // "dark" | "light"
renderer.on("capabilities", (caps) => {}) // Terminal capabilities detected
renderer.on("selection", (selection) => {}) // Text selection finished (mouse-up)
renderer.on("destroy", () => {}) // Renderer destroyed
renderer.on("memory:snapshot", (snapshot) => {}) // Memory snapshot
renderer.on("debugOverlay:toggle", () => {}) // Debug overlay toggledConsole Overlay
renderer.console.show() // Show console overlay
renderer.console.hide() // Hide console overlay
renderer.console.toggle() // Toggle visibility/focus
renderer.console.clear() // Clear console contentsRenderables
All renderables extend the base Renderable class and share common properties.
Common Properties
interface CommonProps {
id?: string // Unique identifier
// Positioning
position?: "relative" | "absolute"
left?: number | string
top?: number | string
right?: number | string
bottom?: number | string
// Dimensions
width?: number | string | "auto"
height?: number | string | "auto"
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
// Flexbox
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
flexGrow?: number
flexShrink?: number
flexBasis?: number | string
flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly"
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
alignContent?: "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-around"
// Spacing
padding?: number
paddingTop?: number
paddingRight?: number
paddingBottom?: number
paddingLeft?: number
margin?: number
marginTop?: number
marginRight?: number
marginBottom?: number
marginLeft?: number
gap?: number
// Display
display?: "flex" | "none"
overflow?: "visible" | "hidden" | "scroll"
zIndex?: number
}Renderable Methods
renderable.add(child) // Add child renderable
renderable.remove(child) // Remove child renderable
renderable.getRenderable(id) // Find child by ID
renderable.focus() // Focus this renderable
renderable.blur() // Remove focus
renderable.destroy() // Destroy and cleanup
renderable.on(event, handler) // Add event listener
renderable.off(event, handler) // Remove event listener
renderable.emit(event, ...args) // Emit eventTextRenderable
Display styled text content.
import { TextRenderable, TextAttributes, t, bold, fg, underline } from "@opentui/core"
const text = new TextRenderable(renderer, {
id: "text",
content: "Hello World",
fg: "#FFFFFF", // Foreground color
bg: "#000000", // Background color
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
selectable: true, // Allow text selection
})
// Styled text with template literals
const styled = new TextRenderable(renderer, {
content: t`${bold("Bold")} and ${fg("#FF0000")(underline("red underlined"))}`,
})TextAttributes flags:
TextAttributes.BOLDTextAttributes.DIMTextAttributes.ITALICTextAttributes.UNDERLINETextAttributes.BLINKTextAttributes.INVERSETextAttributes.HIDDENTextAttributes.STRIKETHROUGH
BoxRenderable
Container with borders and layout.
import { BoxRenderable } from "@opentui/core"
const box = new BoxRenderable(renderer, {
id: "box",
width: 40,
height: 10,
backgroundColor: "#1a1a2e",
border: true,
borderStyle: "single" | "double" | "rounded" | "bold" | "none",
borderColor: "#FFFFFF",
title: "Panel Title",
titleAlignment: "left" | "center" | "right",
onMouseDown: (event) => {},
onMouseUp: (event) => {},
onMouseMove: (event) => {},
})InputRenderable
Single-line text input.
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
const input = new InputRenderable(renderer, {
id: "input",
width: 30,
placeholder: "Enter text...",
value: "", // Initial value
backgroundColor: "#1a1a1a",
textColor: "#FFFFFF",
cursorColor: "#00FF00",
focusedBackgroundColor: "#2a2a2a",
})
input.on(InputRenderableEvents.CHANGE, (value: string) => {
console.log("Value:", value)
})
input.focus() // Must be focused to receive inputSelectRenderable
List selection component.
import { SelectRenderable, SelectRenderableEvents } from "@opentui/core"
const select = new SelectRenderable(renderer, {
id: "select",
width: 30,
height: 10,
options: [
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
],
selectedIndex: 0,
})
// Called when Enter is pressed - selection confirmed
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Selected:", option.name)
performAction(option)
})
// Called when navigating with arrow keys
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
console.log("Browsing:", option.name)
showPreview(option)
})
select.focus() // Navigate with up/down/j/k, select with enterEvent distinction:
ITEM_SELECTED- Enter key pressed, user confirms selectionSELECTION_CHANGED- Arrow keys, user navigating/browsing options
TabSelectRenderable
Horizontal tab selection.
import { TabSelectRenderable, TabSelectRenderableEvents } from "@opentui/core"
const tabs = new TabSelectRenderable(renderer, {
id: "tabs",
width: 60,
options: [
{ name: "Home", description: "Dashboard" },
{ name: "Settings", description: "Configuration" },
],
tabWidth: 20,
})
// Called when Enter is pressed - tab selected
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Tab selected:", option.name)
switchToTab(index)
})
// Called when navigating with arrow keys
tabs.on(TabSelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
console.log("Browsing tab:", option.name)
})
tabs.focus() // Navigate with left/right/[/], select with enterEvent distinction (same as SelectRenderable):
ITEM_SELECTED- Enter key pressed, user confirms tabSELECTION_CHANGED- Arrow keys, user navigating tabs
ScrollBoxRenderable
Scrollable container.
import { ScrollBoxRenderable } from "@opentui/core"
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "scrollbox",
width: 40,
height: 20,
showScrollbar: true,
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
})
// Add content that exceeds viewport
for (let i = 0; i < 100; i++) {
scrollbox.add(new TextRenderable(renderer, {
id: `line-${i}`,
content: `Line ${i}`,
}))
}
scrollbox.focus() // Scroll with arrow keysASCIIFontRenderable
ASCII art text.
import { ASCIIFontRenderable, RGBA } from "@opentui/core"
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "OPENTUI",
font: "tiny" | "block" | "slick" | "shade",
color: RGBA.fromHex("#FFFFFF"),
})FrameBufferRenderable
Low-level 2D rendering surface.
import { FrameBufferRenderable, RGBA } from "@opentui/core"
const canvas = new FrameBufferRenderable(renderer, {
id: "canvas",
width: 50,
height: 20,
})
// Direct pixel manipulation
canvas.frameBuffer.fillRect(10, 5, 20, 8, RGBA.fromHex("#FF0000"))
canvas.frameBuffer.drawText("Custom", 12, 7, RGBA.fromHex("#FFFFFF"))
canvas.frameBuffer.setCell(x, y, char, fg, bg)Constructs (VNode API)
Declarative wrappers that create VNodes instead of direct instances.
import { Text, Box, Input, Select, instantiate, delegate } from "@opentui/core"
// Create VNode tree
const ui = Box(
{ border: true, padding: 1 },
Text({ content: "Hello" }),
Input({ placeholder: "Type here..." }),
)
// Instantiate onto renderer
renderer.root.add(ui)
// Delegate focus to nested element
const form = delegate(
{ focus: "email-input" },
Box(
{},
Text({ content: "Email:" }),
Input({ id: "email-input", placeholder: "you@example.com" }),
),
)
form.focus() // Focuses the input, not the boxColors (RGBA)
The RGBA class is exported from @opentui/core but works across all frameworks (Core, React, Solid). Use it for programmatic color manipulation.
Creating Colors
import { RGBA, parseColor } from "@opentui/core"
// From hex string (most common)
RGBA.fromHex("#FF0000") // Full hex
RGBA.fromHex("#F00") // Short hex
// From integers (0-255 range)
RGBA.fromInts(255, 0, 0, 255) // r, g, b, a - fully opaque red
RGBA.fromInts(255, 0, 0, 128) // 50% transparent red
RGBA.fromInts(0, 0, 0, 0) // Fully transparent
// From normalized floats (0.0-1.0 range)
RGBA.fromValues(1.0, 0.0, 0.0, 1.0) // Fully opaque red
RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // Dark gray, 70% opaque
RGBA.fromValues(0.0, 0.5, 1.0, 1.0) // Light blueCommon Color Patterns
// Theme colors
const primary = RGBA.fromHex("#7aa2f7") // Tokyo Night blue
const background = RGBA.fromHex("#1a1a2e")
const foreground = RGBA.fromHex("#c0caf5")
const error = RGBA.fromHex("#f7768e")
// Overlays and shadows
const modalOverlay = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% black
const shadow = RGBA.fromInts(0, 0, 0, 77) // 30% black
// Borders
const activeBorder = RGBA.fromHex("#7aa2f7")
const inactiveBorder = RGBA.fromInts(65, 72, 104, 255)parseColor Utility
// Accepts multiple formats
parseColor("#FF0000") // Hex string
parseColor("red") // CSS color name
parseColor("transparent") // Special values
parseColor(RGBA.fromHex("#F00")) // Pass-through RGBA objectsWhen to Use Each Method
| Method | Use When |
|---|---|
fromHex() | Working with design specs, CSS colors, config files |
fromInts() | You have 8-bit values (0-255), common in graphics |
fromValues() | Doing color interpolation, animations, math |
parseColor() | Accepting user input or config that could be any format |
Using RGBA in React/Solid
// Import from @opentui/core, use in any framework
import { RGBA } from "@opentui/core"
// React or Solid component
function ThemedBox() {
const bg = RGBA.fromHex("#1a1a2e")
const border = RGBA.fromInts(122, 162, 247, 255)
return (
<box backgroundColor={bg} borderColor={border} border>
<text fg={RGBA.fromHex("#c0caf5")}>Works everywhere!</text>
</box>
)
}Color props in React/Solid accept both string formats ("#FF0000", "red") and RGBA objects.
Keyboard Input
import { type KeyEvent } from "@opentui/core"
renderer.keyInput.on("keypress", (key: KeyEvent) => {
console.log(key.name) // "a", "escape", "f1", etc.
console.log(key.sequence) // Raw escape sequence
console.log(key.ctrl) // Ctrl held
console.log(key.shift) // Shift held
console.log(key.meta) // Alt held
console.log(key.option) // Option held (macOS)
console.log(key.eventType) // "press" | "release" | "repeat"
})
renderer.keyInput.on("paste", (event: PasteEvent) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})Animation Timeline
import { Timeline, engine } from "@opentui/core"
const timeline = new Timeline({
duration: 2000,
loop: false,
autoplay: true,
})
timeline.add(
{ width: 0 },
{
width: 50,
duration: 1000,
ease: "easeOutQuad",
onUpdate: (anim) => {
box.setWidth(anim.targets[0].width)
},
},
)
engine.attach(renderer)
engine.addTimeline(timeline)Type Exports
import type {
CliRenderer,
CliRendererConfig,
RenderContext,
KeyEvent,
Renderable,
// ... and more
} from "@opentui/core"Core Configuration
Renderer Configuration
createCliRenderer Options
import { createCliRenderer, ConsolePosition } from "@opentui/core"
const renderer = await createCliRenderer({
// Rendering
targetFPS: 60, // Target frames per second (default: 60)
// Behavior
exitOnCtrlC: true, // Exit on Ctrl+C (default: true)
// Console overlay
consoleOptions: {
position: ConsolePosition.BOTTOM, // BOTTOM | TOP | LEFT | RIGHT
sizePercent: 30, // Percentage of screen
colorInfo: "#00FFFF",
colorWarn: "#FFFF00",
colorError: "#FF0000",
colorDebug: "#888888",
startInDebugMode: false,
},
// Lifecycle
onDestroy: () => {
// Cleanup callback
},
})Environment Variables
OpenTUI respects several environment variables for configuration and debugging.
Debug & Development
| Variable | Type | Default | Description |
|---|---|---|---|
OTUI_DEBUG | boolean | false | Enable debug mode, capture raw input |
OTUI_DEBUG_FFI | boolean | false | Debug logging for FFI bindings |
OTUI_TRACE_FFI | boolean | false | Tracing for FFI bindings |
OTUI_SHOW_STATS | boolean | false | Show debug overlay at startup |
OTUI_DUMP_CAPTURES | boolean | false | Dump captured output on exit |
Console
| Variable | Type | Default | Description |
|---|---|---|---|
OTUI_USE_CONSOLE | boolean | true | Enable console capture |
SHOW_CONSOLE | boolean | false | Show console at startup |
Rendering
| Variable | Type | Default | Description |
|---|---|---|---|
OTUI_NO_NATIVE_RENDER | boolean | false | Disable ANSI output (for debugging) |
OTUI_USE_ALTERNATE_SCREEN | boolean | true | Use alternate screen buffer |
OTUI_OVERRIDE_STDOUT | boolean | true | Override stdout stream |
Terminal Capabilities
| Variable | Type | Default | Description |
|---|---|---|---|
OPENTUI_NO_GRAPHICS | boolean | false | Disable Kitty graphics protocol |
OPENTUI_FORCE_UNICODE | boolean | false | Force Mode 2026 Unicode support |
OPENTUI_FORCE_WCWIDTH | boolean | false | Use wcwidth for character width |
OPENTUI_FORCE_NOZWJ | boolean | false | Disable ZWJ emoji joining |
OPENTUI_FORCE_EXPLICIT_WIDTH | string | - | Force explicit width ("true"/"false") |
Tree-sitter (Syntax Highlighting)
| Variable | Type | Default | Description |
|---|---|---|---|
OTUI_TS_STYLE_WARN | boolean | false | Warn on missing syntax styles |
OTUI_TREE_SITTER_WORKER_PATH | string | "" | Custom tree-sitter worker path |
XDG Paths
| Variable | Type | Default | Description |
|---|---|---|---|
XDG_CONFIG_HOME | string | "" | User config directory |
XDG_DATA_HOME | string | "" | User data directory |
Usage Examples
Development Mode
# Show debug overlay and console
OTUI_SHOW_STATS=true SHOW_CONSOLE=true bun run src/index.ts
# Debug FFI issues
OTUI_DEBUG_FFI=true OTUI_TRACE_FFI=true bun run src/index.ts
# Disable native rendering for testing
OTUI_NO_NATIVE_RENDER=true bun run src/index.tsTerminal Compatibility
# Force wcwidth for problematic terminals
OPENTUI_FORCE_WCWIDTH=true bun run src/index.ts
# Disable graphics for SSH sessions
OPENTUI_NO_GRAPHICS=true bun run src/index.tsProject Setup
package.json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --watch run src/index.ts",
"test": "bun test"
},
"dependencies": {
"@opentui/core": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "latest"
}
}tsconfig.json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}Note: OpenTUI usesNodeNextmodule resolution. All internal imports use.jsextensions. If you usebundlerresolution, imports still work butNodeNextis recommended for compatibility.
Building Native Code
Native code changes require rebuilding:
# From repo root (if developing OpenTUI itself)
bun run build
# Zig is required for native compilation
# Install: https://ziglang.org/learn/getting-started/Note: TypeScript changes do NOT require building. Bun runs TypeScript directly.
Core Gotchas
Runtime Environment
Use Bun, Not Node.js
OpenTUI is built for Bun. Always use Bun commands:
# CORRECT
bun install @opentui/core
bun run src/index.ts
bun test
# WRONG
npm install @opentui/core
node src/index.ts
npx jestBun APIs to Use
Prefer Bun's built-in APIs for your application code:
// CORRECT - Bun APIs
Bun.serve({ ... }) // Instead of express
Bun.$`ls -la` // Instead of execa
import { Database } from "bun:sqlite" // Instead of better-sqlite3
// WRONG - Node.js patterns
import express from "express"Note: OpenTUI itself uses node:fs internally for file I/O (for broader compatibility), but your application code should still prefer Bun APIs where available.Avoid process.exit()
Never use `process.exit()` directly - it prevents proper terminal cleanup and can leave the terminal in a broken state (alternate screen mode, raw input mode, etc.).
// WRONG - Terminal may be left in broken state
if (error) {
console.error("Fatal error")
process.exit(1)
}
// CORRECT - Use renderer.destroy() for cleanup
if (error) {
console.error("Fatal error")
await renderer.destroy()
process.exit(1) // Only after destroy
}
// BETTER - Let destroy handle exit
const renderer = await createCliRenderer({
exitOnCtrlC: true, // Handles Ctrl+C properly
})
// For programmatic exit
renderer.destroy() // Cleans up and exitsrenderer.destroy() restores the terminal to its original state before exiting.
Environment Variables
Bun auto-loads .env files. Don't use dotenv:
// CORRECT
const apiKey = process.env.API_KEY
// WRONG
import dotenv from "dotenv"
dotenv.config()Debugging TUIs
Cannot See console.log Output
OpenTUI captures console output for the debug overlay. You can't see logs in the terminal while the TUI is running.
Solutions:
1. Use the console overlay:
const renderer = await createCliRenderer()
renderer.console.show()
console.log("This appears in the overlay")2. Toggle with keyboard:
renderer.keyInput.on("keypress", (key) => {
if (key.name === "f12") {
renderer.console.toggle()
}
})3. Write to a file:
import { appendFileSync } from "node:fs"
function debugLog(msg: string) {
appendFileSync("debug.log", `${new Date().toISOString()} ${msg}\n`)
}4. Disable console capture:
OTUI_USE_CONSOLE=false bun run src/index.tsReproduce Issues in Tests
Don't guess at bugs. Create a reproducible test:
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
test("reproduces the issue", async () => {
const { renderer, snapshot } = await createTestRenderer({
width: 40,
height: 10,
})
// Setup that reproduces the bug
const box = new BoxRenderable(renderer, { ... })
renderer.root.add(box)
// Verify with snapshot
expect(snapshot()).toMatchSnapshot()
})Focus Management
Components Must Be Focused
Input components only receive keyboard input when focused:
const input = new InputRenderable(renderer, {
id: "input",
placeholder: "Type here...",
})
renderer.root.add(input)
// WRONG - input won't receive keystrokes
// (no focus call)
// CORRECT
input.focus()Focus in Nested Components
When a component is inside a container, focus the component directly:
const container = new BoxRenderable(renderer, { id: "container" })
const input = new InputRenderable(renderer, { id: "input" })
container.add(input)
renderer.root.add(container)
// WRONG
container.focus()
// CORRECT
input.focus()
// Or use getRenderable
container.getRenderable("input")?.focus()
// Or use delegate (constructs)
const form = delegate(
{ focus: "input" },
Box({}, Input({ id: "input" })),
)
form.focus() // Routes to the inputBuild Requirements
Zig is Required
Native code compilation requires Zig:
# Install Zig first
# macOS
brew install zig
# Linux
# Download from https://ziglang.org/download/
# Then build
bun run buildWhen to Build
- TypeScript changes: NO build needed (Bun runs TS directly)
- Native code changes: Build required
# Only needed when changing native (Zig) code
cd packages/core
bun run buildCommon Errors
"Cannot read properties of undefined"
Usually means a renderable wasn't added to the tree:
// WRONG - not added to tree
const text = new TextRenderable(renderer, { content: "Hello" })
// text.someMethod() // May fail
// CORRECT
const text = new TextRenderable(renderer, { content: "Hello" })
renderer.root.add(text)
text.someMethod()Layout Not Updating
Yoga layout is calculated lazily. Force a recalculation:
// After changing layout properties
box.setWidth(newWidth)
renderer.requestRender()Text Overflow/Clipping
Text doesn't wrap by default. Set explicit width:
// May overflow
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
})
// Contained within width
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
width: 40, // Will clip or wrap based on parent
})Colors Not Showing
Check terminal capability and color format:
// CORRECT formats
fg: "#FF0000" // Hex
fg: "red" // CSS color name
fg: RGBA.fromHex("#FF0000")
// WRONG
fg: "FF0000" // Missing #
fg: 0xFF0000 // Number (not supported)Performance
Avoid Frequent Re-renders
Batch updates when possible:
// WRONG - multiple render calls
item1.setContent("...")
item2.setContent("...")
item3.setContent("...")
// BETTER - single render after all updates
// (OpenTUI batches automatically, but be mindful)
items.forEach((item, i) => {
item.setContent(data[i])
})Minimize Tree Depth
Deep nesting impacts layout calculation:
// Avoid unnecessary wrappers
// WRONG
Box({}, Box({}, Box({}, Text({ content: "Hello" }))))
// CORRECT
Box({}, Text({ content: "Hello" }))Use display: none
Hide elements instead of removing/re-adding:
// For toggling visibility
element.setDisplay("none") // Hidden
element.setDisplay("flex") // Visible
// Instead of
parent.remove(element)
parent.add(element)Testing
Test Runner
Use Bun's test runner:
import { test, expect, beforeEach, afterEach } from "bun:test"
test("my test", () => {
expect(1 + 1).toBe(2)
})Test from Package Directories
Run tests from the specific package directory:
# CORRECT
cd packages/core
bun test
# For native tests
cd packages/core
bun run test:nativeFilter Tests
# Bun test filter
bun test --filter "component name"
# Native test filter
bun run test:native -Dtest-filter="test name"Keyboard Handling
Key Names
Common key names for KeyEvent.name:
// Letters/numbers
"a", "b", ..., "z"
"1", "2", ..., "0"
// Special keys
"escape", "enter", "return", "tab", "backspace", "delete"
"up", "down", "left", "right"
"home", "end", "pageup", "pagedown"
"f1", "f2", ..., "f12"
"space"
// Modifiers (check boolean properties)
key.ctrl // Ctrl held
key.shift // Shift held
key.meta // Alt held
key.option // Option held (macOS)Key Event Types
renderer.keyInput.on("keypress", (key) => {
// eventType: "press" | "release" | "repeat"
if (key.eventType === "repeat") {
// Key being held down
}
})Core Patterns
Composition Patterns
Imperative Composition
Create renderables and compose with .add():
import { createCliRenderer, BoxRenderable, TextRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
// Create parent
const container = new BoxRenderable(renderer, {
id: "container",
flexDirection: "column",
padding: 1,
})
// Create children
const header = new TextRenderable(renderer, {
id: "header",
content: "Header",
fg: "#00FF00",
})
const body = new TextRenderable(renderer, {
id: "body",
content: "Body content",
})
// Compose tree
container.add(header)
container.add(body)
renderer.root.add(container)Declarative Composition (Constructs)
Use VNode functions for cleaner composition:
import { createCliRenderer, Box, Text, Input, delegate } from "@opentui/core"
const renderer = await createCliRenderer()
// Compose as function calls
const ui = Box(
{ flexDirection: "column", padding: 1 },
Text({ content: "Header", fg: "#00FF00" }),
Box(
{ flexDirection: "row", gap: 2 },
Text({ content: "Name:" }),
Input({ id: "name", placeholder: "Enter name..." }),
),
)
renderer.root.add(ui)Reusable Components
Create factory functions for reusable UI pieces:
// Imperative factory
function createLabeledInput(
renderer: RenderContext,
props: { id: string; label: string; placeholder: string }
) {
const container = new BoxRenderable(renderer, {
id: `${props.id}-container`,
flexDirection: "row",
gap: 1,
})
container.add(new TextRenderable(renderer, {
id: `${props.id}-label`,
content: props.label,
}))
container.add(new InputRenderable(renderer, {
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
}))
return container
}
// Declarative factory
function LabeledInput(props: { id: string; label: string; placeholder: string }) {
return delegate(
{ focus: `${props.id}-input` },
Box(
{ flexDirection: "row", gap: 1 },
Text({ content: props.label }),
Input({
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
}),
),
)
}Focus Delegation
Route focus calls to nested elements:
import { delegate, Box, Input, Text } from "@opentui/core"
const form = delegate(
{
focus: "email-input", // Route .focus() to this child
blur: "email-input", // Route .blur() to this child
},
Box(
{ border: true, padding: 1 },
Text({ content: "Email:" }),
Input({ id: "email-input", placeholder: "you@example.com" }),
),
)
// This focuses the input inside, not the box
form.focus()Event Handling
Keyboard Events
const renderer = await createCliRenderer()
// Global keyboard handler
renderer.keyInput.on("keypress", (key) => {
if (key.name === "escape") {
renderer.destroy()
process.exit(0)
}
if (key.ctrl && key.name === "c") {
// Ctrl+C handling (if exitOnCtrlC is false)
}
if (key.name === "tab") {
// Tab navigation
focusNext()
}
})
// Paste events
renderer.keyInput.on("paste", (event) => {
const text = decodePasteBytes(event.bytes)
currentInput?.setValue(currentInput.value + text)
})Component Events
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
const input = new InputRenderable(renderer, {
id: "search",
placeholder: "Search...",
})
input.on(InputRenderableEvents.CHANGE, (value) => {
performSearch(value)
})
// Select events
const select = new SelectRenderable(renderer, {
id: "menu",
options: [...],
})
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
handleSelection(option)
})
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
showPreview(option)
})Mouse Events
const button = new BoxRenderable(renderer, {
id: "button",
border: true,
onMouseDown: (event) => {
button.setBackgroundColor("#444444")
},
onMouseUp: (event) => {
button.setBackgroundColor("#222222")
handleClick()
},
onMouseMove: (event) => {
// Hover effect
},
})State Management
Local State
Manage state in closures or objects:
// Closure-based state
function createCounter(renderer: RenderContext) {
let count = 0
const display = new TextRenderable(renderer, {
id: "count",
content: `Count: ${count}`,
})
const increment = () => {
count++
display.setContent(`Count: ${count}`)
}
return { display, increment }
}
// Class-based state
class CounterWidget {
private count = 0
private display: TextRenderable
constructor(renderer: RenderContext) {
this.display = new TextRenderable(renderer, {
id: "count",
content: this.formatCount(),
})
}
private formatCount() {
return `Count: ${this.count}`
}
increment() {
this.count++
this.display.setContent(this.formatCount())
}
getRenderable() {
return this.display
}
}Focus Management
Track and manage focus across components:
class FocusManager {
private focusables: Renderable[] = []
private currentIndex = 0
register(renderable: Renderable) {
this.focusables.push(renderable)
}
focusNext() {
this.focusables[this.currentIndex]?.blur()
this.currentIndex = (this.currentIndex + 1) % this.focusables.length
this.focusables[this.currentIndex]?.focus()
}
focusPrevious() {
this.focusables[this.currentIndex]?.blur()
this.currentIndex = (this.currentIndex - 1 + this.focusables.length) % this.focusables.length
this.focusables[this.currentIndex]?.focus()
}
}
// Usage
const focusManager = new FocusManager()
focusManager.register(input1)
focusManager.register(input2)
focusManager.register(select1)
renderer.keyInput.on("keypress", (key) => {
if (key.name === "tab") {
key.shift ? focusManager.focusPrevious() : focusManager.focusNext()
}
})Lifecycle Patterns
Cleanup
Always clean up resources:
const renderer = await createCliRenderer()
// Track intervals/timeouts
const intervals: Timer[] = []
intervals.push(setInterval(() => {
updateClock()
}, 1000))
// Cleanup on exit
process.on("SIGINT", () => {
intervals.forEach(clearInterval)
renderer.destroy()
process.exit(0)
})
// Or use onDestroy callback
const renderer = await createCliRenderer({
onDestroy: () => {
intervals.forEach(clearInterval)
},
})Dynamic Updates
Update UI based on external data:
async function createDashboard(renderer: RenderContext) {
const statsText = new TextRenderable(renderer, {
id: "stats",
content: "Loading...",
})
// Poll for updates
const updateStats = async () => {
const data = await fetchStats()
statsText.setContent(`CPU: ${data.cpu}% | Memory: ${data.memory}%`)
}
// Initial load
await updateStats()
// Periodic updates
setInterval(updateStats, 5000)
return statsText
}Layout Patterns
Responsive Layout
Adapt to terminal size:
const renderer = await createCliRenderer()
const mainPanel = new BoxRenderable(renderer, {
id: "main",
width: "100%",
height: "100%",
flexDirection: renderer.width > 80 ? "row" : "column",
})
// Listen for resize
process.stdout.on("resize", () => {
mainPanel.setFlexDirection(renderer.width > 80 ? "row" : "column")
})Split Panels
function createSplitView(renderer: RenderContext, ratio = 0.3) {
const container = new BoxRenderable(renderer, {
id: "split",
flexDirection: "row",
width: "100%",
height: "100%",
})
const left = new BoxRenderable(renderer, {
id: "left",
width: `${ratio * 100}%`,
border: true,
})
const right = new BoxRenderable(renderer, {
id: "right",
flexGrow: 1,
border: true,
})
container.add(left)
container.add(right)
return { container, left, right }
}Debugging Patterns
Console Overlay
Use the built-in console for debugging:
const renderer = await createCliRenderer({
consoleOptions: {
startInDebugMode: true,
},
})
// Show console
renderer.console.show()
// All console methods work
console.log("Debug info")
console.warn("Warning")
console.error("Error")
// Toggle with keyboard
renderer.keyInput.on("keypress", (key) => {
if (key.name === "f12") {
renderer.console.toggle()
}
})State Inspection
function debugState(label: string, state: unknown) {
console.log(`[${label}]`, JSON.stringify(state, null, 2))
}
// In your update logic
debugState("form", { name: nameInput.value, email: emailInput.value })OpenTUI Core (@opentui/core)
The foundational library for building terminal user interfaces. Provides an imperative API with all primitives, giving you maximum control over rendering, state, and behavior.
Overview
OpenTUI Core runs on Bun with native Zig bindings for performance-critical operations:
- Renderer: Manages terminal output, input events, and the rendering loop
- Renderables: Hierarchical UI building blocks with Yoga layout
- Constructs: Declarative wrappers for composing Renderables
- FrameBuffer: Low-level 2D rendering surface for custom graphics
When to Use Core
Use the core imperative API when:
- Building a library or framework on top of OpenTUI
- Need maximum control over rendering and state
- Want smallest possible bundle size (no React/Solid runtime)
- Building performance-critical applications
- Integrating with existing imperative codebases
When NOT to Use Core
| Scenario | Use Instead |
|---|---|
| Familiar with React patterns | @opentui/react |
| Want fine-grained reactivity | @opentui/solid |
| Building typical applications | React or Solid reconciler |
| Rapid prototyping | React or Solid reconciler |
Quick Start
Using create-tui (Recommended)
bunx create-tui@latest -t core my-app
cd my-app
bun run src/index.tsThe CLI creates the my-app directory for you - it must not already exist.
Agent guidance: Always use autonomous mode with -t <template> flag. Never use interactive mode (bunx create-tui@latest my-app without -t) as it requires user prompts that agents cannot respond to.
Manual Setup
mkdir my-tui && cd my-tui
bun init
bun install @opentui/coreimport { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
// Create a box container
const container = new BoxRenderable(renderer, {
id: "container",
width: 40,
height: 10,
border: true,
borderStyle: "rounded",
padding: 1,
})
// Create text inside the box
const greeting = new TextRenderable(renderer, {
id: "greeting",
content: "Hello, OpenTUI!",
fg: "#00FF00",
})
// Compose the tree
container.add(greeting)
renderer.root.add(container)Core Concepts
Renderer
The CliRenderer orchestrates everything:
- Manages the terminal viewport and alternate screen
- Handles input events (keyboard, mouse, paste)
- Runs the rendering loop (configurable FPS)
- Provides the root node for the renderable tree
Renderables vs Constructs
| Renderables (Imperative) | Constructs (Declarative) |
|---|---|
new TextRenderable(renderer, {...}) | Text({...}) |
| Requires renderer at creation | Creates VNode, instantiated later |
| Direct mutation via methods | Chained calls recorded, replayed on instantiation |
| Full control | Cleaner composition |
Storage Options
Renderables can be composed in two ways: 1. Imperative: Create instances, call .add() to compose 2. Declarative (Constructs): Create VNodes, pass children as arguments
Essential Commands
bun install @opentui/core # Install
bun run src/index.ts # Run directly (no build needed)
bun test # Run testsRuntime Requirements
OpenTUI runs on Bun and uses Zig for native builds.
# Package management
bun install @opentui/core
# Running
bun run src/index.ts
bun test
# Building (only needed for native code changes)
bun run buildZig is required for building native components.
In This Reference
- Configuration - Renderer options, environment variables
- API - Renderer, Renderables, types, utilities
- Patterns - Composition, events, state management
- Gotchas - Common issues, debugging, limitations
See Also
- React - React reconciler for declarative TUI
- Solid - Solid reconciler for declarative TUI
- Layout - Yoga/Flexbox layout system
- Components - Component reference by category
- Keyboard - Input handling and shortcuts
- Testing - Test renderer and snapshots
Keyboard Input Handling
How to handle keyboard input in OpenTUI applications.
Overview
OpenTUI provides keyboard input handling through:
- Core:
renderer.keyInputEventEmitter - React:
useKeyboard()hook - Solid:
useKeyboard()hook
When to Use
Use this reference when you need keyboard shortcuts, focus-aware input handling, or custom keybindings.
KeyEvent Object
All keyboard handlers receive a KeyEvent object:
interface KeyEvent {
name: string // Key name: "a", "escape", "f1", etc.
sequence: string // Raw escape sequence
ctrl: boolean // Ctrl modifier held
shift: boolean // Shift modifier held
meta: boolean // Alt modifier held
option: boolean // Option modifier held (macOS)
eventType: "press" | "release" | "repeat"
repeated: boolean // Key is being held (repeat event)
}Basic Usage
Core
import { createCliRenderer, type KeyEvent } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.keyInput.on("keypress", (key: KeyEvent) => {
if (key.name === "escape") {
renderer.destroy()
return
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})React
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to exit</text>
}Solid
import { useKeyboard, useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to exit</text>
}Key Names
Alphabetic Keys
Lowercase: a, b, c, ... z
With Shift: Check key.shift && key.name === "a" for uppercase
Numeric Keys
0, 1, 2, ... 9
Function Keys
f1, f2, f3, ... f12
Special Keys
| Key Name | Description |
|---|---|
escape | Escape key |
enter | Enter/Return |
return | Enter/Return (alias) |
tab | Tab key |
backspace | Backspace |
delete | Delete key |
space | Spacebar |
Arrow Keys
| Key Name | Description |
|---|---|
up | Up arrow |
down | Down arrow |
left | Left arrow |
right | Right arrow |
Navigation Keys
| Key Name | Description |
|---|---|
home | Home key |
end | End key |
pageup | Page Up |
pagedown | Page Down |
insert | Insert key |
Modifier Keys
Check modifier properties on KeyEvent:
renderer.keyInput.on("keypress", (key) => {
if (key.ctrl && key.name === "c") {
// Ctrl+C
}
if (key.shift && key.name === "tab") {
// Shift+Tab
}
if (key.meta && key.name === "s") {
// Alt+S (meta = Alt on most systems)
}
if (key.option && key.name === "a") {
// Option+A (macOS)
}
})Modifier Combinations
// Ctrl+Shift+S
if (key.ctrl && key.shift && key.name === "s") {
saveAs()
}
// Ctrl+Alt+Delete (careful with system shortcuts!)
if (key.ctrl && key.meta && key.name === "delete") {
// ...
}Event Types
Press Events (Default)
Normal key press:
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "press") {
// Initial key press
}
})Repeat Events
Key held down:
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "repeat" || key.repeated) {
// Key is being held
}
})Release Events
Key released (opt-in):
// React
useKeyboard(
(key) => {
if (key.eventType === "release") {
// Key released
}
},
{ release: true } // Enable release events
)
// Solid
useKeyboard(
(key) => {
if (key.eventType === "release") {
// Key released
}
},
{ release: true }
)Patterns
Navigation Menu
function Menu() {
const [selectedIndex, setSelectedIndex] = useState(0)
const items = ["Home", "Settings", "Help", "Quit"]
useKeyboard((key) => {
switch (key.name) {
case "up":
case "k":
setSelectedIndex(i => Math.max(0, i - 1))
break
case "down":
case "j":
setSelectedIndex(i => Math.min(items.length - 1, i + 1))
break
case "enter":
handleSelect(items[selectedIndex])
break
}
})
return (
<box flexDirection="column">
{items.map((item, i) => (
<text
key={item}
fg={i === selectedIndex ? "#00FF00" : "#FFFFFF"}
>
{i === selectedIndex ? "> " : " "}{item}
</text>
))}
</box>
)
}Modal Escape
function Modal({ onClose, children }) {
useKeyboard((key) => {
if (key.name === "escape") {
onClose()
}
})
return (
<box border padding={2}>
{children}
</box>
)
}Vim-style Modes
function Editor() {
const [mode, setMode] = useState<"normal" | "insert">("normal")
const [content, setContent] = useState("")
useKeyboard((key) => {
if (mode === "normal") {
switch (key.name) {
case "i":
setMode("insert")
break
case "escape":
// Already in normal mode
break
case "j":
moveCursorDown()
break
case "k":
moveCursorUp()
break
}
} else if (mode === "insert") {
if (key.name === "escape") {
setMode("normal")
}
// Input component handles text in insert mode
}
})
return (
<box flexDirection="column">
<text>Mode: {mode}</text>
<textarea
value={content}
onChange={setContent}
focused={mode === "insert"}
/>
</box>
)
}Game Controls
function Game() {
const [pressed, setPressed] = useState(new Set<string>())
useKeyboard(
(key) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (key.eventType === "release") {
newKeys.delete(key.name)
} else {
newKeys.add(key.name)
}
return newKeys
})
},
{ release: true }
)
// Game logic uses pressed set
useEffect(() => {
if (pressed.has("up") || pressed.has("w")) {
moveUp()
}
if (pressed.has("down") || pressed.has("s")) {
moveDown()
}
}, [pressed])
return <text>WASD or arrows to move</text>
}Keyboard Shortcuts Help
function ShortcutsHelp() {
const shortcuts = [
{ keys: "Ctrl+S", action: "Save" },
{ keys: "Ctrl+Q", action: "Quit" },
{ keys: "Ctrl+F", action: "Find" },
{ keys: "Tab", action: "Next field" },
{ keys: "Shift+Tab", action: "Previous field" },
]
return (
<box border title="Keyboard Shortcuts" padding={1}>
{shortcuts.map(({ keys, action }) => (
<box key={keys} flexDirection="row">
<text width={15} fg="#00FFFF">{keys}</text>
<text>{action}</text>
</box>
))}
</box>
)
}Paste Events
Handle pasted content. Paste events deliver raw bytes, not decoded text.
PasteEvent Object
import { type PasteEvent } from "@opentui/core"
interface PasteEvent {
type: "paste" // Always "paste"
bytes: Uint8Array // Raw pasted bytes
metadata?: PasteMetadata // Optional metadata
preventDefault(): void // Prevent default paste handling
defaultPrevented: boolean // Whether preventDefault was called
}
interface PasteMetadata {
mimeType?: string // MIME type if available
kind?: PasteKind // Paste kind
}Decoding Paste Bytes
Use decodePasteBytes to convert raw bytes to a string, and stripAnsiSequences to remove ANSI escape codes:
import { decodePasteBytes, stripAnsiSequences } from "@opentui/core"
const text = decodePasteBytes(event.bytes) // Decode UTF-8
const clean = stripAnsiSequences(decodePasteBytes(event.bytes)) // Decode + strip ANSICore
import { type PasteEvent, decodePasteBytes } from "@opentui/core"
renderer.keyInput.on("paste", (event: PasteEvent) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})Solid
Solid provides a dedicated usePaste hook:
import { usePaste } from "@opentui/solid"
import { decodePasteBytes } from "@opentui/core"
function App() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
return <text>Paste something</text>
}Note:usePasteis Solid-only. React does not have this hook - handle paste via the Core event emitter or input component'sonChange.
Text Selection
Text selection is renderer-managed. The renderer owns a single Selection object, walks the renderable tree to find selectable children, and emits a "selection" event when the user finishes selecting (mouse-up). The Selection object aggregates text from all selected renderables automatically.
Making Renderables Selectable
A renderable must have selectable set to true to participate in selection. Text-based renderables (TextRenderable, TextareaRenderable, ASCIIFontRenderable, TextTableRenderable) support this:
// React / Solid
<text selectable>This text can be selected</text>
// Core
const text = new TextRenderable(renderer, {
id: "label",
content: "This text can be selected",
selectable: true,
})Copy-on-Selection (Core)
Listen to the renderer's "selection" event. The Selection object's getSelectedText() returns text aggregated from all selected renderables in reading order:
import type { Selection } from "@opentui/core"
renderer.on("selection", (selection: Selection) => {
const text = selection.getSelectedText()
if (text) {
renderer.copyToClipboardOSC52(text)
}
})Important: Callselection.getSelectedText()on theSelectionobject from the event -- notrenderer.root.getSelectedText(). Individual renderables only return their own selected text. TheSelectionobject aggregates across the tree.
Copy-on-Selection (Solid)
import { useSelectionHandler } from "@opentui/solid"
function App() {
useSelectionHandler((selection) => {
const text = selection.getSelectedText()
if (text) {
renderer.copyToClipboardOSC52(text)
}
})
return <text selectable>Select this text</text>
}Note:useSelectionHandleris Solid-only. React does not have this hook -- use the Corerenderer.on("selection", ...)event.
Selection Object
The Selection object passed to the event callback:
selection.getSelectedText() // Aggregated text from all selected renderables
selection.bounds // { startX, startY, endX, endY } bounding rect
selection.selectedRenderables // Renderable[] with active selections
selection.isActive // Whether selection is still activeIndividual renderables also expose:
renderable.hasSelection() // Does this renderable have selected text?
renderable.getSelectedText() // Selected text in this renderable onlyHow Selection Traversal Works
When the user drags to select, the renderer: 1. Identifies the selection container (common ancestor of start and end points) 2. Walks all selectable descendants within the selection bounds 3. Calls onSelectionChanged(selection) on each, which computes local selection 4. Tracks which renderables have active selections in selection.selectedRenderables
This means selection works across multiple renderables. Dragging across two <text selectable> elements selects text in both, and selection.getSelectedText() joins them with newlines.
Clipboard API (OSC 52)
Copy text to the system clipboard using OSC 52 escape sequences. Works over SSH and in most modern terminal emulators.
// Copy to clipboard
const success = renderer.copyToClipboardOSC52("text to copy")
// Check if OSC 52 is supported
if (renderer.isOsc52Supported()) {
renderer.copyToClipboardOSC52("Hello!")
}
// Clear clipboard
renderer.clearClipboardOSC52()
// Target specific clipboard (X11)
import { ClipboardTarget } from "@opentui/core"
renderer.copyToClipboardOSC52("text", ClipboardTarget.Primary) // X11 primary
renderer.copyToClipboardOSC52("text", ClipboardTarget.Clipboard) // System clipboard (default)Focus and Input Components
Input components (<input>, <textarea>, <select>) capture keyboard events when focused:
<input focused /> // Receives keyboard input
// Global useKeyboard still fires, but input consumes charactersTo prevent conflicts, check if an input is focused before handling global shortcuts:
function App() {
const renderer = useRenderer()
const [inputFocused, setInputFocused] = useState(false)
useKeyboard((key) => {
if (inputFocused) return // Let input handle it
// Global shortcuts
if (key.name === "escape") {
renderer.destroy()
}
})
return (
<input
focused={inputFocused}
onFocus={() => setInputFocused(true)}
onBlur={() => setInputFocused(false)}
/>
)
}Gotchas
Terminal Limitations
Some key combinations are captured by the terminal or OS:
Ctrl+Coften sends SIGINT (useexitOnCtrlC: falseto handle)Ctrl+Zsuspends the process- Some function keys may be intercepted
SSH and Remote Sessions
Key detection may vary over SSH. Test on target environments.
Multiple Handlers
Multiple useKeyboard calls all receive events. Coordinate handlers to prevent conflicts.
See Also
- React API -
useKeyboardhook reference - Solid API -
useKeyboardhook reference - Input Components - Focus management with input, textarea, select
- Testing - Simulating key presses in tests
Layout Patterns
Common layout recipes for terminal user interfaces.
Full-Screen App
Fill the entire terminal:
function App() {
return (
<box width="100%" height="100%">
{/* Content fills terminal */}
</box>
)
}Header/Content/Footer
Classic app layout:
function AppLayout() {
return (
<box flexDirection="column" width="100%" height="100%">
{/* Header - fixed height */}
<box height={3} borderStyle="single" borderBottom>
<text>Header</text>
</box>
{/* Content - fills remaining space */}
<box flexGrow={1}>
<text>Main Content</text>
</box>
{/* Footer - fixed height */}
<box height={1}>
<text>Status: Ready</text>
</box>
</box>
)
}Sidebar Layout
function SidebarLayout() {
return (
<box flexDirection="row" width="100%" height="100%">
{/* Sidebar - fixed width */}
<box width={25} borderStyle="single" borderRight>
<text>Sidebar</text>
</box>
{/* Main - fills remaining space */}
<box flexGrow={1}>
<text>Main Content</text>
</box>
</box>
)
}Resizable Sidebar
Responsive based on terminal width:
function ResponsiveSidebar() {
const dims = useTerminalDimensions() // React: useTerminalDimensions()
const showSidebar = dims.width > 60
const sidebarWidth = Math.min(30, Math.floor(dims.width * 0.3))
return (
<box flexDirection="row" width="100%" height="100%">
{showSidebar && (
<box width={sidebarWidth} border>
<text>Sidebar</text>
</box>
)}
<box flexGrow={1}>
<text>Main</text>
</box>
</box>
)
}Centered Content
Horizontally Centered
<box width="100%" justifyContent="center">
<box width={40}>
<text>Centered horizontally</text>
</box>
</box>Vertically Centered
<box height="100%" alignItems="center">
<text>Centered vertically</text>
</box>Both Axes
<box
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
>
<box width={40} height={10} border>
<text>Centered both ways</text>
</box>
</box>Modal/Dialog
Centered overlay:
function Modal({ children, visible }) {
if (!visible) return null
return (
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
backgroundColor="rgba(0,0,0,0.5)"
>
<box
width={50}
height={15}
border
borderStyle="double"
backgroundColor="#1a1a2e"
padding={2}
>
{children}
</box>
</box>
)
}Grid Layout
Using flexWrap:
function Grid({ items, columns = 3 }) {
const itemWidth = `${Math.floor(100 / columns)}%`
return (
<box flexDirection="row" flexWrap="wrap" width="100%">
{items.map((item, i) => (
<box key={i} width={itemWidth} padding={1}>
<text>{item}</text>
</box>
))}
</box>
)
}Split Panels
Horizontal Split
function HorizontalSplit({ ratio = 0.5 }) {
return (
<box flexDirection="row" width="100%" height="100%">
<box width={`${ratio * 100}%`} border>
<text>Left Panel</text>
</box>
<box flexGrow={1} border>
<text>Right Panel</text>
</box>
</box>
)
}Vertical Split
function VerticalSplit({ ratio = 0.5 }) {
return (
<box flexDirection="column" width="100%" height="100%">
<box height={`${ratio * 100}%`} border>
<text>Top Panel</text>
</box>
<box flexGrow={1} border>
<text>Bottom Panel</text>
</box>
</box>
)
}Form Layout
Label + Input pairs:
function FormField({ label, children }) {
return (
<box flexDirection="row" marginBottom={1}>
<box width={15}>
<text>{label}:</text>
</box>
<box flexGrow={1}>
{children}
</box>
</box>
)
}
function LoginForm() {
return (
<box flexDirection="column" padding={2} border width={50}>
<FormField label="Username">
<input placeholder="Enter username" />
</FormField>
<FormField label="Password">
<input placeholder="Enter password" />
</FormField>
<box marginTop={2} justifyContent="flex-end">
<box border padding={1}>
<text>Login</text>
</box>
</box>
</box>
)
}Navigation Tabs
function TabBar({ tabs, activeIndex, onSelect }) {
return (
<box flexDirection="row" borderBottom>
{tabs.map((tab, i) => (
<box
key={i}
padding={1}
backgroundColor={i === activeIndex ? "#333" : "transparent"}
onMouseDown={() => onSelect(i)}
>
<text fg={i === activeIndex ? "#fff" : "#888"}>
{tab}
</text>
</box>
))}
</box>
)
}Sticky Footer
Footer always at bottom:
function StickyFooterLayout() {
return (
<box flexDirection="column" width="100%" height="100%">
{/* Content area */}
<box flexGrow={1} flexDirection="column">
{/* Your content here */}
<text>Content that might be short</text>
</box>
{/* Footer pushed to bottom */}
<box height={1}>
<text fg="#888">Press ? for help | q to quit</text>
</box>
</box>
)
}Absolute Positioning Overlay
Tooltip or popup:
function Tooltip({ x, y, children }) {
return (
<box
position="absolute"
left={x}
top={y}
border
backgroundColor="#333"
padding={1}
zIndex={100}
>
{children}
</box>
)
}Responsive Breakpoints
Different layouts based on terminal size:
function ResponsiveApp() {
const { width, height } = useTerminalDimensions()
// Define breakpoints
const isSmall = width < 60
const isMedium = width >= 60 && width < 100
const isLarge = width >= 100
if (isSmall) {
// Mobile-like: stacked layout
return (
<box flexDirection="column">
<Navigation />
<Content />
</box>
)
}
if (isMedium) {
// Tablet-like: sidebar + content
return (
<box flexDirection="row">
<box width={20}><Navigation /></box>
<box flexGrow={1}><Content /></box>
</box>
)
}
// Large: full layout
return (
<box flexDirection="row">
<box width={25}><Navigation /></box>
<box flexGrow={1}><Content /></box>
<box width={30}><Sidebar /></box>
</box>
)
}Equal Height Columns
function EqualColumns() {
return (
<box flexDirection="row" alignItems="stretch" height={20}>
<box flexGrow={1} border>
<text>Short content</text>
</box>
<box flexGrow={1} border>
<text>
Longer content that
spans multiple lines
and takes up space
</text>
</box>
<box flexGrow={1} border>
<text>Medium content</text>
</box>
</box>
)
}Spacing Utilities
Consistent spacing patterns:
// Spacer component
function Spacer({ size = 1 }) {
return <box height={size} width={size} />
}
// Divider component
function Divider() {
return <box height={1} width="100%" backgroundColor="#333" />
}
// Usage
<box flexDirection="column">
<text>Section 1</text>
<Spacer size={2} />
<Divider />
<Spacer size={2} />
<text>Section 2</text>
</box>Axis Shorthand Props
Use paddingX/paddingY and marginX/marginY for horizontal/vertical spacing:
// Horizontal padding (left + right)
<box paddingX={4}>
<text>4 chars padding left and right</text>
</box>
// Vertical padding (top + bottom)
<box paddingY={2}>
<text>2 lines padding top and bottom</text>
</box>
// Horizontal margin for centering-like effect
<box marginX={10}>
<text>Indented content</text>
</box>
// Combined for card-like spacing
<box paddingX={3} paddingY={1} marginY={1} border>
<text>Nicely spaced card</text>
</box>These are shorthand for:
paddingX={n}=paddingLeft={n}+paddingRight={n}paddingY={n}=paddingTop={n}+paddingBottom={n}marginX={n}=marginLeft={n}+marginRight={n}marginY={n}=marginTop={n}+marginBottom={n}
OpenTUI Layout System
OpenTUI uses the Yoga layout engine, providing CSS Flexbox-like capabilities for positioning and sizing components in the terminal.
Overview
Key concepts:
- Flexbox model: Familiar CSS Flexbox properties
- Yoga engine: Facebook's cross-platform layout engine
- Terminal units: Dimensions are in character cells (columns x rows)
- Percentage support: Relative sizing based on parent
Flex Container Properties
flexDirection
Controls the main axis direction:
// Row (default) - children flow horizontally
<box flexDirection="row">
<text>1</text>
<text>2</text>
<text>3</text>
</box>
// Output: 1 2 3
// Column - children flow vertically
<box flexDirection="column">
<text>1</text>
<text>2</text>
<text>3</text>
</box>
// Output:
// 1
// 2
// 3
// Reverse variants
<box flexDirection="row-reverse">...</box> // 3 2 1
<box flexDirection="column-reverse">...</box> // Bottom to topjustifyContent
Aligns children along the main axis:
<box flexDirection="row" width={40} justifyContent="flex-start">
{/* Children at start (left for row) */}
</box>
<box flexDirection="row" width={40} justifyContent="flex-end">
{/* Children at end (right for row) */}
</box>
<box flexDirection="row" width={40} justifyContent="center">
{/* Children centered */}
</box>
<box flexDirection="row" width={40} justifyContent="space-between">
{/* First at start, last at end, rest evenly distributed */}
</box>
<box flexDirection="row" width={40} justifyContent="space-around">
{/* Equal space around each child */}
</box>
<box flexDirection="row" width={40} justifyContent="space-evenly">
{/* Equal space between all children and edges */}
</box>alignItems
Aligns children along the cross axis:
<box flexDirection="row" height={10} alignItems="flex-start">
{/* Children at top */}
</box>
<box flexDirection="row" height={10} alignItems="flex-end">
{/* Children at bottom */}
</box>
<box flexDirection="row" height={10} alignItems="center">
{/* Children vertically centered */}
</box>
<box flexDirection="row" height={10} alignItems="stretch">
{/* Children stretch to fill height */}
</box>
<box flexDirection="row" height={10} alignItems="baseline">
{/* Children aligned by text baseline */}
</box>flexWrap
Controls whether children wrap to new lines:
<box flexDirection="row" flexWrap="nowrap" width={20}>
{/* Children overflow (default) */}
</box>
<box flexDirection="row" flexWrap="wrap" width={20}>
{/* Children wrap to next row */}
</box>
<box flexDirection="row" flexWrap="wrap-reverse" width={20}>
{/* Children wrap upward */}
</box>gap
Space between children:
<box flexDirection="row" gap={2}>
<text>A</text>
<text>B</text>
<text>C</text>
</box>
// Output: A B C (2 spaces between)Flex Item Properties
flexGrow
How much a child should grow relative to siblings:
<box flexDirection="row" width={30}>
<box flexGrow={1}><text>1</text></box>
<box flexGrow={2}><text>2</text></box>
<box flexGrow={1}><text>1</text></box>
</box>
// Widths: 7.5 | 15 | 7.5 (1:2:1 ratio)flexShrink
How much a child should shrink when space is limited:
<box flexDirection="row" width={20}>
<box width={15} flexShrink={1}><text>Shrinks</text></box>
<box width={15} flexShrink={0}><text>Fixed</text></box>
</box>flexBasis
Initial size before growing/shrinking:
<box flexDirection="row">
<box flexBasis={20} flexGrow={1}>Starts at 20, can grow</box>
<box flexBasis="50%">Half of parent</box>
</box>alignSelf
Override parent's alignItems for this child:
<box flexDirection="row" height={10} alignItems="center">
<text>Centered</text>
<text alignSelf="flex-start">Top</text>
<text alignSelf="flex-end">Bottom</text>
</box>Dimensions
Fixed Dimensions
<box width={40} height={10}>
{/* Exactly 40 columns by 10 rows */}
</box>Percentage Dimensions
Parent must have explicit size:
<box width="100%" height="100%">
<box width="50%" height="50%">
{/* Half of parent */}
</box>
</box>Min/Max Constraints
<box
minWidth={20}
maxWidth={60}
minHeight={5}
maxHeight={20}
>
{/* Constrained sizing */}
</box>Spacing
Padding (inside)
// All sides
<box padding={2}>Content</box>
// Individual sides
<box
paddingTop={1}
paddingRight={2}
paddingBottom={1}
paddingLeft={2}
>
Content
</box>Margin (outside)
// All sides
<box margin={1}>Content</box>
// Individual sides
<box
marginTop={1}
marginRight={2}
marginBottom={1}
marginLeft={2}
>
Content
</box>Positioning
Relative (default)
Element flows in normal document order:
<box position="relative">
{/* Normal flow */}
</box>Absolute
Element positioned relative to nearest positioned ancestor:
<box position="relative" width="100%" height="100%">
<box
position="absolute"
left={10}
top={5}
width={20}
height={5}
>
Positioned at (10, 5)
</box>
</box>Position Properties
<box
position="absolute"
left={10} // From left edge
top={5} // From top edge
right={10} // From right edge
bottom={5} // From bottom edge
>
Content
</box>Display
Visibility Control
// Visible (default)
<box display="flex">Visible</box>
// Hidden (removed from layout)
<box display="none">Hidden</box>Overflow
<box overflow="visible">
{/* Content can extend beyond bounds (default) */}
</box>
<box overflow="hidden">
{/* Content clipped at bounds */}
</box>
<box overflow="scroll">
{/* Scrollable when content exceeds bounds */}
</box>Z-Index
Control stacking order for overlapping elements:
<box position="relative">
<box position="absolute" zIndex={1}>Behind</box>
<box position="absolute" zIndex={2}>In front</box>
</box>See Also
- Layout Patterns - Common layout recipes
- Components/Containers - Box and ScrollBox details
Related skills
Forks & variants (2)
Opentui has 2 known copies in the catalog totaling 78 installs. They canonicalize to this original listing.
FAQ
Which frameworks does the opentui skill cover?
Three: the OpenTUI core imperative API, the React reconciler, and the Solid reconciler, with decision trees to pick between them.
What runtime does OpenTUI need?
OpenTUI runs on Bun and uses Zig for native builds; new projects are scaffolded with create-tui.
Is Opentui safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.