references/animation/REFERENCE.md
# 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
```tsx
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
```tsx
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
```typescript
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
```typescript
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
```typescript
// 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 duration
```
## Animation Properties
```typescript
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
```tsx
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
```tsx
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
```tsx
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
```tsx
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
```tsx
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:
```typescript
onUpdate: (anim) => {
setX(Math.round(anim.targets[0].x))
}
```
### Clean Up Timelines
Hooks automatically clean up, but for core:
```typescript
// 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:
```tsx
useEffect(() => {
const interval = setInterval(...)
return () => clearInterval(interval)
}, [])
```
## See Also
- [React API](../react/api.md) - `useTimeline` hook reference
- [Solid API](../solid/api.md) - `useTimeline` hook reference
- [Core API](../core/api.md) - `AnimationEngine` and `Timeline` classes
- [Layout Patterns](../layout/patterns.md) - Animated positioning and transitions
references/components/code-diff.md
# Code & Diff Components
Components for displaying code with syntax highlighting and diffs in OpenTUI.
## Code Component
Display syntax-highlighted code blocks.
### Basic Usage
```tsx
// 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`, `javascript`
- `python`
- `rust`
- `go`
- `json`
- `html`, `css`
- `markdown`
- `bash`, `shell`
### Styling
```tsx
<code
code={sourceCode}
language="typescript"
backgroundColor="#1a1a2e"
showLineNumbers
/>
```
### onHighlight Callback
Intercept and modify syntax highlights before rendering:
```tsx
// 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 `undefined` to 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:
```tsx
// 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:
```typescript
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
```typescript
// 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"` | `"none"` | Text wrapping in cells |
| `columnWidthMode` | `"content" \| "fill"` | `"content"` | Column sizing strategy |
| `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` | - | Border color |
| `selectable` | `boolean` | `false` | Allow text selection |
| `columnFitter` | `"proportional" \| "balanced"` | `"proportional"` | Column width distribution |
### Cell Content Format
Each cell is an array of styled text chunks:
```typescript
type TextTableCellContent = { text: string; fg?: RGBA; bg?: RGBA }[]
type TextTableContent = TextTableCellContent[][] // rows -> cells -> chunks
```
### Selection
```typescript
table.getSelectedText() // Get selected text
table.hasSelection() // Check if text is selected
```
Columnar 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
```tsx
// 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
```tsx
// 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:
```tsx
// 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:
```tsx
// 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 indicator
- `warning` - Yellow indicator
- `info` - Blue indicator
- `hint` - Gray indicator
### Diff Highlighting
Show added/removed lines:
```tsx
<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
```tsx
// 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
```tsx
// 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:
```tsx
// 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 = false
```
### Options
```tsx
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified"
showLineNumbers
context={3} // Lines of context around changes
/>
```
### Styling
```tsx
<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:
```typescript
// 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:
```typescript
lineNumberView.highlightLines(5, 10, "#2d4f2d")
lineNumberView.clearHighlightLines(5, 10)
```
### Hunk Navigation (Core)
`getHunkRowOffsets()` returns the visual row offset (0-based, accounting for
wrapping and concealed lines) where each diff hunk begins, in hunk order. Works
in both `"unified"` and `"split"` views.
```typescript
const offsets = diffView.getHunkRowOffsets() // number[]
// Jump to the next hunk below the current scroll position
const next = offsets.find((row) => row > diffView.scrollTop)
if (next !== undefined) diffView.scrollTop = next
// Jump to the previous hunk
const prev = [...offsets].reverse().find((row) => row < diffView.scrollTop)
if (prev !== undefined) diffView.scrollTop = prev
```
The result is cached and invalidated when the view rebuilds, `wrapMode` changes,
or line info changes.
## Markdown Component
Render markdown content with syntax highlighting for code blocks.
### Basic Usage
```tsx
// 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
```tsx
<markdown
content={markdownText}
syntaxStyle={syntaxStyle} // Required
treeSitterClient={client} // Optional: custom tree-sitter client
conceal={true} // Hide markdown syntax characters (default true)
concealCode={false} // Conceal code fences too (default false)
streaming={true} // Enable streaming mode for incremental updates
internalBlockMode="coalesced" // "coalesced" (default) | "top-level"
tableOptions={{ // Customize markdown table rendering
style: "columns", // "grid" | "columns"
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
}}
/>
```
**`internalBlockMode`**: `"top-level"` keeps each top-level block (heading,
paragraph, list, table, fenced code) as its own child renderable — required for
row-by-row committing of streamed output and for stable-block tracking. The
default `"coalesced"` folds siblings together (historical layout). When set to
`"top-level"`, `markdown._stableBlockCount` reports how many head-of-stream
blocks are currently stable.
### Custom Node Rendering
```tsx
// 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
},
})
```
### Custom Code Block Renderers
`createMarkdownCodeBlockRenderer` builds a `renderNode` that only overrides
fenced code blocks, keyed by the **normalized** fence info string (e.g. `tsx` →
`typescriptreact`; custom names like `taskflow` match directly):
```typescript
import { createMarkdownCodeBlockRenderer, MarkdownRenderable } from "@opentui/core"
const renderNode = createMarkdownCodeBlockRenderer({
mermaid: (token, ctx) => renderMermaidDiagram(ctx, token.text),
taskflow: (token, ctx) => renderTaskflow(ctx, token.text),
})
const md = new MarkdownRenderable(renderer, {
id: "markdown",
content,
syntaxStyle,
renderNode, // Only the matched fences are replaced; others render normally
})
```
Each renderer receives `(token: Tokens.Code, context: RenderNodeContext)` and
returns a `Renderable`, or `undefined`/`null` to fall back to default rendering.
### Streaming Mode
For real-time content like LLM output:
```tsx
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
/>
```
In Core, set `markdown.streaming = true` while appending chunks (assign or `+=`
to `markdown.content`; each change reparses incrementally). Set
`markdown.streaming = false` when the stream completes to finalize trailing
block parsing. Use `internalBlockMode: "top-level"` if you need per-block commit
boundaries (`markdown._stableBlockCount`).
## Use Cases
### Code Editor
```tsx
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
```tsx
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
```tsx
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
```tsx
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
```tsx
// React
<line-number />
// Solid
<line_number />
```
### Language Required for Highlighting
```tsx
// 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
```tsx
<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
references/components/containers.md
# Container Components
Components for grouping and organizing content in OpenTUI.
## Box Component
The primary container component with borders, backgrounds, and layout capabilities.
### Basic Usage
```tsx
// React/Solid
<box>
<text>Content inside box</text>
</box>
// Core
const box = new BoxRenderable(renderer, {
id: "container",
})
box.add(child)
```
### Borders
```tsx
<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
```tsx
<box
border
title="Settings"
titleColor="#FFCC00" // Title text color (defaults to border color)
titleAlignment="center" // left | center | right
bottomTitle="Press q to quit" // Title text in the bottom border
bottomTitleAlignment="right" // left | center | right
>
Panel content
</box>
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | – | Title text in the top border |
| `titleColor` | `string \| RGBA` | border color | Color of the title text |
| `titleAlignment` | `"left" \| "center" \| "right"` | `"left"` | Top title position |
| `bottomTitle` | `string` | – | Title text in the bottom border |
| `bottomTitleAlignment` | `"left" \| "center" \| "right"` | `"left"` | Bottom title position |
### Background
```tsx
<box backgroundColor="#1a1a2e">
Dark background
</box>
<box backgroundColor="transparent">
No background
</box>
```
### Layout
Boxes are flex containers by default:
```tsx
<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
```tsx
<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
```tsx
<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
```tsx
<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:
```tsx
// 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
```tsx
// 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
```tsx
<scrollbox focused height={20}>
{/* Use arrow keys to scroll */}
</scrollbox>
```
### Scrollbar Styling
```tsx
// 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)
```typescript
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 ID
```
`scrollChildIntoView(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.
## ScrollBar Component
A **standalone** scrollbar (separate from `scrollbox`) with optional arrows, a
draggable thumb, and keyboard navigation. `ScrollBarRenderable` is exported from
`@opentui/core`. Connect it to any content by wiring its size/position props.
```typescript
import { ScrollBarRenderable } from "@opentui/core"
const scrollbar = new ScrollBarRenderable(renderer, {
id: "scrollbar",
orientation: "vertical", // "vertical" | "horizontal"
showArrows: false,
scrollSize: 0, // Total content size
viewportSize: 0, // Visible size
scrollPosition: 0, // Current position
onChange: (position) => {
// Sync your content to the new scroll position
},
})
// Drive it from your content dimensions, then focus for keyboard control
scrollbar.scrollSize = content.length
scrollbar.viewportSize = visibleRows
scrollbar.scrollPosition = 0
scrollbar.focus()
```
| Prop | Type | Default |
|------|------|---------|
| `orientation` | `"vertical" \| "horizontal"` | – |
| `showArrows` | `boolean` | `false` |
| `arrowOptions` | `ArrowOptions` | – |
| `trackOptions` | `Partial<SliderOptions>` | – |
| `scrollSize` | `number` | `0` |
| `viewportSize` | `number` | `0` |
| `scrollPosition` | `number` | `0` |
| `scrollStep` | `number` | – |
| `onChange` | `(position: number) => void` | – |
When focused: arrows / `hjkl`, `PageUp`/`PageDown`, `Home`/`End`.
> Most apps should use `scrollbox` (which embeds a scrollbar). Reach for
> `ScrollBarRenderable` only when you need a scrollbar decoupled from a
> `scrollbox` viewport.
## Composition Patterns
### Card Component
```tsx
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
```tsx
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
```tsx
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
```tsx
<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
```tsx
// 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
```tsx
// WRONG
<box>
<box flexGrow={1}>Won't grow</box>
</box>
// CORRECT
<box height="100%">
<box flexGrow={1}>Will grow</box>
</box>
```
### ScrollBox Needs Height
```tsx
// WRONG - no height constraint
<scrollbox>
{items}
</scrollbox>
// CORRECT
<scrollbox height={20}>
{items}
</scrollbox>
```
### Borders Add to Size
Borders take up space inside the box:
```tsx
<box width={10} border>
{/* Inner content area is 8 chars (10 - 2 for borders) */}
</box>
```
references/components/inputs.md
# Input Components
Components for user input in OpenTUI.
## Input Component
Single-line text input field.
### Basic Usage
```tsx
// 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
```tsx
<input
width={30}
maxLength={100} // Maximum characters
minLength={3} // Minimum length for submit() to succeed
backgroundColor="#1a1a1a"
textColor="#FFFFFF"
cursorColor="#00FF00"
focusedBackgroundColor="#2a2a2a"
placeholderColor="#666666"
/>
```
> **`minLength`** (default `0`) does not block typing — it only makes `submit()`
> (Enter) fail silently while the value is shorter than `minLength`. Setting
> `minLength > maxLength` throws.
### Events
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
<textarea
showLineNumbers // Display line numbers
wrapText // Wrap long lines
readOnly // Disable editing
tabSize={2} // Tab character width
/>
```
### Syntax Highlighting
```tsx
<textarea
language="typescript"
value={code}
onChange={setCode}
/>
```
## Select Component
List selection for choosing from options.
### Basic Usage
```tsx
// 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
```typescript
interface SelectOption {
name: string // Display text
description?: string // Optional description shown below
value?: any // Associated value
}
```
### Styling
```tsx
<select
height={8} // Visible height
selectedIndex={0} // Initially selected
showScrollIndicator // Show scroll arrows
showSelectionIndicator={true} // Show "▶ " marker + gutter (default true)
selectedBackgroundColor="#333"
selectedTextColor="#fff"
highlightBackgroundColor="#444"
/>
```
> **`showSelectionIndicator`** (default `true`): when `false`, the `▶ ` marker is
> hidden AND its 2-column gutter is reclaimed, so option text shifts left by 2.
> In Core, toggle at runtime with `select.showSelectionIndicator = false`.
### Navigation
Default keybindings:
- `Up` / `k` - Move up
- `Down` / `j` - Move down
- `Enter` - 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 |
```tsx
// 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
```tsx
// 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:
```tsx
<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
```tsx
// 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 tab
- `Right` / `]` - Next tab
- `Enter` - Select tab
## Slider Component
A draggable value slider (`SliderRenderable`, exported from `@opentui/core`).
```typescript
// Core
import { SliderRenderable, createCliRenderer } from "@opentui/core"
const slider = new SliderRenderable(renderer, {
id: "volume",
orientation: "horizontal", // "horizontal" | "vertical"
width: 30,
height: 1,
min: 0,
max: 100,
value: 25,
onChange: (value) => console.log("Value:", value),
})
renderer.root.add(slider)
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `orientation` | `"vertical" \| "horizontal"` | – | Required direction |
| `value` | `number` | `min` | Current value |
| `min` | `number` | `0` | Minimum |
| `max` | `number` | `100` | Maximum |
| `viewPortSize` | `number` | range × 0.1 | Thumb size relative to content |
| `backgroundColor` | `string \| RGBA` | – | Track color |
| `foregroundColor` | `string \| RGBA` | – | Thumb color |
| `onChange` | `(value: number) => void` | – | Fired on change |
Vertical example: `{ orientation: "vertical", width: 2, height: 10, min: 0, max: 1, value: 0.5 }`.
## Focus Management
### Single Focused Input
```tsx
function SingleInput() {
return <input placeholder="I'm focused" focused />
}
```
### Multiple Inputs with Focus State
```tsx
// 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)
```typescript
input.focus() // Give focus
input.blur() // Remove focus
input.isFocused() // Check focus state
```
## Form Patterns
### Login Form
```tsx
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
```tsx
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:
```tsx
// 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:
```tsx
// WRONG
<select options={["a", "b", "c"]} />
// CORRECT
<select options={[
{ name: "A", description: "Option A" },
{ name: "B", description: "Option B" },
]} />
```
### Solid Uses Underscores
```tsx
// React
<tab-select />
// Solid
<tab_select />
```
### Value vs onInput (Solid)
Solid uses `onInput` instead of `onChange`:
```tsx
// React
<input value={value} onChange={setValue} />
// Solid
<input value={value()} onInput={setValue} />
```
references/components/REFERENCE.md
# 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, qr-code | [text-display.md](./text-display.md) |
| Containers | box, scrollbox, scrollbar, borders | [containers.md](./containers.md) |
| Inputs | input, textarea, select, tab-select, slider | [inputs.md](./inputs.md) |
| Code & Diff | code, line-number, diff, markdown, text-table | [code-diff.md](./code-diff.md) |
## Component Chooser
```
Need a component?
├─ Styled text, ASCII art, or QR code -> text-display.md
├─ Containers, borders, scrolling, scrollbar -> containers.md
├─ Forms, input controls, sliders -> inputs.md
└─ Code blocks, diffs, line numbers, markdown -> code-diff.md
```
## Component 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) |
| Slider | `SliderRenderable` | N/A (Core only) | N/A (Core only) |
| ScrollBar | `ScrollBarRenderable` | N/A (Core only) | N/A (Core only) |
| FrameBuffer | `FrameBufferRenderable` | N/A (Core + construct) | N/A (Core + construct) |
| QR Code | `QRCodeRenderable` | `<qr-code>` * | `<qr_code>` * |
**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. `SliderRenderable` and `ScrollBarRenderable` do not yet expose a construct/JSX API — use the Core class. `FrameBufferRenderable` is Core-only with a `FrameBuffer({...})` construct wrapper (no reconciler JSX element). `* QRCodeRenderable` ships in the separate `@opentui/qrcode` package and requires `registerQRCode()` before the JSX element is available (see [text-display.md](./text-display.md)).
## Common Properties
All components share these layout properties (see [Layout](../layout/REFERENCE.md)):
```tsx
// 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"
zIndex
```
## Quick Examples
### Core (Imperative)
```typescript
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
```tsx
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
```tsx
import { render } from "@opentui/solid"
function App() {
return (
<box border padding={2}>
<text fg="#00FF00">Hello!</text>
</box>
)
}
render(() => <App />)
```
## See Also
- [Core API](../core/api.md) - Imperative component classes
- [React API](../react/api.md) - React component props
- [Solid API](../solid/api.md) - Solid component props
- [Layout](../layout/REFERENCE.md) - Layout system details
references/components/text-display.md
# Text & Display Components
Components for displaying text content in OpenTUI.
## Text Component
The primary component for displaying styled text.
### Basic Usage
```tsx
// 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:
```tsx
<text fg="#FFFFFF" bg="#000000">
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
</text>
```
> **Important**: Do NOT use `bold`, `italic`, `underline`, `dim`, `strikethrough` as props on `<text>` — they don't work. Always use nested tags like `<strong>`, `<em>`, `<u>`, or `<span>` with styling.
### Styling (Core) - Text Attributes
```typescript
import { TextRenderable, TextAttributes } from "@opentui/core"
const text = new TextRenderable(renderer, {
content: "Styled",
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
})
```
**Available attributes:**
- `TextAttributes.BOLD`
- `TextAttributes.DIM`
- `TextAttributes.ITALIC`
- `TextAttributes.UNDERLINE`
- `TextAttributes.BLINK`
- `TextAttributes.INVERSE`
- `TextAttributes.HIDDEN`
- `TextAttributes.STRIKETHROUGH`
### Text Selection
```tsx
<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:
```tsx
<text>
Normal text with <span fg="red">red text</span> inline
</text>
```
### Bold/Strong
```tsx
<text>
<strong>Bold text</strong>
<b>Also bold</b>
</text>
```
### Italic/Emphasis
```tsx
<text>
<em>Italic text</em>
<i>Also italic</i>
</text>
```
### Underline
```tsx
<text>
<u>Underlined text</u>
</text>
```
### Line Break
```tsx
<text>
Line one
<br />
Line two
</text>
```
### Link
```tsx
<text>
Visit <a href="https://example.com">our website</a>
</text>
```
### Combined Modifiers
```tsx
<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:
```typescript
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
```tsx
// 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
```tsx
// 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
█▀▀█ █▀▀█ █▀▀ █▀▀▄
█ █ █▀▀▀ █▀▀ █ █
▀▀▀▀ ▀ ▀▀▀ ▀ ▀
```
## QR Code Component
Render a QR code in the terminal. Ships as a **separate package**,
`@opentui/qrcode` (not part of `@opentui/core`).
```bash
bun add @opentui/qrcode
```
```typescript
// Core
import { createCliRenderer } from "@opentui/core"
import { QRCodeRenderable } from "@opentui/qrcode"
const renderer = await createCliRenderer()
const qr = new QRCodeRenderable(renderer, {
id: "docs-link",
content: "https://opentui.com/docs/getting-started",
quietZone: 4,
scale: 2,
})
renderer.root.add(qr)
```
React and Solid require explicit element registration (the elements are not
built in):
```tsx
// React — element <qr-code>
import { registerQRCode } from "@opentui/qrcode/react"
registerQRCode()
<qr-code content="https://opentui.com" quietZone={4} scale={2} />
// Solid — element <qr_code> (underscore)
import { registerQRCode } from "@opentui/qrcode/solid"
registerQRCode()
<qr_code content="https://opentui.com" quietZone={4} scale={2} />
```
| Prop | Type | Default | Notes |
|------|------|---------|-------|
| `content` | `string` | `""` | Text/URL to encode |
| `errorCorrectionLevel` | `ErrorCorrectionLevel` | `M` | `.L` / `.M` / `.Q` / `.H` |
| `quietZone` | `number` | `4` | Must be ≥ 4 (throws otherwise) |
| `scale` | `number` | `1` | Columns per module before fitting |
| `fit` | `"contain" \| "none"` | `"contain"` | `contain` shrinks to parent |
| `foregroundColor` | `ColorInput` | `"#000000"` | Dark module color |
| `backgroundColor` | `ColorInput` | `"#ffffff"` | Light module / quiet-zone color |
| `fallbackContent` | `string` | `""` | Shown when too small to render |
| `fallbackColor` | `ColorInput` | `"#ffffff"` | Fallback text color |
Import `ErrorCorrectionLevel` from `@opentui/qrcode`, e.g.
`errorCorrectionLevel: ErrorCorrectionLevel.H`. Read-only getters: `version`,
`moduleCount`.
## Colors
### Color Formats
```tsx
// 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
Color props accept string formats (`"#FF0000"`, `"#F00"`, `"red"`, `"transparent"`)
in all frameworks. For programmatic color manipulation, the `RGBA` class from
`@opentui/core` (`fromHex` / `fromInts` / `fromValues` / `parseColor`) works in
Core, React, and Solid alike:
```tsx
import { RGBA } from "@opentui/core"
<box backgroundColor={RGBA.fromHex("#1a1a2e")} borderColor={RGBA.fromInts(122, 162, 247, 255)}>
<text fg={RGBA.fromHex("#c0caf5")}>Styled with RGBA</text>
</box>
```
See **[core/api.md → Colors (RGBA)](../core/api.md#colors-rgba)** for the full
constructor reference and the "when to use each method" guidance.
## Text Wrapping
Text wraps based on parent container:
```tsx
<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
```tsx
function Counter() {
const [count, setCount] = useState(0)
return <text>Count: {count}</text>
}
```
### Solid
```tsx
function Counter() {
const [count, setCount] = createSignal(0)
return <text>Count: {count()}</text>
}
```
### Core
```typescript
const text = new TextRenderable(renderer, {
id: "counter",
content: "Count: 0",
})
// Update later
text.setContent("Count: 1")
```
## Gotchas
### Text Modifiers Outside Text
```tsx
// WRONG - modifiers only work inside <text>
<box>
<strong>Won't work</strong>
</box>
// CORRECT
<box>
<text>
<strong>This works</strong>
</text>
</box>
```
### Empty Text
```tsx
// May cause layout issues
<text></text>
// Better - use space or conditional
<text>{content || " "}</text>
```
### Color Format
```tsx
// WRONG
<text fg="FF0000">Missing #</text>
// CORRECT
<text fg="#FF0000">With #</text>
```
references/core/api.md
# Core API Reference
## Renderer
### createCliRenderer(config?)
Creates and initializes the CLI renderer.
```typescript
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
})
```
#### Custom stdin/stdout (SSH, PTY, xterm.js)
`CliRendererConfig` accepts custom streams so the renderer can drive a transport
other than the local terminal. When `stdout` is not `process.stdout`, native
frame bytes are routed through an internal `NativeSpanFeed`.
```typescript
const renderer = await createCliRenderer({
stdin, // NodeJS.ReadStream (default: process.stdin)
stdout, // NodeJS.WriteStream (default: process.stdout)
width: cols, // Fallback columns for non-TTY / custom stdout
height: rows, // Fallback rows for non-TTY / custom stdout
remote: true, // Treat output as a remote terminal (auto-detects SSH/mosh for process.stdout)
exitOnCtrlC: false,
})
// SIGWINCH is only auto-registered for process.stdout — call resize() manually
// when an external terminal reports a new size:
renderer.resize(newCols, newRows)
// Each stdin/stdout object may be owned by one renderer at a time. destroy()
// releases ownership and restores stdout.write. Allow a microtask to flush
// feed-backed bytes before closing the transport:
renderer.destroy()
await new Promise<void>((resolve) => queueMicrotask(resolve))
```
Size resolution order: `stdout.columns/rows` → `config.width/height` → `80x24`.
Env overrides: `OTUI_OVERRIDE_STDOUT` (force stdout routing),
`OTUI_USE_ALTERNATE_SCREEN`.
### CliRenderer Instance
```typescript
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 shape
```
### Cursor & Mouse Pointer
```typescript
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
```typescript
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 toggled
renderer.on("frame", ({ frameId }) => {}) // A frame was committed
renderer.on("focused_renderable", (current, previous) => {}) // Focus moved
```
### Scheduler & Idle
```typescript
await renderer.idle() // Resolves when no render pass/scheduled render is pending
renderer.getSchedulerState() // { isRunning, isRendering, hasScheduledRender }
renderer.resize(width, height) // Apply an external terminal resize
```
### Desktop Notifications (OSC)
Send a terminal notification via OSC 9 / 777 / 99. Returns `true` only when a
supported protocol was detected.
```typescript
if (renderer.capabilities?.notifications) {
renderer.triggerNotification("Tests passed", "CI") // (message, title?)
}
```
tmux requires `set -g allow-passthrough on`; Zellij uses OSC 99. Env overrides:
`OPENTUI_NOTIFICATION_PROTOCOL` (`osc9`/`osc777`/`osc99`/`none`),
`OPENTUI_NOTIFICATIONS=0`.
### Audio
Native audio engine exported from `@opentui/core`.
```typescript
import { Audio } from "@opentui/core"
const audio = Audio.create({ autoStart: false }) // or setupAudio(options?)
audio.on("error", (error, context) => console.error(`${context.action}: ${error.message}`))
const sound = await audio.loadSoundFile("click.wav")
if (sound != null && audio.start()) {
audio.play(sound, { volume: 0.8, pan: 0, loop: false })
}
audio.dispose()
```
Key methods: `start()`, `stop()`, `loadSound(data)`, `loadSoundFile(path)`,
`play(sound, options?)`, `stopVoice(voice)`, `group(name)`, `setGroupVolume()`,
`setMasterVolume()`, `listPlaybackDevices()`, `getStats()`, `dispose()`.
`AudioPlayOptions`: `{ volume?, pan?, loop?, groupId? }` (32 voice slots).
### Console Overlay
```typescript
renderer.console.show() // Show console overlay
renderer.console.hide() // Hide console overlay
renderer.console.toggle() // Toggle visibility/focus
renderer.console.clear() // Clear console contents
```
## Renderables
All renderables extend the base `Renderable` class and share common properties.
### Common Properties
```typescript
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
```typescript
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 event
```
### TextRenderable
Display styled text content.
```typescript
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.BOLD`
- `TextAttributes.DIM`
- `TextAttributes.ITALIC`
- `TextAttributes.UNDERLINE`
- `TextAttributes.BLINK`
- `TextAttributes.INVERSE`
- `TextAttributes.HIDDEN`
- `TextAttributes.STRIKETHROUGH`
### Box, Input, Select, Tab Select, ScrollBox, ASCII Font
Every component is `new <Name>Renderable(renderer, options)`, composed with
`.add()`. **Full option props for each live in the shared
[components](../components/REFERENCE.md) references** (e.g. Box titles →
[containers.md](../components/containers.md); `minLength` /
`showSelectionIndicator` → [inputs.md](../components/inputs.md)). This section
covers only the **Core-specific** surface: imperative composition and event
enums.
```typescript
import { BoxRenderable, TextRenderable } from "@opentui/core"
const box = new BoxRenderable(renderer, { id: "box", border: true, title: "Panel" })
box.add(new TextRenderable(renderer, { content: "Hello" })) // Compose imperatively
box.focus() // Focusable boxes only
```
**Events (Core uses enums; React/Solid use `onChange`/`onSelect` props):**
```typescript
import {
InputRenderableEvents,
SelectRenderableEvents,
TabSelectRenderableEvents,
} from "@opentui/core"
input.on(InputRenderableEvents.CHANGE, (value: string) => {})
// ITEM_SELECTED = Enter (confirm selection); SELECTION_CHANGED = arrow keys (browse)
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {})
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {})
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {})
```
The `ITEM_SELECTED` / `SELECTION_CHANGED` distinction is identical for Select and
Tab Select. Inputs must be focused to receive keys (`input.focus()`).
### FrameBufferRenderable
Low-level 2D rendering surface.
```typescript
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.
```typescript
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 box
```
## Colors (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
```typescript
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 blue
```
### Common Color Patterns
```typescript
// 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
```typescript
// Accepts multiple formats
parseColor("#FF0000") // Hex string
parseColor("red") // CSS color name
parseColor("transparent") // Special values
parseColor(RGBA.fromHex("#F00")) // Pass-through RGBA objects
```
### When 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
```tsx
// 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
```typescript
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
```typescript
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
```typescript
import type {
CliRenderer,
CliRendererConfig,
RenderContext,
KeyEvent,
Renderable,
// ... and more
} from "@opentui/core"
```
references/core/configuration.md
# Core Configuration
## Renderer Configuration
### createCliRenderer Options
```typescript
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
```bash
# 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.ts
```
### Terminal Compatibility
```bash
# 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.ts
```
## Project Setup
### package.json
```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
```json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
```
> **Note**: OpenTUI uses `NodeNext` module resolution. All internal imports use `.js` extensions. If you use `bundler` resolution, imports still work but `NodeNext` is recommended for compatibility.
## Building Native Code
Native code changes require rebuilding:
```bash
# 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.
references/core/gotchas.md
# Core Gotchas
## Runtime Environment
### Bun (reference runtime) or Node.js 26.3.0
Bun is the reference runtime and the smoothest path — prefer it for new projects:
```bash
# Recommended
bun install @opentui/core
bun run src/index.ts
bun test
```
**Node.js is now also supported**, with caveats:
- Importing `@opentui/core` (and `@opentui/keymap`) works in Node.js **without
FFI** as long as you don't create a native renderer.
- Creating a **native renderer** (`createCliRenderer()`) requires FFI: **Node.js
26.3.0** launched with `--experimental-ffi` (and, under Node's permission
model, `--allow-ffi` plus filesystem permissions). OpenTUI does not install
Node for you.
- The Node path is lower-level than Bun; the `packages/*/package.json` `engines`
fields and root README still list Bun only, but the docs site
(getting-started, keymap, solid pages) is the source of truth for Node support.
### Bun APIs to Use
Prefer Bun's built-in APIs for your application code:
```typescript
// 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.).
```typescript
// 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 exits
```
`renderer.destroy()` restores the terminal to its original state before exiting.
### Environment Variables
Bun auto-loads `.env` files. Don't use dotenv:
```typescript
// 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:**
```typescript
const renderer = await createCliRenderer()
renderer.console.show()
console.log("This appears in the overlay")
```
2. **Toggle with keyboard:**
```typescript
renderer.keyInput.on("keypress", (key) => {
if (key.name === "f12") {
renderer.console.toggle()
}
})
```
3. **Write to a file:**
```typescript
import { appendFileSync } from "node:fs"
function debugLog(msg: string) {
appendFileSync("debug.log", `${new Date().toISOString()} ${msg}\n`)
}
```
4. **Disable console capture:**
```bash
OTUI_USE_CONSOLE=false bun run src/index.ts
```
### Reproduce Issues in Tests
Don't guess at bugs. Create a reproducible test:
```typescript
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:
```typescript
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:
```typescript
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 input
```
## Build Requirements
### Zig is Required
Native code compilation requires Zig:
```bash
# Install Zig first
# macOS
brew install zig
# Linux
# Download from https://ziglang.org/download/
# Then build
bun run build
```
### When to Build
- **TypeScript changes**: NO build needed (Bun runs TS directly)
- **Native code changes**: Build required
```bash
# Only needed when changing native (Zig) code
cd packages/core
bun run build
```
## Common Errors
### "Cannot read properties of undefined"
Usually means a renderable wasn't added to the tree:
```typescript
// 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:
```typescript
// After changing layout properties
box.setWidth(newWidth)
renderer.requestRender()
```
### Text Overflow/Clipping
Text doesn't wrap by default. Set explicit width:
```typescript
// 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:
```typescript
// 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:
```typescript
// 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:
```typescript
// 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:
```typescript
// 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:
```typescript
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:
```bash
# CORRECT
cd packages/core
bun test
# For native tests
cd packages/core
bun run test:native
```
### Filter Tests
```bash
# 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`:
```typescript
// 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
```typescript
renderer.keyInput.on("keypress", (key) => {
// eventType: "press" | "release" | "repeat"
if (key.eventType === "repeat") {
// Key being held down
}
})
```
references/core/patterns.md
# Core Patterns
## Composition Patterns
### Imperative Composition
Create renderables and compose with `.add()`:
```typescript
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:
```typescript
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:
```typescript
// 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:
```typescript
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
```typescript
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
```typescript
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
```typescript
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:
```typescript
// 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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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:
```typescript
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
```typescript
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 })
```
references/core/REFERENCE.md
# 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)
```bash
bunx create-tui@latest -t core my-app
cd my-app
bun run src/index.ts
```
The 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
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/core
```
```typescript
import { 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
```bash
bun install @opentui/core # Install
bun run src/index.ts # Run directly (no build needed)
bun test # Run tests
```
## Runtime Requirements
OpenTUI runs on **Bun (reference runtime)** and uses Zig for native builds.
**Node.js 26.3.0** is also supported for the native renderer when launched with
`--experimental-ffi`; importing core/keymap without a native renderer works on
Node without FFI. See [Gotchas](./gotchas.md) for the full Node.js notes.
```bash
# Package management
bun install @opentui/core
# Running
bun run src/index.ts
bun test
# Building (only needed for native code changes)
bun run build
```
**Zig** is required for building native components.
## Additional Capabilities
- **Audio** — native audio engine via `Audio` from `@opentui/core`. See [API](./api.md#audio).
- **Notifications** — `renderer.triggerNotification(message, title?)` (OSC 9/777/99). See [API](./api.md).
- **SSH** — serve a TUI over SSH with the `@opentui/ssh` package:
```typescript
import { createServer } from "@opentui/ssh"
import { BoxRenderable, TextRenderable } from "@opentui/core"
const server = createServer({
hostKey: { path: "./host_key" }, // auto-generated on first run
auth: { publicKey: "any" },
}).serve((session) => {
const { renderer, identity } = session // renderer is bound to the SSH channel
const box = new BoxRenderable(renderer, { width: "100%", height: "100%", border: true })
box.add(new TextRenderable(renderer, { content: `Hello, ${identity.username}!` }))
renderer.root.add(box)
})
await server.listen(2222) // ssh -p 2222 localhost
```
`@opentui/core` is a peer dependency; works with core, React (`createRoot`),
and Solid (`render`).
## In This Reference
- [Configuration](./configuration.md) - Renderer options, environment variables
- [API](./api.md) - Renderer, Renderables, types, utilities
- [Patterns](./patterns.md) - Composition, events, state management
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
## See Also
- [React](../react/REFERENCE.md) - React reconciler for declarative TUI
- [Solid](../solid/REFERENCE.md) - Solid reconciler for declarative TUI
- [Layout](../layout/REFERENCE.md) - Yoga/Flexbox layout system
- [Components](../components/REFERENCE.md) - Component reference by category
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
references/keyboard/REFERENCE.md
# Keyboard Input Handling
How to handle keyboard input in OpenTUI applications.
## Overview
OpenTUI provides keyboard input handling through:
- **Core**: `renderer.keyInput` EventEmitter
- **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:
```typescript
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
```typescript
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
```tsx
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
```tsx
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`:
```typescript
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
```typescript
// 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:
```typescript
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "press") {
// Initial key press
}
})
```
### Repeat Events
Key held down:
```typescript
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "repeat" || key.repeated) {
// Key is being held
}
})
```
### Release Events
Key released (opt-in):
```tsx
// 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
```tsx
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
```tsx
function Modal({ onClose, children }) {
useKeyboard((key) => {
if (key.name === "escape") {
onClose()
}
})
return (
<box border padding={2}>
{children}
</box>
)
}
```
### Vim-style Modes
```tsx
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
```tsx
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
```tsx
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
```typescript
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:
```typescript
import { decodePasteBytes, stripAnsiSequences } from "@opentui/core"
const text = decodePasteBytes(event.bytes) // Decode UTF-8
const clean = stripAnsiSequences(decodePasteBytes(event.bytes)) // Decode + strip ANSI
```
### Core
```typescript
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:
```tsx
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**: `usePaste` is **Solid-only**. React does not have this hook - handle paste via the Core event emitter or input component's `onChange`.
## 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:
```tsx
// 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:
```typescript
import type { Selection } from "@opentui/core"
renderer.on("selection", (selection: Selection) => {
const text = selection.getSelectedText()
if (text) {
renderer.copyToClipboardOSC52(text)
}
})
```
> **Important**: Call `selection.getSelectedText()` on the `Selection` object from the event -- not `renderer.root.getSelectedText()`. Individual renderables only return their own selected text. The `Selection` object aggregates across the tree.
### Copy-on-Selection (Solid)
```tsx
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**: `useSelectionHandler` is **Solid-only**. React does not have this hook -- use the Core `renderer.on("selection", ...)` event.
### Selection Object
The `Selection` object passed to the event callback:
```typescript
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 active
```
Individual renderables also expose:
```typescript
renderable.hasSelection() // Does this renderable have selected text?
renderable.getSelectedText() // Selected text in this renderable only
```
### How 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.
```typescript
// 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:
```tsx
<input focused /> // Receives keyboard input
// Global useKeyboard still fires, but input consumes characters
```
To prevent conflicts, check if an input is focused before handling global shortcuts:
```tsx
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+C` often sends SIGINT (use `exitOnCtrlC: false` to handle)
- `Ctrl+Z` suspends 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](../react/api.md) - `useKeyboard` hook reference
- [Solid API](../solid/api.md) - `useKeyboard` hook reference
- [Input Components](../components/inputs.md) - Focus management with input, textarea, select
- [Testing](../testing/REFERENCE.md) - Simulating key presses in tests
references/keymap/REFERENCE.md
# Keymap (@opentui/keymap)
A host-agnostic key binding, command, and sequence engine for both terminal
(OpenTUI) and browser (DOM) hosts. It is a **separate package** from
`@opentui/core`, shipped as `@opentui/keymap`, and is **pure JavaScript** — it
imports in Node.js with **no Bun and no native FFI required** (only creating a
native OpenTUI renderer needs FFI).
## When to Use
Use keymap when you need declarative, layered keybindings with commands, leader
keys, multi-key sequences (`dd`, `<leader>s`), counts (`{count}j`), or ex-style
commands — instead of hand-rolling `useKeyboard`/`keyInput` handlers.
## Install
```bash
bun add @opentui/keymap
```
## Entry Points
| Import | Purpose |
|--------|---------|
| `@opentui/keymap` | Main engine: `Keymap`, key stringifiers, shared types |
| `@opentui/keymap/addons` | Universal addons (parser stages, metadata, diagnostics, sequences, ex-commands) |
| `@opentui/keymap/addons/opentui` | Universal addons + OpenTUI base-layout & edit-buffer helpers |
| `@opentui/keymap/extras` | Config/formatting helpers (`commandBindings`, `createBindingLookup`, `formatKeySequence`, `formatCommandBindings`) |
| `@opentui/keymap/extras/graph` | `getGraphSnapshot()` for debug/graph UIs |
| `@opentui/keymap/testing` | Fake host + diagnostics for addon tests |
| `@opentui/keymap/opentui` | OpenTUI terminal host adapter |
| `@opentui/keymap/html` | DOM host adapter |
| `@opentui/keymap/react` | React provider/hooks |
| `@opentui/keymap/solid` | Solid provider/hooks |
## Quick Start (Terminal)
```typescript
import { createCliRenderer } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
const renderer = await createCliRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
keymap.registerLayer({
commands: [{ name: "quit", run() { renderer.destroy() } }],
bindings: [{ key: "q", cmd: "quit" }],
})
```
Construction options:
- Bare: `new Keymap(host)`
- Host helpers: `createOpenTuiKeymap(renderer)` / `createHtmlKeymap(root)`
- With default addons: `createDefaultOpenTuiKeymap(renderer)` / `createDefaultHtmlKeymap(root)`
## Binding Shape
A binding has a required `key`, plus optional fields:
```typescript
{
key: "ctrl+x", // string ("dd", "<leader>s", "{count}j") or stroke object { name: "return", ctrl: true }
cmd: "quit", // command name to run
event: "press", // "press" (default) | "release"
preventDefault: true, // default true
fallthrough: false, // default false
// ...custom addon fields
}
```
## Core API (Keymap instance)
- **Register** (each returns a disposer): `registerLayer()`, `registerToken()`,
`registerSequencePattern()`, `registerLayerFields()`, `registerBindingFields()`,
`registerCommandFields()`, plus parser/expander/transformer/resolver
`prepend*/append*` stages.
- **Dispatch/execute**: `runCommand()`, `dispatchCommand()`,
`intercept("key" | "key:after" | "raw", fn)`.
- **Query**: `getActiveKeys(options?)`, `getCommands()`, `getCommandEntries()`,
`getCommandBindings()`, `getPendingSequence()`, `hasPendingSequence()`,
`clearPendingSequence()`, `popPendingSequence()`.
- **Data/state**: `setData(name, value)`, `getData(name)`; events via
`on("state" | "pendingSequence" | "dispatch" | "warning" | "error", fn)`.
- **Key helpers**: `parseKeySequence()`, `formatKey()`, `createKeyMatcher()`,
`getHostMetadata()`; exported `stringifyKeyStroke()` / `stringifyKeySequence()`.
## Shipped Addons
`registerDefaultKeys()`, `registerLeader()`, `registerTimedLeader()`,
`registerModBindings()`, `registerCommaBindings()`, `registerEmacsBindings()`,
`registerExCommands()`, `registerNeovimDisambiguation()`,
`registerMetadataFields()`, `registerEnabledFields()`; OpenTUI-specific
`registerBaseLayoutFallback()`, `createTextareaBindings()`,
`registerManagedTextareaLayer()`, `registerEditBufferCommands()`.
## React
```tsx
import { KeymapProvider, useKeymap, useBindings, useActiveKeys } from "@opentui/keymap/react"
// Provide a pre-created Keymap<Renderable, KeyEvent>, then:
useBindings((keymap) => keymap.registerLayer({ /* ... */ }), [deps])
const active = useActiveKeys()
```
Exports: `KeymapProvider`, `useKeymap()`, `useBindings(createLayer, deps?)`,
`useActiveKeys(options?)`, `usePendingSequence()`, `reactiveMatcherFromStore()`.
## Solid
The Solid adapter (`@opentui/keymap/solid`) exposes an equivalent provider and
hooks that consume a pre-created `Keymap`.
## Keymap vs Keyboard
- **Keyboard** (`keyboard/REFERENCE.md`, `useKeyboard`/`keyInput`): low-level raw
key events. Best for simple, one-off shortcuts.
- **Keymap** (this file): declarative layered bindings, commands, sequences,
leader keys, and counts. Best for editor-style keymaps and larger apps.
references/layout/patterns.md
# Layout Patterns
Common layout recipes for terminal user interfaces.
## Full-Screen App
Fill the entire terminal:
```tsx
function App() {
return (
<box width="100%" height="100%">
{/* Content fills terminal */}
</box>
)
}
```
## Header/Content/Footer
Classic app layout:
```tsx
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
```tsx
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:
```tsx
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
```tsx
<box width="100%" justifyContent="center">
<box width={40}>
<text>Centered horizontally</text>
</box>
</box>
```
### Vertically Centered
```tsx
<box height="100%" alignItems="center">
<text>Centered vertically</text>
</box>
```
### Both Axes
```tsx
<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:
```tsx
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:
```tsx
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
```tsx
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
```tsx
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:
```tsx
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
```tsx
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:
```tsx
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:
```tsx
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:
```tsx
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
```tsx
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:
```tsx
// 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:
```tsx
// 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}`
references/layout/REFERENCE.md
# 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:
```tsx
// 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 top
```
### justifyContent
Aligns children along the main axis:
```tsx
<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:
```tsx
<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:
```tsx
<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:
```tsx
<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:
```tsx
<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:
```tsx
<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:
```tsx
<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:
```tsx
<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
```tsx
<box width={40} height={10}>
{/* Exactly 40 columns by 10 rows */}
</box>
```
### Percentage Dimensions
Parent must have explicit size:
```tsx
<box width="100%" height="100%">
<box width="50%" height="50%">
{/* Half of parent */}
</box>
</box>
```
### Min/Max Constraints
```tsx
<box
minWidth={20}
maxWidth={60}
minHeight={5}
maxHeight={20}
>
{/* Constrained sizing */}
</box>
```
## Spacing
### Padding (inside)
```tsx
// All sides
<box padding={2}>Content</box>
// Individual sides
<box
paddingTop={1}
paddingRight={2}
paddingBottom={1}
paddingLeft={2}
>
Content
</box>
```
### Margin (outside)
```tsx
// 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:
```tsx
<box position="relative">
{/* Normal flow */}
</box>
```
### Absolute
Element positioned relative to nearest positioned ancestor:
```tsx
<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
```tsx
<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
```tsx
// Visible (default)
<box display="flex">Visible</box>
// Hidden (removed from layout)
<box display="none">Hidden</box>
```
## Overflow
```tsx
<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:
```tsx
<box position="relative">
<box position="absolute" zIndex={1}>Behind</box>
<box position="absolute" zIndex={2}>In front</box>
</box>
```
## See Also
- [Layout Patterns](./patterns.md) - Common layout recipes
- [Components/Containers](../components/containers.md) - Box and ScrollBox details
references/react/api.md
# React API Reference
> **Requirements**: `@opentui/react` uses `react-reconciler` 0.33 and requires
> **React ≥ 19.2**. Run `bun add react@latest react-dom@latest` before upgrading.
## Rendering
### createRoot(renderer)
Creates a React root for rendering.
```tsx
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
const renderer = await createCliRenderer({
exitOnCtrlC: false, // Handle Ctrl+C yourself
})
const root = createRoot(renderer)
root.render(<App />)
```
## Hooks
### useRenderer()
Access the OpenTUI renderer instance.
```tsx
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
// Access renderer properties
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
// Show debug console
renderer.console.show()
// Access theme mode (dark/light based on terminal settings)
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
}, [renderer])
return <text>Hello</text>
}
// Listen for theme mode changes
function ThemedApp() {
const renderer = useRenderer()
const [theme, setTheme] = useState(renderer.themeMode ?? "dark")
useEffect(() => {
const handler = (mode: "dark" | "light") => setTheme(mode)
renderer.on("theme_mode", handler)
return () => renderer.off("theme_mode", handler)
}, [renderer])
return (
<box backgroundColor={theme === "dark" ? "#1a1a2e" : "#ffffff"}>
<text fg={theme === "dark" ? "#fff" : "#000"}>
Current theme: {theme}
</text>
</box>
)
}
```
### useKeyboard(handler, options?)
Handle keyboard events.
```tsx
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // Never use process.exit() directly!
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})
return <text>Press ESC to exit</text>
}
// With release events
function GameControls() {
const [pressed, setPressed] = useState(new Set<string>())
useKeyboard(
(event) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true } // Include release events
)
return <text>Pressed: {Array.from(pressed).join(", ")}</text>
}
```
**Options:**
- `release?: boolean` - Include key release events (default: false)
**KeyEvent properties:**
- `name: string` - Key name ("a", "escape", "f1", etc.)
- `sequence: string` - Raw escape sequence
- `ctrl: boolean` - Ctrl modifier
- `shift: boolean` - Shift modifier
- `meta: boolean` - Alt modifier
- `option: boolean` - Option modifier (macOS)
- `eventType: "press" | "release" | "repeat"`
- `repeated: boolean` - Key is being held
### useOnResize(callback)
Handle terminal resize events.
```tsx
import { useOnResize } from "@opentui/react"
function App() {
useOnResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize the terminal</text>
}
```
### useTerminalDimensions()
Get reactive terminal dimensions.
```tsx
import { useTerminalDimensions } from "@opentui/react"
function ResponsiveLayout() {
const { width, height } = useTerminalDimensions()
return (
<box flexDirection={width > 80 ? "row" : "column"}>
<box flexGrow={1}>
<text>Width: {width}</text>
</box>
<box flexGrow={1}>
<text>Height: {height}</text>
</box>
</box>
)
}
```
### useTimeline(options?)
Create animations with the timeline system.
```tsx
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function AnimatedBox() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
useEffect(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
}, [timeline])
return <box style={{ width, height: 3, backgroundColor: "#6a5acd" }} />
}
```
**Options:**
- `duration?: number` - Default duration (ms)
- `loop?: boolean` - Loop the timeline
- `autoplay?: boolean` - Auto-start (default: true)
- `onComplete?: () => void` - Completion callback
- `onPause?: () => void` - Pause callback
**Timeline methods:**
- `add(target, properties, startTime?)` - Add animation
- `play()` - Start playback
- `pause()` - Pause playback
- `restart()` - Restart from beginning
### usePaste(handler)
Subscribe to bracketed-paste events. `handler` receives a `PasteEvent` with raw
`bytes` (decode with `decodePasteBytes`). The callback is kept stable across
renders.
```tsx
import { usePaste } from "@opentui/react"
import { decodePasteBytes } from "@opentui/core"
function Editor() {
usePaste((event) => {
console.log("Pasted:", decodePasteBytes(event.bytes))
})
return <textarea focused />
}
```
### useFocus(handler) / useBlur(handler)
Fire when the terminal window gains or loses OS focus.
```tsx
import { useFocus, useBlur } from "@opentui/react"
useFocus(() => console.log("Terminal gained focus"))
useBlur(() => console.log("Terminal lost focus"))
```
### useSelectionHandler(handler)
Fire when the user finishes a text selection (mouse-up). `handler` receives a
`Selection` (from `@opentui/core`); use `selection.getSelectedText()`.
```tsx
import { useSelectionHandler } from "@opentui/react"
useSelectionHandler((selection) => {
console.log("Selected:", selection.getSelectedText())
})
```
> These four hooks (`usePaste`, `useFocus`, `useBlur`, `useSelectionHandler`)
> mirror the Solid hooks and are available from `@opentui/react`.
## Components
Full props for every component live in the shared **[components](../components/REFERENCE.md)**
references. This section only covers what is **React-specific**; read the linked
category file for the complete prop list.
### JSX Element Names
React uses **hyphenated** tag names. Full props are in the linked file:
| Element | Full props |
|---------|-----------|
| `<text>`, `<span>`, `<strong>`, `<em>`, `<u>`, `<a>`, `<br>` | [text-display.md](../components/text-display.md) |
| `<box>`, `<scrollbox>` | [containers.md](../components/containers.md) |
| `<input>`, `<textarea>`, `<select>`, `<tab-select>` | [inputs.md](../components/inputs.md) |
| `<code>`, `<line-number>`, `<diff>`, `<markdown>` | [code-diff.md](../components/code-diff.md) |
| `<ascii-font>` | [text-display.md](../components/text-display.md) |
### Text Styling Uses Nested Tags (React-specific)
Style text with **nested modifier elements**, not props:
```tsx
<text fg="#FFFFFF" bg="#000000" selectable>
<span fg="red">Red</span> <strong>Bold</strong> <em>Italic</em> <u>Underline</u>
<br />
<a href="https://...">Link</a>
</text>
```
> **Note**: Do NOT use `bold`, `italic`, `underline` as props on `<text>`. Use
> nested modifier tags like `<strong>`, `<em>`, `<u>` instead.
### Controlled Inputs (React-specific)
Inputs are controlled with `value` + `onChange` (single value), and use the
`focused` prop to receive keyboard input:
```tsx
<input value={value} onChange={setValue} focused />
<textarea value={text} onChange={setText} focused width={40} height={10} />
// Select/tab-select: onChange fires on navigation, onSelect on Enter
<select options={opts} onChange={(i, opt) => setSel(opt)} focused />
```
### Scrollbox `style` Nesting (React-specific)
`<scrollbox>` takes a nested `style` object (`rootOptions`, `wrapperOptions`,
`viewportOptions`, `contentOptions`, `scrollbarOptions`). See
[containers.md](../components/containers.md) for the full structure.
## Type Exports
```tsx
import type {
// Component props
TextProps,
BoxProps,
InputProps,
SelectProps,
// Hook types
KeyEvent,
// From core
CliRenderer,
} from "@opentui/react"
```
references/react/configuration.md
# React Configuration
## Project Setup
### Quick Start
```bash
bunx create-tui@latest -t react my-app
cd my-app && bun install
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
### Manual Setup
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/react @opentui/core react
```
## TypeScript Configuration
### tsconfig.json
```json
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
```
**Critical settings:**
- `jsx: "react-jsx"` - Use the new JSX transform
- `jsxImportSource: "@opentui/react"` - Import JSX runtime from OpenTUI
- `module` / `moduleResolution: "NodeNext"` - Recommended for OpenTUI compatibility
### Why DOM lib?
The `DOM` lib is needed for React types. OpenTUI's JSX types extend React's.
## Package Configuration
### package.json
```json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.tsx",
"dev": "bun --watch run src/index.tsx",
"test": "bun test",
"build": "bun build src/index.tsx --outdir=dist --target=bun"
},
"dependencies": {
"@opentui/core": "latest",
"@opentui/react": "latest",
"react": ">=19.0.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": ">=19.0.0",
"typescript": "latest"
}
}
```
## Project Structure
Recommended structure:
```
my-tui-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── MainContent.tsx
│ ├── hooks/
│ │ └── useAppState.ts
│ ├── App.tsx
│ └── index.tsx
├── package.json
└── tsconfig.json
```
### Entry Point (src/index.tsx)
```tsx
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
import { App } from "./App"
const renderer = await createCliRenderer({
exitOnCtrlC: true,
})
createRoot(renderer).render(<App />)
```
### App Component (src/App.tsx)
```tsx
import { Header } from "./components/Header"
import { Sidebar } from "./components/Sidebar"
import { MainContent } from "./components/MainContent"
export function App() {
return (
<box flexDirection="column" width="100%" height="100%">
<Header />
<box flexDirection="row" flexGrow={1}>
<Sidebar />
<MainContent />
</box>
</box>
)
}
```
## Renderer Configuration
### createCliRenderer Options
```tsx
import { createCliRenderer, ConsolePosition } from "@opentui/core"
const renderer = await createCliRenderer({
// Rendering
targetFPS: 60,
// Behavior
exitOnCtrlC: true, // Set false to handle Ctrl+C yourself
autoFocus: true, // Auto-focus elements on click (default: true)
useMouse: true, // Enable mouse support (default: true)
// Debug console
consoleOptions: {
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
// Cleanup
onDestroy: () => {
// Cleanup code
},
})
```
## Building for Distribution
### Bundling with Bun
```typescript
// build.ts
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
})
```
Run: `bun run build.ts`
### Creating Executables
```typescript
// build.ts
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
compile: {
target: "bun-darwin-arm64", // or bun-linux-x64, etc.
outfile: "my-app",
},
})
```
## Environment Variables
Create `.env` for development:
```env
# Debug settings
OTUI_SHOW_STATS=false
SHOW_CONSOLE=false
# App settings
API_URL=https://api.example.com
```
Bun auto-loads `.env` files. Access via `process.env`:
```tsx
const apiUrl = process.env.API_URL
```
## React DevTools
OpenTUI React supports React DevTools for debugging.
### Setup
1. Install DevTools as a dev dependency (must use version 7):
```bash
bun add react-devtools-core@7 -d
```
2. Run DevTools standalone app:
```bash
npx react-devtools@7
```
3. Start your app with `DEV=true` environment variable:
```bash
DEV=true bun run src/index.tsx
```
**Important**: Auto-connect to DevTools ONLY happens when `DEV=true` is set. Without this environment variable, the DevTools connection code is not loaded.
### How It Works
OpenTUI checks for `process.env["DEV"] === "true"` at startup. When true, it dynamically imports `react-devtools-core` and connects to the standalone DevTools app.
## Testing Configuration
### Test Setup
```typescript
// src/test-utils.tsx
import { createTestRenderer } from "@opentui/core/testing"
import { createRoot } from "@opentui/react"
export async function renderForTest(
element: React.ReactElement,
options = { width: 80, height: 24 }
) {
const testSetup = await createTestRenderer(options)
createRoot(testSetup.renderer).render(element)
return testSetup
}
```
### Test Example
```typescript
// src/components/Counter.test.tsx
import { test, expect } from "bun:test"
import { renderForTest } from "../test-utils"
import { Counter } from "./Counter"
test("Counter renders initial value", async () => {
const { snapshot } = await renderForTest(<Counter initialValue={5} />)
expect(snapshot()).toContain("Count: 5")
})
```
## Common Issues
### JSX Types Not Working
Ensure `jsxImportSource` is set:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react"
}
}
```
### React Version Mismatch
Ensure React 19+:
```bash
bun install react@19 @types/react@19
```
### Module Resolution Errors
Use `moduleResolution: "bundler"` for Bun compatibility.
references/react/gotchas.md
# React Gotchas
## Critical
### Never use `process.exit()` directly
**This is the most common mistake.** Using `process.exit()` leaves the terminal in a broken state (cursor hidden, raw mode, alternate screen).
```tsx
// WRONG - Terminal left in broken state
process.exit(0)
// CORRECT - Use renderer.destroy()
import { useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
const handleExit = () => {
renderer.destroy() // Cleans up and exits properly
}
}
```
`renderer.destroy()` restores the terminal (exits alternate screen, restores cursor, etc.) before exiting.
### Signal Handling
OpenTUI automatically handles cleanup for these signals:
- `SIGINT` (Ctrl+C), `SIGTERM`, `SIGQUIT` - Standard termination
- `SIGHUP` - Terminal closed/hangup
- `SIGBREAK` - Ctrl+Break (Windows)
- `SIGPIPE` - Broken pipe (output closed)
- `SIGBUS`, `SIGFPE` - Hardware errors
This ensures terminal state is restored even on unexpected termination. If you need custom signal handling, use `exitOnCtrlC: false` and handle signals yourself while still calling `renderer.destroy()`.
## JSX Configuration
### Missing jsxImportSource
**Symptom**: JSX elements have wrong types, components don't render
```
// Error: Property 'text' does not exist on type 'JSX.IntrinsicElements'
```
**Fix**: Configure tsconfig.json:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react"
}
}
```
### HTML Elements vs TUI Elements
OpenTUI's JSX elements are **not** HTML elements:
```tsx
// WRONG - These are HTML concepts
<div>Not supported</div>
<button>Not supported</button>
<span>Only works inside <text></span>
// CORRECT - OpenTUI elements
<box>Container</box>
<text>Display text</text>
<text><span>Inline styled</span></text>
```
## Component Issues
### Text Modifiers Outside Text
Text modifiers only work inside `<text>`:
```tsx
// WRONG
<box>
<strong>This won't work</strong>
</box>
// CORRECT
<box>
<text>
<strong>This works</strong>
</text>
</box>
```
### Focus Not Working
Components must be explicitly focused:
```tsx
// WRONG - Won't receive keyboard input
<input placeholder="Type here..." />
// CORRECT
<input placeholder="Type here..." focused />
// Or manage focus state
const [isFocused, setIsFocused] = useState(true)
<input placeholder="Type here..." focused={isFocused} />
```
### Select Not Responding
Select requires focus and proper options format:
```tsx
// WRONG - Missing required properties
<select options={["a", "b", "c"]} />
// CORRECT
<select
options={[
{ name: "Option A", description: "First option", value: "a" },
{ name: "Option B", description: "Second option", value: "b" },
]}
onSelect={(index, option) => {
// Called when Enter is pressed
console.log("Selected:", option.name)
}}
focused
/>
```
### Select Events Confusion
Remember: `onSelect` fires on Enter (selection confirmed), `onChange` fires on navigation:
```tsx
// WRONG - expecting onChange to fire on Enter
<select
options={options}
onChange={(i, opt) => submitForm(opt)} // This fires on arrow keys!
/>
// CORRECT
<select
options={options}
onSelect={(i, opt) => submitForm(opt)} // Enter pressed - submit
onChange={(i, opt) => showPreview(opt)} // Arrow keys - preview
/>
```
## Hook Issues
### useKeyboard Not Firing
Multiple `useKeyboard` hooks can conflict:
```tsx
// Both handlers fire - may cause issues
function App() {
useKeyboard((key) => { /* parent handler */ })
return <ChildWithKeyboard />
}
function ChildWithKeyboard() {
useKeyboard((key) => { /* child handler */ })
return <text>Child</text>
}
```
**Solution**: Use a single keyboard handler or implement event stopping:
```tsx
function App() {
const [handled, setHandled] = useState(false)
useKeyboard((key) => {
if (handled) {
setHandled(false)
return
}
// Handle at app level
})
return <Child onKeyHandled={() => setHandled(true)} />
}
```
### useEffect Cleanup
Always clean up intervals and listeners:
```tsx
// WRONG - Memory leak
useEffect(() => {
setInterval(() => updateData(), 1000)
}, [])
// CORRECT
useEffect(() => {
const interval = setInterval(() => updateData(), 1000)
return () => clearInterval(interval) // Cleanup!
}, [])
```
## Styling Issues
### Colors Not Applying
Check color format:
```tsx
// CORRECT formats
<text fg="#FF0000">Red</text>
<text fg="red">Red</text>
<box backgroundColor="#1a1a2e">Box</box>
// WRONG
<text fg="FF0000">Missing #</text>
<text color="#FF0000">Wrong prop name (use fg)</text>
```
### Layout Not Working
Ensure parent has dimensions:
```tsx
// WRONG - Parent has no height
<box flexDirection="column">
<box flexGrow={1}>Won't grow</box>
</box>
// CORRECT
<box flexDirection="column" height="100%">
<box flexGrow={1}>Will grow</box>
</box>
```
### Percentage Widths Not Working
Parent must have explicit dimensions:
```tsx
// WRONG
<box>
<box width="50%">Won't work</box>
</box>
// CORRECT
<box width="100%">
<box width="50%">Works</box>
</box>
```
## Performance Issues
### Too Many Re-renders
Avoid inline objects/functions in props:
```tsx
// WRONG - New object every render
<box style={{ padding: 2 }}>Content</box>
// BETTER - Use direct props
<box padding={2}>Content</box>
// OR memoize style objects
const style = useMemo(() => ({ padding: 2 }), [])
<box style={style}>Content</box>
```
### Heavy Components
Use React.memo for expensive components:
```tsx
const ExpensiveList = React.memo(function ExpensiveList({
items
}: {
items: Item[]
}) {
return (
<box flexDirection="column">
{items.map(item => (
<text key={item.id}>{item.name}</text>
))}
</box>
)
})
```
### State Updates During Render
Don't update state during render:
```tsx
// WRONG
function Component({ value }: { value: number }) {
const [count, setCount] = useState(0)
// This causes infinite loop!
if (value > 10) {
setCount(value)
}
return <text>{count}</text>
}
// CORRECT
function Component({ value }: { value: number }) {
const [count, setCount] = useState(0)
useEffect(() => {
if (value > 10) {
setCount(value)
}
}, [value])
return <text>{count}</text>
}
```
## Debugging
### Console Not Visible
OpenTUI captures console output. Show the overlay:
```tsx
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
renderer.console.show()
console.log("Now you can see this!")
}, [renderer])
return <box>{/* ... */}</box>
}
```
### Component Not Rendering
Check if component is in the tree:
```tsx
// WRONG - Conditional returns nothing
function MaybeComponent({ show }: { show: boolean }) {
if (!show) return // Returns undefined!
return <text>Visible</text>
}
// CORRECT
function MaybeComponent({ show }: { show: boolean }) {
if (!show) return null // Explicit null
return <text>Visible</text>
}
```
### Events Not Firing
Check event handler names:
```tsx
// WRONG
<box onClick={() => {}}>Click</box> // No onClick in TUI
// CORRECT
<box onMouseDown={() => {}}>Click</box>
<box onMouseUp={() => {}}>Click</box>
```
## Runtime Issues
### Use Bun, Not Node
```bash
# WRONG
node src/index.tsx
npm run start
# CORRECT
bun run src/index.tsx
bun run start
```
### Async Top-level
Bun supports top-level await, but be careful:
```tsx
// index.tsx - This works in Bun
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
// If you need to handle errors
try {
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
} catch (error) {
console.error("Failed to initialize:", error)
process.exit(1)
}
```
## Common Error Messages
### "Cannot read properties of undefined (reading 'root')"
Renderer not initialized:
```tsx
// WRONG
const renderer = createCliRenderer() // Missing await!
createRoot(renderer).render(<App />)
// CORRECT
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
```
### "Invalid hook call"
Hooks called outside component:
```tsx
// WRONG
const dimensions = useTerminalDimensions() // Outside component!
function App() {
return <text>{dimensions.width}</text>
}
// CORRECT
function App() {
const dimensions = useTerminalDimensions()
return <text>{dimensions.width}</text>
}
```
references/react/patterns.md
# React Patterns
## State Management
### Local State with useState
```tsx
import { useState } from "react"
function Counter() {
const [count, setCount] = useState(0)
return (
<box flexDirection="row" gap={2}>
<text>Count: {count}</text>
<box border onMouseDown={() => setCount(c => c - 1)}>
<text>-</text>
</box>
<box border onMouseDown={() => setCount(c => c + 1)}>
<text>+</text>
</box>
</box>
)
}
```
### Complex State with useReducer
```tsx
import { useReducer } from "react"
type State = {
items: string[]
selectedIndex: number
}
type Action =
| { type: "ADD_ITEM"; item: string }
| { type: "REMOVE_ITEM"; index: number }
| { type: "SELECT"; index: number }
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_ITEM":
return { ...state, items: [...state.items, action.item] }
case "REMOVE_ITEM":
return {
...state,
items: state.items.filter((_, i) => i !== action.index),
}
case "SELECT":
return { ...state, selectedIndex: action.index }
}
}
function ItemList() {
const [state, dispatch] = useReducer(reducer, {
items: [],
selectedIndex: 0,
})
// Use state and dispatch...
}
```
### Context for Global State
```tsx
import { createContext, useContext, useState, ReactNode } from "react"
type Theme = "dark" | "light"
const ThemeContext = createContext<{
theme: Theme
setTheme: (theme: Theme) => void
} | null>(null)
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark")
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error("useTheme must be used within ThemeProvider")
return context
}
// Usage
function App() {
return (
<ThemeProvider>
<ThemedBox />
</ThemeProvider>
)
}
function ThemedBox() {
const { theme } = useTheme()
return (
<box backgroundColor={theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
<text fg={theme === "dark" ? "#fff" : "#000"}>
Current theme: {theme}
</text>
</box>
)
}
```
## Focus Management
### Focus State
```tsx
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
function FocusableForm() {
const [focusIndex, setFocusIndex] = useState(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
if (key.shift && key.name === "tab") {
setFocusIndex(i => (i - 1 + fields.length) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
{fields.map((field, i) => (
<input
key={field}
placeholder={`Enter ${field}...`}
focused={i === focusIndex}
/>
))}
</box>
)
}
```
### Ref-based Focus
```tsx
import { useRef, useEffect } from "react"
function AutoFocusInput() {
const inputRef = useRef<any>(null)
useEffect(() => {
// Focus on mount
inputRef.current?.focus()
}, [])
return <input ref={inputRef} placeholder="Auto-focused" />
}
```
## Keyboard Navigation
### Global Shortcuts
```tsx
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
// Quit on Escape or Ctrl+C - use renderer.destroy(), never process.exit()
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
renderer.destroy()
return
}
// Toggle help on ?
if (key.name === "?" || (key.shift && key.name === "/")) {
setShowHelp(h => !h)
}
// Vim-style navigation
if (key.name === "j") moveDown()
if (key.name === "k") moveUp()
})
return <box>{/* ... */}</box>
}
```
### Component-level Shortcuts
```tsx
function Editor() {
const [mode, setMode] = useState<"normal" | "insert">("normal")
useKeyboard((key) => {
if (mode === "normal") {
if (key.name === "i") setMode("insert")
if (key.name === "escape") setMode("normal")
} else {
if (key.name === "escape") setMode("normal")
// Handle text input in insert mode
}
})
return (
<box>
<text>Mode: {mode}</text>
<textarea focused={mode === "insert"} />
</box>
)
}
```
## Form Handling
### Controlled Inputs
```tsx
import { useState } from "react"
function LoginForm() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const handleSubmit = () => {
console.log("Login:", { username, password })
}
return (
<box flexDirection="column" gap={1} padding={2} border>
<text>Login</text>
<box flexDirection="row" gap={1}>
<text>Username:</text>
<input
value={username}
onChange={setUsername}
width={20}
/>
</box>
<box flexDirection="row" gap={1}>
<text>Password:</text>
<input
value={password}
onChange={setPassword}
width={20}
/>
</box>
<box border onMouseDown={handleSubmit}>
<text>Submit</text>
</box>
</box>
)
}
```
### Form Validation
```tsx
function ValidatedForm() {
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const validateEmail = (value: string) => {
if (!value.includes("@")) {
setError("Invalid email address")
} else {
setError("")
}
setEmail(value)
}
return (
<box flexDirection="column" gap={1}>
<input
value={email}
onChange={validateEmail}
placeholder="Email"
/>
{error && <text fg="red">{error}</text>}
</box>
)
}
```
## Responsive Design
### Terminal-size Responsive
```tsx
import { useTerminalDimensions } from "@opentui/react"
function ResponsiveLayout() {
const { width } = useTerminalDimensions()
// Stack vertically on narrow terminals
const isNarrow = width < 80
return (
<box flexDirection={isNarrow ? "column" : "row"}>
<box flexGrow={isNarrow ? 0 : 1} height={isNarrow ? 10 : "100%"}>
<text>Sidebar</text>
</box>
<box flexGrow={1}>
<text>Main Content</text>
</box>
</box>
)
}
```
### Dynamic Layouts
```tsx
function DynamicGrid({ items }: { items: string[] }) {
const { width } = useTerminalDimensions()
const columns = Math.max(1, Math.floor(width / 20))
return (
<box flexDirection="row" flexWrap="wrap">
{items.map((item, i) => (
<box key={i} width={`${100 / columns}%`} padding={1}>
<text>{item}</text>
</box>
))}
</box>
)
}
```
## Async Data Loading
### Loading States
```tsx
import { useState, useEffect } from "react"
function DataDisplay() {
const [data, setData] = useState<string[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function load() {
try {
const response = await fetch("https://api.example.com/data")
const json = await response.json()
setData(json.items)
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error")
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) {
return <text>Loading...</text>
}
if (error) {
return <text fg="red">Error: {error}</text>
}
return (
<box flexDirection="column">
{data?.map((item, i) => (
<text key={i}>{item}</text>
))}
</box>
)
}
```
## Animation Patterns
### Simple Animations
```tsx
import { useState, useEffect } from "react"
import { useTimeline } from "@opentui/react"
function ProgressBar() {
const [progress, setProgress] = useState(0)
const timeline = useTimeline({ duration: 3000 })
useEffect(() => {
timeline.add(
{ value: 0 },
{
value: 100,
duration: 3000,
ease: "linear",
onUpdate: (anim) => {
setProgress(Math.round(anim.targets[0].value))
},
}
)
}, [])
return (
<box flexDirection="column" gap={1}>
<text>Progress: {progress}%</text>
<box width={50} height={1} backgroundColor="#333">
<box
width={`${progress}%`}
height={1}
backgroundColor="#00ff00"
/>
</box>
</box>
)
}
```
### Interval-based Updates
```tsx
function Clock() {
const [time, setTime] = useState(new Date())
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
return () => clearInterval(interval)
}, [])
return <text>{time.toLocaleTimeString()}</text>
}
```
## Component Composition
### Render Props
```tsx
function Focusable({
children
}: {
children: (focused: boolean) => React.ReactNode
}) {
const [focused, setFocused] = useState(false)
return (
<box
onMouseDown={() => setFocused(true)}
onMouseUp={() => setFocused(false)}
>
{children(focused)}
</box>
)
}
// Usage
<Focusable>
{(focused) => (
<text fg={focused ? "#00ff00" : "#ffffff"}>
{focused ? "Focused!" : "Click me"}
</text>
)}
</Focusable>
```
### Higher-Order Components
```tsx
function withBorder<P extends object>(
Component: React.ComponentType<P>,
borderStyle: string = "single"
) {
return function BorderedComponent(props: P) {
return (
<box border borderStyle={borderStyle} padding={1}>
<Component {...props} />
</box>
)
}
}
// Usage
const BorderedText = withBorder(({ content }: { content: string }) => (
<text>{content}</text>
))
<BorderedText content="Hello!" />
```
references/react/REFERENCE.md
# OpenTUI React (@opentui/react)
A React reconciler for building terminal user interfaces with familiar React patterns. Write TUIs using JSX, hooks, and component composition.
## Overview
OpenTUI React provides:
- **Custom reconciler**: React components render to OpenTUI renderables
- **JSX intrinsics**: `<text>`, `<box>`, `<input>`, etc.
- **Hooks**: `useKeyboard`, `useRenderer`, `useTimeline`, etc.
- **Full React compatibility**: useState, useEffect, context, and more
## When to Use React
Use the React reconciler when:
- You're familiar with React patterns
- You want declarative UI composition
- You need React's ecosystem (context, state management libraries)
- Building applications with complex state
- Team knows React already
## When NOT to Use React
| Scenario | Use Instead |
|----------|-------------|
| Maximum performance critical | `@opentui/core` (imperative) |
| Fine-grained reactivity | `@opentui/solid` |
| Smallest bundle size | `@opentui/core` |
| Building a framework/library | `@opentui/core` |
## Quick Start
```bash
bunx create-tui@latest -t react my-app
cd my-app
bun run src/index.tsx
```
The 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.
Or manual setup:
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/react @opentui/core react
```
```tsx
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
import { useState } from "react"
function App() {
const [count, setCount] = useState(0)
return (
<box border padding={2}>
<text>Count: {count}</text>
<box
border
onMouseDown={() => setCount(c => c + 1)}
>
<text>Click me!</text>
</box>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
```
## Core Concepts
### JSX Elements
React maps JSX intrinsic elements to OpenTUI renderables:
```tsx
// These are not HTML elements!
<text>Hello</text> // TextRenderable
<box border>Content</box> // BoxRenderable
<input placeholder="..." /> // InputRenderable
<select options={[...]} /> // SelectRenderable
```
### Text Modifiers
Inside `<text>`, use modifier elements:
```tsx
<text>
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
<span fg="red">Colored text</span>
<br />
New line with <a href="https://example.com">link</a>
</text>
```
### Styling
Two approaches to styling:
```tsx
// Direct props
<box backgroundColor="blue" padding={2} border>
<text fg="#00FF00">Green text</text>
</box>
// Style prop
<box style={{ backgroundColor: "blue", padding: 2, border: true }}>
<text style={{ fg: "#00FF00" }}>Green text</text>
</box>
```
## Available Components
### Layout & Display
- `<text>` - Styled text content
- `<box>` - Container with borders and layout
- `<scrollbox>` - Scrollable container
- `<ascii-font>` - ASCII art text
### Input
- `<input>` - Single-line text input
- `<textarea>` - Multi-line text input
- `<select>` - List selection
- `<tab-select>` - Tab-based selection
### Code & Diff
- `<code>` - Syntax-highlighted code
- `<line-number>` - Code with line numbers
- `<diff>` - Unified or split diff viewer
### Text Modifiers (inside `<text>`)
- `<span>` - Inline styled text
- `<strong>`, `<b>` - Bold
- `<em>`, `<i>` - Italic
- `<u>` - Underline
- `<br>` - Line break
- `<a>` - Link
## Essential Hooks
```tsx
import {
useRenderer,
useKeyboard,
useOnResize,
useTerminalDimensions,
useTimeline,
} from "@opentui/react"
```
See [API Reference](./api.md) for detailed hook documentation.
## In This Reference
- [Configuration](./configuration.md) - Project setup, tsconfig, bundling
- [API](./api.md) - Components, hooks, createRoot
- [Patterns](./patterns.md) - State management, keyboard handling, forms
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
## See Also
- [Core](../core/REFERENCE.md) - Underlying imperative API
- [Solid](../solid/REFERENCE.md) - Alternative declarative approach
- [Components](../components/REFERENCE.md) - Component reference by category
- [Layout](../layout/REFERENCE.md) - Flexbox layout system
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
references/solid/api.md
# Solid API Reference
## Rendering
### render(node, rendererOrConfig?)
Renders a Solid component tree into a CLI renderer.
```tsx
import { render } from "@opentui/solid"
// Simple usage - creates renderer automatically
render(() => <App />)
// With config
render(() => <App />, {
exitOnCtrlC: false,
targetFPS: 60,
})
// With existing renderer
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
render(() => <App />, renderer)
```
### testRender(node, options?)
Create a test renderer for snapshots and tests.
```tsx
import { testRender } from "@opentui/solid"
const testSetup = await testRender(() => <App />, {
width: 40,
height: 10,
})
// Access test utilities
testSetup.snapshot() // Get current render
testSetup.renderer // Access renderer
```
### extend(components)
Register custom renderables as JSX intrinsic elements.
```tsx
import { extend } from "@opentui/solid"
import { CustomRenderable } from "./custom"
extend({
custom: CustomRenderable,
})
// Now usable in JSX
<custom prop="value" />
```
### getComponentCatalogue()
Returns the current component catalogue.
```tsx
import { getComponentCatalogue } from "@opentui/solid"
const catalogue = getComponentCatalogue()
console.log(Object.keys(catalogue))
```
## Hooks
### useRenderer()
Access the OpenTUI renderer instance.
```tsx
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
function App() {
const renderer = useRenderer()
onMount(() => {
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
renderer.console.show()
// Access theme mode (dark/light based on terminal settings)
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
})
return <text>Hello</text>
}
// Listen for theme mode changes
function ThemedApp() {
const renderer = useRenderer()
const [theme, setTheme] = createSignal(renderer.themeMode ?? "dark")
onMount(() => {
renderer.on("theme_mode", (mode: "dark" | "light") => setTheme(mode))
})
return (
<box backgroundColor={theme() === "dark" ? "#1a1a2e" : "#ffffff"}>
<text fg={theme() === "dark" ? "#fff" : "#000"}>
Current theme: {theme()}
</text>
</box>
)
}
```
### useKeyboard(handler, options?)
Handle keyboard events.
```tsx
import { useKeyboard, useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // Never use process.exit() directly!
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})
return <text>Press ESC to exit</text>
}
// With release events
function GameControls() {
const [pressed, setPressed] = createSignal(new Set<string>())
useKeyboard(
(event) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true }
)
return <text>Pressed: {Array.from(pressed()).join(", ")}</text>
}
```
### usePaste(handler)
Handle paste events. Receives a `PasteEvent` with raw bytes.
```tsx
import { usePaste } from "@opentui/solid"
import { decodePasteBytes } from "@opentui/core"
function PasteHandler() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
return <text>Paste something</text>
}
```
### onResize(callback)
Handle terminal resize events.
```tsx
import { onResize } from "@opentui/solid"
function App() {
onResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize the terminal</text>
}
```
### useTerminalDimensions()
Get reactive terminal dimensions.
```tsx
import { useTerminalDimensions } from "@opentui/solid"
function ResponsiveLayout() {
const dimensions = useTerminalDimensions()
return (
<box flexDirection={dimensions().width > 80 ? "row" : "column"}>
<text>Width: {dimensions().width}</text>
<text>Height: {dimensions().height}</text>
</box>
)
}
```
### onFocus(callback) / onBlur(callback)
Handle terminal window focus and blur events. Solid-only hooks.
```tsx
import { onFocus, onBlur } from "@opentui/solid"
function App() {
onFocus(() => {
console.log("Terminal window gained focus")
})
onBlur(() => {
console.log("Terminal window lost focus")
})
return <text>Focus/blur tracking</text>
}
```
These hooks fire when the terminal emulator window gains or loses operating system focus. The renderer deduplicates events (won't re-emit the same focus state).
### useSelectionHandler(handler)
Handle text selection events. Fires when the user finishes a mouse selection (mouse-up). Solid-only hook - React does not have this.
```tsx
import { useSelectionHandler } from "@opentui/solid"
import type { Selection } from "@opentui/core"
function SelectableText() {
const [selected, setSelected] = createSignal("")
const renderer = useRenderer()
useSelectionHandler((selection: Selection) => {
const text = selection.getSelectedText()
if (text) {
setSelected(text)
renderer.copyToClipboardOSC52(text)
}
})
return (
<box flexDirection="column">
<text selectable>Select this text with your mouse</text>
<text fg="#888">Selected: {selected()}</text>
</box>
)
}
```
The `Selection` object aggregates selected text from all selectable renderables in the tree. See `keyboard/REFERENCE.md` (selection) for full details on the selection API and traversal model.
### useTimeline(options?)
Create animations with the timeline system.
```tsx
import { useTimeline } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
function AnimatedBox() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
onMount(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
})
return <box style={{ width: width(), height: 3, backgroundColor: "#6a5acd" }} />
}
```
## Components
Full props for every component live in the shared **[components](../components/REFERENCE.md)**
references. This section only covers what is **Solid-specific**; read the linked
category file for the complete prop list.
### JSX Element Names (Solid uses underscores)
Multi-word elements use **underscores** (not hyphens like React):
| Element | Full props |
|---------|-----------|
| `<text>`, `<span>`, `<strong>`, `<em>`, `<u>`, `<a>`, `<br>` | [text-display.md](../components/text-display.md) |
| `<box>`, `<scrollbox>` | [containers.md](../components/containers.md) |
| `<input>`, `<textarea>`, `<select>`, `<tab_select>` | [inputs.md](../components/inputs.md) |
| `<code>`, `<line_number>`, `<diff>`, `<markdown>` | [code-diff.md](../components/code-diff.md) |
| `<ascii_font>` | [text-display.md](../components/text-display.md) |
### Text Styling Uses Nested Tags (Solid-specific)
```tsx
<text fg="#FFFFFF" bg="#000000" selectable>
<span fg="red">Red</span> <strong>Bold</strong> <em>Italic</em> <u>Underline</u>
</text>
```
> **Note**: Do NOT use `bold`, `italic`, `underline` as props on `<text>`. Use
> nested modifier tags like `<strong>`, `<em>`, `<u>` instead.
### Reactive Values + `onInput` (Solid-specific)
Solid uses **`onInput`** (not React's `onChange`) for text inputs, and values are
signals. Use `<For>` inside `<scrollbox>` for lists:
```tsx
<input value={value()} onInput={setValue} focused />
<textarea value={text()} onInput={setText} focused width={40} height={10} />
// select/tab_select still use onChange (navigate) / onSelect (Enter)
<select options={opts} onChange={(i, opt) => setSelected(opt)} focused />
<scrollbox focused>
<For each={items()}>{(item) => <text>{item}</text>}</For>
</scrollbox>
```
Scrollbox takes the same nested `style` object as React — see
[containers.md](../components/containers.md).
## Control Flow
Solid's control flow components work with OpenTUI:
### For
```tsx
import { For } from "solid-js"
<For each={items()}>
{(item, index) => (
<box key={index()}>
<text>{item.name}</text>
</box>
)}
</For>
```
### Show
```tsx
import { Show } from "solid-js"
<Show when={isVisible()} fallback={<text>Hidden</text>}>
<text>Visible content</text>
</Show>
```
### Switch/Match
```tsx
import { Switch, Match } from "solid-js"
<Switch>
<Match when={status() === "loading"}>
<text>Loading...</text>
</Match>
<Match when={status() === "error"}>
<text fg="red">Error!</text>
</Match>
<Match when={status() === "success"}>
<text fg="green">Success!</text>
</Match>
</Switch>
```
### Index
```tsx
import { Index } from "solid-js"
<Index each={items()}>
{(item, index) => (
<text>{index}: {item().name}</text>
)}
</Index>
```
## Special Components
### Portal
```tsx
import { Portal } from "@opentui/solid"
<Portal mount={targetNode}>
<box>Portal content</box>
</Portal>
```
### Dynamic
```tsx
import { Dynamic } from "@opentui/solid"
<Dynamic
component={isMultiline() ? "textarea" : "input"}
placeholder="Enter text..."
focused
/>
```
references/solid/configuration.md
# Solid Configuration
## Project Setup
### Quick Start
```bash
bunx create-tui@latest -t solid my-app
cd my-app && bun install
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
### Manual Setup
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/solid @opentui/core solid-js
```
## TypeScript Configuration
### tsconfig.json
```json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
```
**Critical settings:**
- `jsx: "preserve"` - Let Solid's compiler handle JSX
- `jsxImportSource: "@opentui/solid"` - Import JSX runtime from OpenTUI Solid
- `module` / `moduleResolution: "NodeNext"` - Recommended for OpenTUI compatibility
## Bun Configuration
### bunfig.toml
**Required** for the Solid compiler:
```toml
preload = ["@opentui/solid/preload"]
```
This loads the Solid JSX transform before your code runs.
## Package Configuration
### package.json
```json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.tsx",
"dev": "bun --watch run src/index.tsx",
"test": "bun test",
"build": "bun run build.ts"
},
"dependencies": {
"@opentui/core": "latest",
"@opentui/solid": "latest",
"solid-js": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "latest"
}
}
```
## Project Structure
Recommended structure:
```
my-tui-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── MainContent.tsx
│ ├── stores/
│ │ └── appStore.ts
│ ├── App.tsx
│ └── index.tsx
├── bunfig.toml # Required!
├── package.json
└── tsconfig.json
```
### Entry Point (src/index.tsx)
```tsx
import { render } from "@opentui/solid"
import { App } from "./App"
render(() => <App />)
```
### App Component (src/App.tsx)
```tsx
import { Header } from "./components/Header"
import { Sidebar } from "./components/Sidebar"
import { MainContent } from "./components/MainContent"
export function App() {
return (
<box flexDirection="column" width="100%" height="100%">
<Header />
<box flexDirection="row" flexGrow={1}>
<Sidebar />
<MainContent />
</box>
</box>
)
}
```
## Renderer Configuration
### render() Options
```tsx
import { render } from "@opentui/solid"
import { ConsolePosition } from "@opentui/core"
render(() => <App />, {
// Rendering
targetFPS: 60,
// Behavior
exitOnCtrlC: true,
autoFocus: true, // Auto-focus elements on click (default: true)
useMouse: true, // Enable mouse support (default: true)
// Debug console
consoleOptions: {
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
// Cleanup
onDestroy: () => {
// Cleanup code
},
})
```
### Using Existing Renderer
```tsx
import { render } from "@opentui/solid"
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({
exitOnCtrlC: false,
})
render(() => <App />, renderer)
```
## Building for Distribution
### Build Script (build.ts)
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
plugins: [solidPlugin],
})
console.log("Build complete!")
```
Run: `bun run build.ts`
### Creating Executables
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./src/index.tsx"],
target: "bun",
plugins: [solidPlugin],
compile: {
target: "bun-darwin-arm64", // or bun-linux-x64, etc.
outfile: "my-app",
},
})
```
**Available targets:**
- `bun-darwin-arm64` - macOS Apple Silicon
- `bun-darwin-x64` - macOS Intel
- `bun-linux-x64` - Linux x64
- `bun-linux-arm64` - Linux ARM64
- `bun-windows-x64` - Windows x64
## Environment Variables
Create `.env` for development:
```env
# Debug settings
OTUI_SHOW_STATS=false
SHOW_CONSOLE=false
# App settings
API_URL=https://api.example.com
```
Bun auto-loads `.env` files:
```tsx
const apiUrl = process.env.API_URL
```
## Testing Configuration
### Test Setup
```typescript
// src/test-utils.tsx
import { testRender } from "@opentui/solid"
export async function renderForTest(
Component: () => JSX.Element,
options = { width: 80, height: 24 }
) {
return await testRender(Component, options)
}
```
### Test Example
```typescript
// src/components/Counter.test.tsx
import { test, expect } from "bun:test"
import { renderForTest } from "../test-utils"
import { Counter } from "./Counter"
test("Counter renders initial value", async () => {
const { snapshot } = await renderForTest(() => <Counter initialValue={5} />)
expect(snapshot()).toContain("Count: 5")
})
```
## Common Configuration Issues
### Missing bunfig.toml
**Symptom**: JSX not transformed, syntax errors
**Fix**: Create `bunfig.toml` with preload:
```toml
preload = ["@opentui/solid/preload"]
```
### Wrong JSX Settings
**Symptom**: JSX compiles to React calls
**Fix**: Ensure tsconfig has:
```json
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}
```
### Build Missing Plugin
**Symptom**: Built output has untransformed JSX
**Fix**: Add Solid plugin to build:
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
// ...
plugins: [solidPlugin],
})
```
references/solid/gotchas.md
# Solid Gotchas
## Critical
### Never use `process.exit()` directly
**This is the most common mistake.** Using `process.exit()` leaves the terminal in a broken state (cursor hidden, raw mode, alternate screen).
```tsx
// WRONG - Terminal left in broken state
process.exit(0)
// CORRECT - Use renderer.destroy()
import { useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
const handleExit = () => {
renderer.destroy() // Cleans up and exits properly
}
}
```
`renderer.destroy()` restores the terminal (exits alternate screen, restores cursor, etc.) before exiting.
## Configuration Issues
### Missing bunfig.toml
**Symptom**: JSX syntax errors, components not rendering
```
SyntaxError: Unexpected token '<'
```
**Fix**: Create `bunfig.toml` in project root:
```toml
preload = ["@opentui/solid/preload"]
```
### Wrong JSX Settings
**Symptom**: JSX compiles to React, errors about React not found
**Fix**: Ensure tsconfig.json has:
```json
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}
```
### Build Without Plugin
**Symptom**: Built bundle has raw JSX
**Fix**: Add Solid plugin to build:
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
// ...
plugins: [solidPlugin],
})
```
## Reactivity Issues
### Accessing Signals Without Calling
**Symptom**: Value never updates, shows `[Function]`
```tsx
// WRONG - Missing ()
const [count, setCount] = createSignal(0)
<text>Count: {count}</text> // Shows [Function]
// CORRECT
<text>Count: {count()}</text>
```
### Breaking Reactivity with Destructuring
**Symptom**: Props stop being reactive
```tsx
// WRONG - Breaks reactivity
function Component(props: { value: number }) {
const { value } = props // Destructured once, never updates!
return <text>{value}</text>
}
// CORRECT - Keep props reactive
function Component(props: { value: number }) {
return <text>{props.value}</text>
}
// OR use splitProps
function Component(props: { value: number; other: string }) {
const [local, rest] = splitProps(props, ["value"])
return <text>{local.value}</text>
}
```
### Effects Not Running
**Symptom**: createEffect doesn't trigger
```tsx
// WRONG - Signal not accessed in effect
const [count, setCount] = createSignal(0)
createEffect(() => {
console.log("Count changed") // Never runs after initial!
})
// CORRECT - Access the signal
createEffect(() => {
console.log("Count:", count()) // Runs when count changes
})
```
## HTML Entity Decoding
Solid's reconciler automatically decodes HTML entities in JSX text content. This means `<`, `>`, `&`, etc. render as their literal characters:
```tsx
// These render correctly in Solid
<text>Use <box> for containers</text> // Displays: Use <box> for containers
<text>A & B</text> // Displays: A & B
```
This applies to text nodes, the `content` prop, and the `text` prop.
## Component Naming
### Underscore vs Hyphen
Solid uses underscores for multi-word component names:
```tsx
// WRONG - React-style naming
<tab-select /> // Error!
<ascii-font /> // Error!
<line-number /> // Error!
// CORRECT - Solid naming
<tab_select />
<ascii_font />
<line_number />
```
**Component mapping:**
| Concept | React | Solid |
|---------|-------|-------|
| Tab Select | `<tab-select>` | `<tab_select>` |
| ASCII Font | `<ascii-font>` | `<ascii_font>` |
| Line Number | `<line-number>` | `<line_number>` |
## Focus Issues
### Focus Not Working
Components need explicit focus:
```tsx
// WRONG
<input placeholder="Type here..." />
// CORRECT
<input placeholder="Type here..." focused />
```
### Select Not Responding
```tsx
// WRONG
<select options={["a", "b"]} />
// CORRECT
<select
options={[
{ name: "A", description: "Option A", value: "a" },
{ name: "B", description: "Option B", value: "b" },
]}
onSelect={(index, option) => {
// Called when Enter is pressed
console.log("Selected:", option.name)
}}
focused
/>
```
### Select Events Confusion
Remember: `onSelect` fires on Enter (selection confirmed), `onChange` fires on navigation:
```tsx
// WRONG - expecting onChange to fire on Enter
<select
options={options()}
onChange={(i, opt) => submitForm(opt)} // This fires on arrow keys!
/>
// CORRECT
<select
options={options()}
onSelect={(i, opt) => submitForm(opt)} // Enter pressed - submit
onChange={(i, opt) => showPreview(opt)} // Arrow keys - preview
/>
```
## Control Flow Issues
### For vs Index
Use `For` for arrays of objects, `Index` for primitives:
```tsx
// For objects - item is reactive
<For each={objects()}>
{(obj) => <text>{obj.name}</text>}
</For>
// For primitives - use Index, item() is reactive
<Index each={strings()}>
{(str, index) => <text>{index}: {str()}</text>}
</Index>
```
### Missing Fallback
Show requires fallback for proper rendering:
```tsx
// May cause issues
<Show when={data()}>
<Component />
</Show>
// Better - explicit fallback
<Show when={data()} fallback={<text>Loading...</text>}>
<Component />
</Show>
```
## Cleanup Issues
### Forgetting onCleanup
**Symptom**: Memory leaks, multiple intervals running
```tsx
// WRONG - Interval never cleared
function Timer() {
const [time, setTime] = createSignal(0)
setInterval(() => setTime(t => t + 1), 1000)
return <text>{time()}</text>
}
// CORRECT
function Timer() {
const [time, setTime] = createSignal(0)
const interval = setInterval(() => setTime(t => t + 1), 1000)
onCleanup(() => clearInterval(interval))
return <text>{time()}</text>
}
```
### Effect Cleanup
```tsx
createEffect(() => {
const subscription = subscribe(data())
// WRONG - No cleanup
// subscription stays active
// CORRECT
onCleanup(() => subscription.unsubscribe())
})
```
## Store Issues
### Mutating Store Directly
**Symptom**: Changes don't trigger updates
```tsx
const [state, setState] = createStore({ items: [] })
// WRONG - Direct mutation
state.items.push(newItem) // Won't trigger updates!
// CORRECT - Use setState
setState("items", items => [...items, newItem])
```
### Nested Updates
```tsx
const [state, setState] = createStore({
user: { profile: { name: "John" } }
})
// WRONG
state.user.profile.name = "Jane"
// CORRECT
setState("user", "profile", "name", "Jane")
```
## Debugging
### Console Not Visible
OpenTUI captures console output:
```tsx
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
function App() {
const renderer = useRenderer()
onMount(() => {
renderer.console.show()
console.log("Now visible!")
})
return <box>{/* ... */}</box>
}
```
### Tracking Reactivity
Use `createEffect` to debug:
```tsx
createEffect(() => {
console.log("State:", {
count: count(),
items: items(),
})
})
```
## Runtime Issues
### Use Bun
```bash
# WRONG
node src/index.tsx
npm run start
# CORRECT
bun run src/index.tsx
bun run start
```
### Async render()
The render function is async when creating a renderer:
```tsx
// This is fine - Bun supports top-level await
render(() => <App />)
// If you need the renderer
import { createCliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
const renderer = await createCliRenderer()
render(() => <App />, renderer)
```
## Common Error Messages
### "Cannot read properties of undefined"
Usually a missing reactive access:
```tsx
// Check if signal is being called
<text>{count()}</text> // Note the ()
// Check if props are being accessed correctly
<text>{props.value}</text> // Not destructured
```
### "JSX element has no corresponding closing tag"
Check component naming:
```tsx
// Wrong
<tab-select></tab-select>
// Correct
<tab_select></tab_select>
```
### "store is not a function"
Stores aren't called like signals:
```tsx
const [store, setStore] = createStore({ count: 0 })
// WRONG
<text>{store().count}</text>
// CORRECT
<text>{store.count}</text>
```
references/solid/patterns.md
# Solid Patterns
## Reactive State
### Signals
Basic reactive state with signals:
```tsx
import { createSignal } from "solid-js"
function Counter() {
const [count, setCount] = createSignal(0)
return (
<box flexDirection="row" gap={2}>
<text>Count: {count()}</text>
<box border onMouseDown={() => setCount(c => c - 1)}>
<text>-</text>
</box>
<box border onMouseDown={() => setCount(c => c + 1)}>
<text>+</text>
</box>
</box>
)
}
```
### Derived State
Compute values from signals:
```tsx
import { createSignal, createMemo } from "solid-js"
function PriceCalculator() {
const [quantity, setQuantity] = createSignal(1)
const [price, setPrice] = createSignal(9.99)
// Derived value - only recalculates when dependencies change
const total = createMemo(() => quantity() * price())
const formatted = createMemo(() => `$${total().toFixed(2)}`)
return (
<box flexDirection="column">
<text>Quantity: {quantity()}</text>
<text>Price: ${price()}</text>
<text>Total: {formatted()}</text>
</box>
)
}
```
### Effects
React to state changes:
```tsx
import { createSignal, createEffect, onCleanup } from "solid-js"
function AutoSave() {
const [content, setContent] = createSignal("")
createEffect(() => {
const text = content()
// Debounced save
const timeout = setTimeout(() => {
saveToFile(text)
}, 1000)
// Cleanup on next run or disposal
onCleanup(() => clearTimeout(timeout))
})
return (
<textarea
value={content()}
onInput={setContent}
placeholder="Auto-saves after 1 second..."
/>
)
}
```
## Stores
### createStore for Complex State
```tsx
import { createStore } from "solid-js/store"
interface AppState {
user: { name: string; email: string } | null
items: Array<{ id: number; name: string; done: boolean }>
settings: { theme: "dark" | "light" }
}
function App() {
const [state, setState] = createStore<AppState>({
user: null,
items: [],
settings: { theme: "dark" },
})
const addItem = (name: string) => {
setState("items", items => [
...items,
{ id: Date.now(), name, done: false }
])
}
const toggleItem = (id: number) => {
setState("items", item => item.id === id, "done", done => !done)
}
const setTheme = (theme: "dark" | "light") => {
setState("settings", "theme", theme)
}
return (
<box backgroundColor={state.settings.theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
<For each={state.items}>
{(item) => (
<text
fg={item.done ? "#888" : "#fff"}
onMouseDown={() => toggleItem(item.id)}
>
{item.done ? "[x]" : "[ ]"} {item.name}
</text>
)}
</For>
</box>
)
}
```
### Store with Context
Share state across components:
```tsx
import { createStore } from "solid-js/store"
import { createContext, useContext, ParentComponent } from "solid-js"
interface Store {
count: number
items: string[]
}
type StoreContextValue = [
Store,
{
increment: () => void
addItem: (item: string) => void
}
]
const StoreContext = createContext<StoreContextValue>()
const StoreProvider: ParentComponent = (props) => {
const [state, setState] = createStore<Store>({
count: 0,
items: [],
})
const actions = {
increment: () => setState("count", c => c + 1),
addItem: (item: string) => setState("items", i => [...i, item]),
}
return (
<StoreContext.Provider value={[state, actions]}>
{props.children}
</StoreContext.Provider>
)
}
function useStore() {
const context = useContext(StoreContext)
if (!context) throw new Error("useStore must be used within StoreProvider")
return context
}
// Usage
function Counter() {
const [state, { increment }] = useStore()
return (
<box onMouseDown={increment}>
<text>Count: {state.count}</text>
</box>
)
}
```
## Control Flow
### Conditional Rendering with Show
```tsx
import { Show, createSignal } from "solid-js"
function ToggleableContent() {
const [visible, setVisible] = createSignal(false)
return (
<box flexDirection="column">
<box border onMouseDown={() => setVisible(v => !v)}>
<text>Toggle</text>
</box>
<Show
when={visible()}
fallback={<text fg="#888">Content is hidden</text>}
>
<text fg="#0f0">Content is visible!</text>
</Show>
</box>
)
}
```
### Lists with For
```tsx
import { For, createSignal } from "solid-js"
function TodoList() {
const [todos, setTodos] = createSignal([
{ id: 1, text: "Learn Solid", done: false },
{ id: 2, text: "Build TUI", done: false },
])
const toggle = (id: number) => {
setTodos(todos =>
todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
)
)
}
return (
<box flexDirection="column">
<For each={todos()}>
{(todo) => (
<box onMouseDown={() => toggle(todo.id)}>
<text fg={todo.done ? "#888" : "#fff"}>
{todo.done ? "[x]" : "[ ]"} {todo.text}
</text>
</box>
)}
</For>
</box>
)
}
```
### Index for Primitive Arrays
Use `Index` when array items are primitives:
```tsx
import { Index, createSignal } from "solid-js"
function StringList() {
const [items, setItems] = createSignal(["apple", "banana", "cherry"])
return (
<box flexDirection="column">
<Index each={items()}>
{(item, index) => (
<text>{index}: {item()}</text>
)}
</Index>
</box>
)
}
```
### Switch/Match for Multiple Conditions
```tsx
import { Switch, Match, createSignal } from "solid-js"
type Status = "idle" | "loading" | "success" | "error"
function StatusDisplay() {
const [status, setStatus] = createSignal<Status>("idle")
return (
<Switch>
<Match when={status() === "idle"}>
<text>Ready</text>
</Match>
<Match when={status() === "loading"}>
<text fg="#ff0">Loading...</text>
</Match>
<Match when={status() === "success"}>
<text fg="#0f0">Success!</text>
</Match>
<Match when={status() === "error"}>
<text fg="#f00">Error occurred</text>
</Match>
</Switch>
)
}
```
## Focus Management
### Focus State
```tsx
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
function FocusableForm() {
const [focusIndex, setFocusIndex] = createSignal(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
if (key.shift && key.name === "tab") {
setFocusIndex(i => (i - 1 + fields.length) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
<Index each={fields}>
{(field, i) => (
<input
placeholder={`Enter ${field()}...`}
focused={i === focusIndex()}
/>
)}
</Index>
</box>
)
}
```
## Keyboard Navigation
### Global Shortcuts
```tsx
import { useKeyboard } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // Never use process.exit() directly!
}
if (key.ctrl && key.name === "s") {
save()
}
// Vim-style
if (key.name === "j") moveDown()
if (key.name === "k") moveUp()
})
return <box>{/* ... */}</box>
}
```
## Responsive Design
### Terminal-size Responsive
```tsx
import { useTerminalDimensions } from "@opentui/solid"
function ResponsiveLayout() {
const dims = useTerminalDimensions()
return (
<box flexDirection={dims().width > 80 ? "row" : "column"}>
<box flexGrow={1}>
<text>Panel 1</text>
</box>
<box flexGrow={1}>
<text>Panel 2</text>
</box>
</box>
)
}
```
## Async Data
### Resources
```tsx
import { createResource, Suspense } from "solid-js"
async function fetchData() {
const response = await fetch("https://api.example.com/data")
return response.json()
}
function DataDisplay() {
const [data] = createResource(fetchData)
return (
<Suspense fallback={<text>Loading...</text>}>
<Show when={data()}>
{(items) => (
<For each={items()}>
{(item) => <text>{item.name}</text>}
</For>
)}
</Show>
</Suspense>
)
}
```
### Error Handling
```tsx
import { createResource, Show, ErrorBoundary } from "solid-js"
function SafeDataDisplay() {
const [data] = createResource(fetchData)
return (
<ErrorBoundary fallback={(err) => <text fg="red">Error: {err.message}</text>}>
<Show
when={!data.loading}
fallback={<text>Loading...</text>}
>
<Show
when={!data.error}
fallback={<text fg="red">Failed to load</text>}
>
<For each={data()}>
{(item) => <text>{item.name}</text>}
</For>
</Show>
</Show>
</ErrorBoundary>
)
}
```
## Component Composition
### Props and Children
```tsx
import { ParentComponent, JSX } from "solid-js"
interface PanelProps {
title: string
children: JSX.Element
}
const Panel: ParentComponent<{ title: string }> = (props) => {
return (
<box border padding={1} flexDirection="column">
<text fg="#0ff">{props.title}</text>
<box marginTop={1}>
{props.children}
</box>
</box>
)
}
// Usage
<Panel title="Settings">
<text>Panel content here</text>
</Panel>
```
### Spread Props
```tsx
import { splitProps } from "solid-js"
interface ButtonProps {
label: string
onClick: () => void
// ...rest goes to box
}
function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, ["label", "onClick"])
return (
<box border onMouseDown={local.onClick} {...rest}>
<text>{local.label}</text>
</box>
)
}
```
## Animation
### With Timeline
```tsx
import { createSignal, onMount } from "solid-js"
import { useTimeline } from "@opentui/solid"
function AnimatedProgress() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
})
onMount(() => {
timeline.add(
{ value: 0 },
{
value: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].value))
},
}
)
})
return (
<box flexDirection="column" gap={1}>
<text>Progress: {width()}%</text>
<box width={50} height={1} backgroundColor="#333">
<box width={width()} height={1} backgroundColor="#0f0" />
</box>
</box>
)
}
```
### Interval-based
```tsx
import { createSignal, onCleanup } from "solid-js"
function Clock() {
const [time, setTime] = createSignal(new Date())
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
onCleanup(() => clearInterval(interval))
return <text>{time().toLocaleTimeString()}</text>
}
```
references/solid/REFERENCE.md
# OpenTUI Solid (@opentui/solid)
A SolidJS reconciler for building terminal user interfaces with fine-grained reactivity. Get optimal performance with Solid's signal-based approach.
## Overview
OpenTUI Solid provides:
- **Custom reconciler**: Solid components render to OpenTUI renderables
- **JSX intrinsics**: `<text>`, `<box>`, `<input>`, etc.
- **Hooks**: `useKeyboard`, `useRenderer`, `useTimeline`, etc.
- **Fine-grained reactivity**: Only what changes re-renders
- **Portal & Dynamic**: Advanced composition primitives
## When to Use Solid
Use the Solid reconciler when:
- You want optimal re-rendering performance
- You prefer signal-based reactivity
- You need fine-grained control over updates
- Building performance-critical applications
- You already know SolidJS
## When NOT to Use Solid
| Scenario | Use Instead |
|----------|-------------|
| Team knows React, not Solid | `@opentui/react` |
| Maximum control needed | `@opentui/core` |
| Smallest bundle size | `@opentui/core` |
| Building a framework/library | `@opentui/core` |
## Quick Start
```bash
bunx create-tui@latest -t solid my-app
cd my-app && bun install
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
**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.
Or manually:
```bash
bun install @opentui/solid @opentui/core solid-js
```
```tsx
import { render } from "@opentui/solid"
import { createSignal } from "solid-js"
function App() {
const [count, setCount] = createSignal(0)
return (
<box border padding={2}>
<text>Count: {count()}</text>
<box
border
onMouseDown={() => setCount(c => c + 1)}
>
<text>Click me!</text>
</box>
</box>
)
}
render(() => <App />)
```
## Core Concepts
### Signals
Solid uses signals for reactive state:
```tsx
import { createSignal, createEffect } from "solid-js"
function Counter() {
const [count, setCount] = createSignal(0)
// Effect runs when count changes
createEffect(() => {
console.log("Count is now:", count())
})
return <text>Count: {count()}</text>
}
```
### JSX Elements
Solid maps JSX intrinsic elements to OpenTUI renderables:
```tsx
// Note: Some use underscores (Solid convention)
<text>Hello</text> // TextRenderable
<box border>Content</box> // BoxRenderable
<input placeholder="..." /> // InputRenderable
<select options={[...]} /> // SelectRenderable
<tab_select /> // TabSelectRenderable (underscore!)
<ascii_font /> // ASCIIFontRenderable (underscore!)
<line_number /> // LineNumberRenderable (underscore!)
```
### Text Modifiers
Inside `<text>`, use modifier elements:
```tsx
<text>
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
<span fg="red">Colored text</span>
<br />
New line with <a href="https://example.com">link</a>
</text>
```
## Available Components
### Layout & Display
- `<text>` - Styled text content
- `<box>` - Container with borders and layout
- `<scrollbox>` - Scrollable container
- `<ascii_font>` - ASCII art text (note underscore)
### Input
- `<input>` - Single-line text input
- `<textarea>` - Multi-line text input
- `<select>` - List selection
- `<tab_select>` - Tab-based selection (note underscore)
### Code & Diff
- `<code>` - Syntax-highlighted code
- `<line_number>` - Code with line numbers (note underscore)
- `<diff>` - Unified or split diff viewer
### Text Modifiers (inside `<text>`)
- `<span>` - Inline styled text
- `<strong>`, `<b>` - Bold
- `<em>`, `<i>` - Italic
- `<u>` - Underline
- `<br>` - Line break
- `<a>` - Link
## Special Components
### Portal
Render children to a different mount node:
```tsx
import { Portal } from "@opentui/solid"
function Overlay() {
return (
<Portal mount={renderer.root}>
<box position="absolute" left={10} top={5} border>
<text>Overlay content</text>
</box>
</Portal>
)
}
```
### Dynamic
Render components dynamically:
```tsx
import { Dynamic } from "@opentui/solid"
function DynamicInput(props: { multiline: boolean }) {
return (
<Dynamic
component={props.multiline ? "textarea" : "input"}
placeholder="Enter text..."
/>
)
}
```
## In This Reference
- [Configuration](./configuration.md) - Project setup, tsconfig, bunfig, building
- [API](./api.md) - Components, hooks, render function
- [Patterns](./patterns.md) - Signals, stores, control flow, composition
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
## See Also
- [Core](../core/REFERENCE.md) - Underlying imperative API
- [React](../react/REFERENCE.md) - Alternative declarative approach
- [Components](../components/REFERENCE.md) - Component reference by category
- [Layout](../layout/REFERENCE.md) - Flexbox layout system
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
references/testing/REFERENCE.md
# Testing OpenTUI Applications
How to test terminal user interfaces built with OpenTUI.
## Overview
OpenTUI provides:
- **Test Renderer**: Headless renderer for testing
- **Snapshot Testing**: Verify visual output
- **Interaction Testing**: Simulate user input
## When to Use
Use this reference when you need snapshot tests, interaction testing, or renderer-based regression checks.
## Test Setup
### Bun Test Runner
OpenTUI uses Bun's built-in test runner:
```typescript
import { test, expect, beforeEach, afterEach } from "bun:test"
```
### Test Renderer
Create a test renderer for headless testing:
```typescript
import { createTestRenderer } from "@opentui/core/testing"
const testSetup = await createTestRenderer({
width: 80, // Terminal width
height: 24, // Terminal height
})
```
## Core Testing
### Basic Test
```typescript
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { TextRenderable } from "@opentui/core"
test("renders text", async () => {
const testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const text = new TextRenderable(testSetup.renderer, {
id: "greeting",
content: "Hello, World!",
})
testSetup.renderer.root.add(text)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Hello, World!")
})
```
### Snapshot Testing
```typescript
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { BoxRenderable, TextRenderable } from "@opentui/core"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const box = new BoxRenderable(testSetup.renderer, {
id: "box",
border: true,
width: 20,
height: 5,
})
box.add(new TextRenderable(testSetup.renderer, {
content: "Content",
}))
testSetup.renderer.root.add(box)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toMatchSnapshot()
})
```
### Test Renderer Setup Object
`createTestRenderer()` returns an expanded `TestRendererSetup` with helpers for
driving frames, input, and asserting on output:
| Property | Type | Description |
|----------|------|-------------|
| `renderer` | `TestRenderer` | Headless renderer instance |
| `mockInput` | `MockInput` | Simulate keyboard input |
| `mockMouse` | `MockMouse` | Simulate mouse events |
| `renderOnce()` | `() => Promise<void>` | Trigger a single render cycle |
| `flush(options?)` | `(o?: { maxPasses? }) => Promise<void>` | Drain scheduled render work (default 20 passes) |
| `waitFor(predicate, options?)` | `Promise<void>` | Advance frames until a boolean/async predicate passes |
| `waitForFrame(predicate, options?)` | `Promise<string>` | Like `waitFor`, predicate receives the frame string; resolves to it |
| `waitForVisualIdle(options?)` | `(o?: { quietFrames?, maxFrames? }) => Promise<void>` | Wait until rendering settles (defaults 1 / 20) |
| `captureCharFrame()` | `() => string` | Capture output as text |
| `captureSpans()` | `() => CapturedFrame` | Capture styled spans |
| `externalOutput` | `TestExternalOutput` | Capture scrollback / external output (`take()`, `takeText()`, `clear()`) |
| `getNativeStats()` | `() => NativeRenderStats` | Native render statistics |
| `resize(width, height)` | `(w, h) => void` | Resize the virtual terminal |
```typescript
import { createTestRenderer } from "@opentui/core/testing"
const t = await createTestRenderer({ width: 40, height: 10 })
// Wait for async content instead of guessing at renderOnce() timing
await t.waitForFrame((frame) => frame.includes("Loaded"))
// Or wait until the UI stops changing
await t.waitForVisualIdle()
// Assert on scrollback written above the viewport
const commits = t.externalOutput.take()
```
`TestRendererOptions` extends `CliRendererConfig` with `width`, `height`,
`kittyKeyboard`, and `otherModifiersMode`. Other exports from
`@opentui/core/testing`: `createMockKeys`, `KeyCodes`, `createMockMouse`,
`MouseButtons`, `createSpy`, `TestRecorder`, `ManualClock`,
`createTerminalCapabilities`, `setRendererCapabilities`,
`createMockTreeSitterClient`.
## React Testing
### Test Utilities
React provides a built-in `testRender` utility via the `@opentui/react/test-utils` subpath export:
```tsx
import { testRender } from "@opentui/react/test-utils"
```
This utility:
- Creates a headless test renderer
- Sets up the React Act environment automatically
- Handles proper unmounting on destroy
- Returns the standard test setup object
### Basic Component Test
```tsx
import { test, expect } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
function Greeting({ name }: { name: string }) {
return <text>Hello, {name}!</text>
}
test("Greeting renders name", async () => {
const testSetup = await testRender(
<Greeting name="World" />,
{ width: 80, height: 24 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Hello, World!")
})
```
### Snapshot Testing
```tsx
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await testRender(
<box style={{ width: 20, height: 5, border: true }}>
<text>Content</text>
</box>,
{ width: 25, height: 8 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})
```
### State Testing
```tsx
import { test, expect, afterEach } from "bun:test"
import { useState } from "react"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
function Counter() {
const [count, setCount] = useState(0)
return (
<box>
<text>Count: {count}</text>
</box>
)
}
test("Counter shows initial value", async () => {
testSetup = await testRender(
<Counter />,
{ width: 20, height: 5 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Count: 0")
})
```
### Test Setup/Teardown Pattern
For multiple tests, use beforeEach/afterEach to manage the renderer lifecycle:
```tsx
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
describe("MyComponent", () => {
beforeEach(async () => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("renders correctly", async () => {
testSetup = await testRender(<MyComponent />, {
width: 40,
height: 10,
})
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})
})
```
### Test Setup Return Object
The `testRender` function returns a test setup object with these properties:
| Property | Type | Description |
|----------|------|-------------|
| `renderer` | `Renderer` | The headless renderer instance |
| `renderOnce` | `() => Promise<void>` | Triggers a single render cycle |
| `captureCharFrame` | `() => string` | Captures current output as text |
| `resize` | `(width, height) => void` | Resize the virtual terminal |
## Solid Testing
### Test Utilities
Solid exports `testRender` directly from the main package:
```tsx
import { testRender } from "@opentui/solid"
```
Note: Unlike React, Solid's `testRender` takes a **function component** (not a JSX element).
### Basic Component Test
```tsx
import { test, expect } from "bun:test"
import { testRender } from "@opentui/solid"
function Greeting(props: { name: string }) {
return <text>Hello, {props.name}!</text>
}
test("Greeting renders name", async () => {
const testSetup = await testRender(
() => <Greeting name="World" />,
{ width: 80, height: 24 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Hello, World!")
})
```
### Snapshot Testing
```tsx
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/solid"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await testRender(
() => (
<box style={{ width: 20, height: 5, border: true }}>
<text>Content</text>
</box>
),
{ width: 25, height: 8 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})
```
## Snapshot Format
Snapshots capture the rendered terminal output as text:
```
┌──────────────────┐
│ Hello, World! │
│ │
└──────────────────┘
```
### Updating Snapshots
```bash
bun test --update-snapshots
```
## Interaction Testing
### Simulating Key Presses
```typescript
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("responds to keyboard", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
// Create component that responds to keys
// ...
// Simulate keypress
testSetup.renderer.keyInput.emit("keypress", {
name: "enter",
sequence: "\r",
ctrl: false,
shift: false,
meta: false,
option: false,
eventType: "press",
repeated: false,
})
// Render after the keypress
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Selected")
})
```
### Testing Focus
```typescript
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { InputRenderable } from "@opentui/core"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("input receives focus", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const input = new InputRenderable(testSetup.renderer, {
id: "test-input",
placeholder: "Type here",
})
testSetup.renderer.root.add(input)
input.focus()
expect(input.isFocused()).toBe(true)
})
```
## Test Organization
### File Structure
```
src/
├── components/
│ ├── Button.tsx
│ └── Button.test.tsx
├── hooks/
│ ├── useCounter.ts
│ └── useCounter.test.ts
└── test-utils.tsx
```
### Running Tests
```bash
# Run all tests
bun test
# Run specific test file
bun test src/components/Button.test.tsx
# Run with filter
bun test --filter "Button"
# Watch mode
bun test --watch
```
## Patterns
### Testing Conditional Rendering (React)
```tsx
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("shows loading state", async () => {
testSetup = await testRender(
<DataLoader loading={true} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Loading...")
})
test("shows data when loaded", async () => {
testSetup = await testRender(
<DataLoader loading={false} data={["Item 1", "Item 2"]} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Item 1")
expect(frame).toContain("Item 2")
})
```
### Testing Lists
```tsx
test("renders all items", async () => {
const items = ["Apple", "Banana", "Cherry"]
testSetup = await testRender(
<ItemList items={items} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
items.forEach(item => {
expect(frame).toContain(item)
})
})
```
### Testing Layouts
```tsx
test("matches layout snapshot", async () => {
testSetup = await testRender(
<AppLayout />,
{ width: 120, height: 40 } // Larger viewport
)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toMatchSnapshot()
})
```
## Debugging Tests
### Print Frame Output
```tsx
import { testRender } from "@opentui/react/test-utils"
test("debug output", async () => {
const testSetup = await testRender(
<MyComponent />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
// Print to see what's rendered
console.log(frame)
expect(frame).toContain("expected")
})
```
### Verbose Mode
```bash
bun test --verbose
```
## Gotchas
### Async Rendering
Always call `renderOnce()` after setting up your component to ensure rendering is complete:
```typescript
const testSetup = await testRender(<MyComponent />, { width: 40, height: 10 })
await testSetup.renderOnce() // Required before capturing frame
const frame = testSetup.captureCharFrame()
```
### Test Isolation and Cleanup
Always destroy the renderer after each test to avoid resource leaks:
```typescript
import { afterEach } from "bun:test"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("test 1", async () => {
testSetup = await testRender(<Component1 />, { width: 40, height: 10 })
// ...
})
test("test 2", async () => {
testSetup = await testRender(<Component2 />, { width: 40, height: 10 })
// ...
})
```
### Snapshot Dimensions
Be consistent with test dimensions for stable snapshots:
```typescript
const testSetup = await createTestRenderer({
width: 80, // Standard width
height: 24, // Standard height
})
```
### Running from Package Directory
Run tests from the package directory:
```bash
cd packages/core
bun test
# Not from repo root for package-specific tests
```
## See Also
- [Core API](../core/api.md) - `createTestRenderer` and renderable classes
- [React Configuration](../react/configuration.md) - React test setup
- [Solid Configuration](../solid/configuration.md) - Solid test setup
- [Keyboard](../keyboard/REFERENCE.md) - Simulating key events in tests