Technical Documentation: anomalyco/opentui
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
OpenTUI is a library to build terminal user interfaces.
- It is written in Zig.
- You write TypeScript directly or through React and Solid.
- You arrange boxes and text with flexbox.
- You add selects, inputs, and scroll boxes with keyboard and mouse controls.
- You can play sounds, show images, and render 3D graphics.
OpenCode uses OpenTUI in production for millions of users.
Website | Documentation | Packages
Packages
@opentui/core- Native terminal UI library with TypeScript bindings.@opentui/react- React renderer for building terminal user interfaces with OpenTUI Core.@opentui/solid- Solid renderer for building terminal user interfaces with OpenTUI Core.@opentui/keymap- Key binding and command routing library for terminal and browser applications.@opentui/qrcode- QR code encoder and renderable with React and Solid surfaces.@opentui/three- Three.js WebGPU renderer for OpenTUI.@opentui/ssh- SSH server integration for OpenTUI applications.
Private packages
@opentui/native- Private workspace with the Zig implementation, native tests, and benchmarks.@opentui/examples- Private workspace with example applications and a standalone examples executable.@opentui/web- Private workspace with the documentation website and the AI agent skill.
AI agent skill
Install the OpenTUI documentation as a skill for your AI coding assistant with npx skills:
npx skills add https://opentui.com --skill opentuiAdd -g to install the skill globally. The skill matches the latest release, and npx skills update moves it to each
new release. Install from https://opentui.com/docs/next to follow the main branch. A skill installed withnpx skills add anomalyco/opentui follows the main branch; run the command above to switch it to releases.
Development
Development requires Bun 1.4.1 or later and Zig 0.16.0.
See AGENTS.md for engineering, tooling, and verification conventions.
bun installRun package scripts from their package directory. Run bun run build from the repository root after you change
native code or cross-package build outputs. Ordinary TypeScript changes do not require a root build.
Run repository tests and static checks from the repository root:
bun run test
bun run fmt:check
bun run lintTo link the source packages into a Bun-managed project, install that project's dependencies, then run./scripts/link-opentui-dev.sh /path/to/project from the repository root. Add --react or --solid when needed.
Contributing
OpenTUI is open for contributions.
License
OpenTUI is licensed under the MIT License.
Overview (`api/README.md`)
API history
Each <version>.txt file records how the API of the published @opentui/* packages and of the C ABI inpackages/native/include/opentui.h changed in that release. The documentation site reads these files to show when
each feature was added, changed, deprecated, or removed.
A file exists for every stable release from the oldest file on. A release without API changes has a file with only
the header.
Format
base 0.5.14
- @opentui/core: method DiffRenderable.destroyRecursively(): void
+ @opentui/core: protected method DiffRenderable.destroySelf(): void
- @opentui/solid: method SlotRenderable.getSlotChild(parent: BaseRenderable): TextSlotRenderable | LayoutSlotRenderable
+ @opentui/solid: method SlotRenderable.getSlotChild(parent: BaseRenderable): LayoutSlotRenderable | TextSlotRenderableThe first line names the release that the file is relative to: base <version>. Usually the base is the previous
release. A patch of an older line names the release it was made from. The oldest file is a snapshot: base none,
and every feature of its release as a + line.
Each other line adds (+) or removes (-) one feature. A changed signature is a - line and a + line with the
same key. The API of a release is the API of its base, without the - lines, with the + lines.
Lines are sorted by key, then - before +, then by text. A key is <module>: <kind> <name>.
Features
<module>: [<modifier> ...]<kind> <name><signature><module>is the npm specifier of an entry point, such as@opentui/coreor@opentui/core/testing. Each subpath
of a package'sexportsmap with atypestarget is an entry point. A feature exported by two entry points has a
line for each. The module of the C ABI isopentui.h.<modifier>isdeprecated(JSDoc@deprecated),static,protected,abstract, orreadonly, in that order.<kind>isclass,interface,type,function,const,let,enum,enum-member,namespace,constructor,method,property,call,construct,index, orreexport. The C ABI also usesstructandfield.<name>is the export name. A member name includes its owner:CliRenderer.suspend,Yoga.Node.create. A
constructor, call, construct, or index signature uses the owner's name. A default export isdefault.<signature>is the declaration syntax after the name, from the published.d.tsfiles. Comments are removed,
whitespace is collapsed, andimport("…").Xis written asX.
Members are the own members of a class or interface. Inherited members are not repeated; the extends clause is on
the class line. Private, #private, and @internal members are left out. So are public members whose name starts
with _. Protected members, including _ names, are API because users subclass renderables. Getters and setters
are properties; a getter without a setter is readonly. Each overload has its own line.
An export * as X of a module in the package records namespace X and its members as X.member. An export from
another package records only the export: namespace THREE from "three", reexport createElement from "react", orreexport * from "react". A renamed export from another package ends with as <original name>.
The C ABI
opentui.h: function ot_scene_move_node(ot_context *, const ot_handle *node, const ot_handle *parent, uint32_t index): ot_status
opentui.h: type ot_status = int32_t
opentui.h: type ot_scene_measure_callback = void (*)(uint64_t context_id, uint32_t slot, …, float *out_size)
opentui.h: type ot_context = struct ot_context
opentui.h: struct ot_handle
opentui.h: field ot_handle.slot: uint32_t, offset 8
opentui.h: const OT_INVALID_ARGUMENT = -1The lines of opentui.h record each declaration whose name starts with ot_ or OT_:
- A function is
function, with its parameters as the header declares them and its return type after a colon. - A typedef of a function pointer, an integer, or an incomplete struct is
type, with the C type after=. - A struct is
struct, and each of its fields isfield <struct>.<field>: <type>, offset. A field that moves
is a change, so a line shows a layout change even when the field keeps its name and type. - An object-like macro is
const, with its integer value.
Types keep the header's spelling, with whitespace collapsed. The field offsets and macro values come frompackages/core/src/native-abi.generated.ts, which bun run check:abi in packages/core derives from the header with
the C compiler.
A release's C ABI is its commit's header: the C ABI is used from a source checkout, not from an npm package.
Commands
Run these from the repository root.
bun packages/web/scripts/api.ts release <version>writesapi/<version>.txt: the difference between the latest
file's API and the API of the source tree.scripts/release.tsruns it in the release commit. It does not
overwrite a file with different contents. Pass--base <version>for a patch of an older line.bun packages/web/scripts/api.ts verify <version>compares the API that the files record for a release with its
packages on npm. The release workflow runs it after npm serves the release. The C ABI is not on npm, so it is left
out.bun packages/web/scripts/api.ts diffprints the file that a release of the source tree would add.bun packages/web/scripts/api.ts currentprints every feature of the source tree.bun packages/web/scripts/api.ts checkvalidates the files.bun packages/web/scripts/api.ts backfill [--from <version>] [--to <version>]writes the files of published
releases from their npm tarballs and the header at their tags, from the oldest file on. It rewrites only files that
changed. Run it after a change to the extractor. A release without a tag has no C ABI.bun packages/web/scripts/api.ts squash <version>starts the history at a release: its file becomes a snapshot,
and the files of older releases go. The API that the files record for the kept releases stays the same; the site
stops listing the older releases and the versions that added their features. Run it when nobody uses the older
lines.
The source commands run tsc declaration emit for each package into a temporary directory, with the exports map that
the package's scripts/build.ts publishes. They do not run the package builds. The extractor reads those
declarations in the same way as a tarball, so a source tree at a release tag gives the API of that release. They read
the source tree's header for the C ABI.
Overview (`packages/core/README.md`)
OpenTUI is a library to build terminal user interfaces.
- It is written in Zig.
- You write TypeScript directly or through React and Solid.
- You arrange boxes and text with flexbox.
- You add selects, inputs, and scroll boxes with keyboard and mouse controls.
- You can play sounds, show images, and render 3D graphics.
OpenCode uses OpenTUI in production for millions of users.
Website | Documentation | Packages
OpenTUI Core
@opentui/core supplies the renderer and TypeScript API. You use imperative renderables and events directly. The
TypeScript packages call native Zig through an internal foreign function interface (FFI) boundary.
import { TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({ exitOnCtrlC: true })
renderer.root.add(new TextRenderable(renderer, { content: "Hello, OpenTUI!" }))exitOnCtrlC: true calls renderer.destroy() when the user presses Ctrl+C. The code that creates the renderer must
call renderer.destroy() on every other shutdown path.
Install the package:
bun add @opentui/coreThen build your first app with the quickstart.
Runtime and platform support
@opentui/core runs on Bun 1.3.14 or later, or on Node.js 26.4.0 or later with ECMAScript modules (ESM) and--experimental-ffi.
Use Bun 1.4.0 or later on native Windows arm64.
The native ABI and the generated platform packages, such as @opentui/core-linux-x64, are internal distribution
surfaces, not application APIs.
AI agent skill
Install the OpenTUI documentation as a skill for your AI coding assistant with npx skills:
npx skills add https://opentui.com --skill opentuiAdd -g to install the skill globally. The skill matches the latest release, and npx skills update moves it to each
new release. Install from https://opentui.com/docs/next to follow the main branch. A skill installed withnpx skills add anomalyco/opentui follows the main branch; run the command above to switch it to releases.
License
OpenTUI is licensed under the MIT License.
Overview (`packages/keymap/README.md`)
@opentui/keymap
A host-agnostic keymap engine for terminal and DOM apps. Same core, multiple adapters.
It models keybindings as priority-ordered, focus-scoped layers attached to targets (terminal renderables or DOM elements). The core is intentionally bare; everything beyond raw key dispatch is opt-in via addons, parsers, expanders, resolvers, and field compilers.
Highlights
- Host-agnostic core over a small
KeymapHostinterface with host metadata, focus, parent traversal, target destruction, key press/release, optional raw input, and synthetic command events. - Target-aware focus routing for global layers plus local
focus/focus-withinlayers that follow the active target's parent chain. - Layered bindings with priority ordering, newest-first ties,
fallthrough, andpreventDefaultcontrol. - Branch-aware multi-key sequences over flat compiled bindings, with a public pending-sequence API, synchronous
pendingSequenceevents, active continuation queries, and automatic invalidation on focus changes. - Programmable exact-vs-prefix disambiguation (e.g.
gvsgg) withrunExact,continueSequence,clear, and deferredAbortSignal+sleepdecisions. Ships a Neovim-style timeout resolver. - Pluggable binding language: stackable binding parsers, key expanders, layer-binding transformers, binding transformers, command resolvers, command transformers, and ordered event-match resolvers.
- Extensible schema and activation: register custom fields on layers, bindings, and commands. Field compilers can emit
attrs; all field kinds can gate activation viarequire(...)andactiveWhen(matcher). - Reactive matchers with subscription-driven state notifications, plus React store and Solid signal helpers.
- Raw and key intercepts before and after normal binding dispatch, including pre-binding
consume({ preventDefault, stopPropagation }), post-dispatch handled/no-match outcomes, and raw inputstop()handling. - Command catalog and dispatch with named commands, inline command handlers, command chains, namespaces, search, visibility tiers (
registered/reachable/active), binding queries,runCommand, and focus-awaredispatchCommand. - Opt-in graph snapshots and diagnostics for layers, commands, bindings, sequence nodes, pending paths, inactive reasons, shadowing, stable warning/error codes, and lint-style layer analyzers.
- Broad key coverage in the default parser, including function keys, navigation/editing keys, numpad keys, media keys, left/right modifiers,
super,hyper, and literal+bindings. - Platform-aware modifier aliases via
registerModBindings, resolvingmod+...from host metadata while preserving display strings.
Addons
@opentui/keymap/addons ships ready-made building blocks:
registerDefaultKeys—ctrl+shift+sstyle parser and event matching.registerLeader,registerTimedLeader— leader tokens with optional timeout.registerEmacsBindings—ctrl+x ctrl+schords.registerExCommands—:write-style commands withaliasesandnargs.registerCommaBindings,registerModBindings,registerAliasesField,registerBindingOverrides.registerEnabledFields,registerMetadataFields(desc,group,title,category).registerNeovimDisambiguation,registerEscapeClearsPendingSequence,registerBackspacePopsPendingSequence.registerDeadBindingWarnings,registerUnresolvedCommandWarnings.
@opentui/keymap/addons/opentui adds OpenTUI-specific pieces: base-layout matching via event.baseCode for non-Latin keys and the digit row (the same rule as Core), and pre-wired textarea / edit-buffer commands. Direct event matches win before fallback matches, even across layers.
Entry Points
@opentui/keymap— core API@opentui/keymap/addons— universal addons@opentui/keymap/addons/opentui— universal + OpenTUI addons@opentui/keymap/extras/graph— graph snapshot helpers for debug and graph UIs@opentui/keymap/testing— host-agnostic fake keymap host and diagnostics for addon tests@opentui/keymap/html— HTML adapter@opentui/keymap/opentui— OpenTUI adapter@opentui/keymap/react—KeymapProvider,useKeymap,useBindings,useActiveKeys,usePendingSequence,reactiveMatcherFromStore@opentui/keymap/solid—KeymapProvider,useKeymap,useKeymapSelector,useBindings,reactiveMatcherFromSignal@opentui/keymap/extras— helpers for cheat-sheet UIs (createBindingLookup,commandBindings,formatCommandBindings)
Usage
import { registerDefaultKeys } from "@opentui/keymap/addons"
import { createOpenTuiKeymap } from "@opentui/keymap/opentui"
import { KeymapProvider } from "@opentui/keymap/react"
const keymap = createOpenTuiKeymap(renderer)
registerDefaultKeys(keymap)
createRoot(renderer).render(
<KeymapProvider keymap={keymap}>
<App />
</KeymapProvider>,
)Create a keymap, install the addons you want, then pass the configured instance to your app. The React and Solid entrypoints consume a pre-created OpenTUI keymap through context.
Keymap is the core runtime. Pass feature factories to its constructor when you need graph snapshots or layer analyzers.
Adapters
Adapters implement a small KeymapHost interface (metadata, rootTarget, getFocusedTarget, getParentTarget, onKeyPress, onKeyRelease, onFocusChange, onTargetDestroy, ...). The HTML adapter normalizes DOM key names (Escape → escape, ArrowUp → up, Meta → super, Alt → meta) and tracks targets via MutationObserver. The OpenTUI adapter hooks CliRenderer keypress, keyrelease, focus, destroy, target destroy, and raw input events.
Formatting Keys
Use keymap.formatKey when displaying raw binding strings. It runs them through the keymap's parsers and tokens before stringifying.
keymap.formatKey("<leader>s", { separator: " " }) // "space s"
keymap.formatKey("<leader>s", { preferDisplay: true }) // "<leader>s"Re-entry
Runtime/data re-entry is supported during dispatch: command handlers, intercepts, and pending-sequence listeners may read or write runtime data and pending-sequence state.
Structural re-entry is not supported. Do not register or unregister layers, tokens, parsers, or resolvers while a dispatch is in flight.
Installation
bun install @opentui/keymapDevelopment
bun run build
bun run test
bun src/keymap-benchmark.tsThe HTML demo lives in the docs app at /demos/keymap-html/ under packages/web.
Overview (`packages/native/README.md`)
Native API
OpenTUI uses Context-owned scenes and Sessions for production rendering, including
text, editors, custom paint hooks, images, detached surfaces, and split output.
Standalone resources use the same checked ownership model without a terminal.
Read Resources and ownership for
resource bindings, text copy units, and scoped framebuffer access.
Frames and output defines mutation
admission, painted drafts, and transport completion.
How Core uses native explains TypeScript
staging and resource ownership.
Host I/O and time defines Context
file operations, Session deadlines, and native diagnostic clocks.
API surfaces
src/opentui.zigis the public Zig module. It exports the
checkedContextAPI and raw Zig primitives, includingCliRenderer,NativeRenderable,OptimizedBuffer, text buffers, and pools. Callers manage
the lifetimes of raw primitives. Importing a raw primitive does not add Context
checks to it.include/opentui.hdefines the checkedot_*C ABI for
Contexts, Sessions, scenes, drawing, text, editors, styles, leases, clipboard,
and diagnostics.
The ABI is experimental and at version 1. Core renders through it.../core/src/zig.tssupplies TypeScript wrappers over that
checked ABI. Its checked signatures, callbacks, constants, and record layouts come
fromnative-abi.generated.ts.
ABI generation and builds
Use C headers and libraries from the same revision. Set each versioned record's
exact struct_size and abi_version, and leave unused flags and reserved fields
zero. Follow the output and failure contract of each operation in the header.
From packages/core:
bun run generate:abi
bun run check:abi
bun run test:abiscripts/native-abi.ts uses Zig Translate-C andscripts/native-abi.zig reflection to derive scalar
widths, signatures, callback types, constants, record sizes, alignment, and field
offsets from the header. Pointer nullability, retention, address fields, and portablebuffer/ptr policy live inscripts/native-abi-pointers.ts, because C
types cannot prove lifetimes. Review that metadata when ownership contracts change.
Do not edit generated bindings. check:abi detects stale output. Usebun run check:abi --all-targets to also compare the layouts of all supported targets.
An unsupported record shape or calling convention fails the script. It does not
produce partial metadata. C compiler assertions also check complete function and
callback prototypes, record layouts, field types, and constant values. Translate-C
alone is not enough, because it can drop callback calling-convention attributes and
ignore #pragma pack.
From packages/native, bun run build installs the header and libraries underlib/<target>/ for the host target. Linux and macOS produce libopentui.a beside
the shared library. Windows produces opentui-static.lib, opentui.lib for DLL
imports, and opentui.dll. Static linkage also needs the platform and C++ runtime
libraries. zig build -Dall builds all supported targets.zig build -Dlibrary-target=<target> builds one target.
zig build test-abi --summary allThis command runs the C fixture against the static and shared libraries on the host.
On a glibc Linux host, the fixture targets glibc 2.17. check:abi --all-targets
checks layouts for all eight supported targets. It does not test macOS or Windows
runtime linkage or terminal behavior.
The external examples/hello package imports the public Zig module
without JavaScript.
Overview (`packages/qrcode/README.md`)
@opentui/qrcode
QR code encoder and renderable for OpenTUI.
import { createCliRenderer } from "@opentui/core"
import { QRCodeRenderable } from "@opentui/qrcode"
const renderer = await createCliRenderer()
renderer.root.add(
new QRCodeRenderable(renderer, {
content: "https://opentui.com",
quietZone: 4,
scale: 2,
}),
)React JSX support is registered explicitly:
import { registerQRCode } from "@opentui/qrcode/react"
registerQRCode()Solid JSX support is registered explicitly:
import { registerQRCode } from "@opentui/qrcode/solid"
registerQRCode()Overview (`packages/react/README.md`)
@opentui/react
A React renderer for OpenTUI Core. Build terminal user interfaces with React components, hooks, and state.
Installation
Quick start with bun and create-tui:
bun create tui --template reactManual installation:
bun install @opentui/react @opentui/core reactQuick Start
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return <text>Hello, world!</text>
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)TypeScript Configuration
Configure your tsconfig.json:
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true
}
}Table of Contents
Core Concepts
Components
OpenTUI React includes these built-in components. Each component maps to an OpenTUI Core renderable:
Layout & Display:
<text>- Display text with styling- `` - Container with borders and layout
<scrollbox>- A scrollable box<ascii-font>- Display ASCII art text with different font styles
For QR codes, call registerQRCode() from @opentui/qrcode/react. It registers the <qr-code> element.
Input Components:
- `` - Text input field
<textarea>- Multi-line text input field<select>- Vertical list of options<tab-select>- Tab-based selection
Code & Diff Components:
<code>- Code block with syntax highlighting<line-number>- Code display with line numbers, diff highlights, and diagnostics<diff>- Unified or split diff viewer with syntax highlighting
Helpers:
,,,,,, ``,<a>- Text modifiers (use them only inside a<text>component)
Styling
Style a component with props or with the style prop:
// Direct props
<box backgroundColor="blue" padding={2}>
<text>Hello, world!</text>
</box>
// Style prop
<box style={{ backgroundColor: "blue", padding: 2 }}>
<text>Hello, world!</text>
</box>API Reference
`createRoot(renderer)`
Creates a root for rendering a React tree with the given CLI renderer.
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
const renderer = await createCliRenderer({
// Optional renderer configuration
exitOnCtrlC: false,
})
createRoot(renderer).render(<App />)Parameters:
renderer: ACliRendererinstance (typically created withcreateCliRenderer())
Returns: A root with a render(node) method and an unmount() method. render(node) accepts a React element.unmount() removes the React tree and keeps the renderer active. renderer.destroy() also unmounts the root.
`render(element, config?)` (Removed)
Removed:
@opentui/reactdoes not exportrender(). UsecreateRoot(renderer).render(node)instead.
Hooks
`useRenderer()`
Access the OpenTUI renderer instance.
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
renderer.console.show()
console.log("Hello, from the console!")
}, [])
return <box />
}`useKeyboard(handler, options?)`
Handle keyboard events.
import { useKeyboard } from "@opentui/react"
function App() {
useKeyboard((key) => {
if (key.name === "escape") {
process.exit(0)
}
})
return <text>Press ESC to exit</text>
}Parameters:
handler: Callback function that receives aKeyEventobjectoptions?: Optional configuration object:release?: Boolean to include key release events (default:false)
By default, the handler gets press events only. Press events include key repeats with repeated: true. Set options.release to true to also get release events.
Example with release events:
import { useKeyboard } from "@opentui/react"
import { useState } from "react"
function App() {
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set())
useKeyboard(
(event) => {
setPressedKeys((keys) => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true },
)
return (
<box>
<text>Currently pressed: {Array.from(pressedKeys).join(", ") || "none"}</text>
</box>
)
}`usePaste(handler)`
Handle terminal paste events (bracketed paste).
import { decodePasteBytes } from "@opentui/core"
import { usePaste } from "@opentui/react"
function App() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted text:", text)
})
return <text>Paste something into the terminal</text>
}Parameters:
handler: Callback function that receives aPasteEventobject withbytes: Uint8Array(decode withdecodePasteBytesfrom@opentui/core)
`useFocus(handler)`
Subscribe to terminal window focus events. The handler runs when the terminal window gains focus.
import { useFocus } from "@opentui/react"
function App() {
useFocus(() => {
console.log("Terminal gained focus")
})
return <text>Focus-aware component</text>
}Parameters:
handler: Callback function invoked when the terminal gains focus
`useBlur(handler)`
Subscribe to terminal window blur events. The handler runs when the terminal window loses focus.
import { useBlur } from "@opentui/react"
function App() {
useBlur(() => {
console.log("Terminal lost focus")
})
return <text>Blur-aware component</text>
}Parameters:
handler: Callback function invoked when the terminal loses focus
`useSelectionHandler(handler)`
Handle text selection events, such as a mouse drag selection.
import { useSelectionHandler } from "@opentui/react"
function App() {
useSelectionHandler((selection) => {
const text = selection.getSelectedText()
console.log("Selected:", text)
})
return <text selectable>Select this text with your mouse</text>
}Parameters:
handler: Callback function that receives aSelectionobject with methods likegetSelectedText()
`useOnResize(callback)`
Handle terminal resize events.
import { useOnResize, useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
renderer.console.show()
}, [renderer])
useOnResize((width, height) => {
console.log(`Terminal resized to ${width}x${height}`)
})
return <text>Resize-aware component</text>
}`useTerminalDimensions()`
Get the current terminal dimensions. The hook updates them when the terminal resizes.
import { useTerminalDimensions } from "@opentui/react"
function App() {
const { width, height } = useTerminalDimensions()
return (
<box>
<text>
Terminal dimensions: {width}x{height}
</text>
<box style={{ width: Math.floor(width / 2), height: Math.floor(height / 3) }}>
<text>Half-width, third-height box</text>
</box>
</box>
)
}Returns: An object with width and height properties representing the current terminal dimensions.
`useTimeline(options?)`
Create one Timeline for animations. The hook registers the timeline with the animation engine after mount. It pauses and unregisters the timeline during React effect cleanup.
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function App() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
useEffect(() => {
timeline.add(
{
width,
},
{
width: 50,
duration: 2000,
ease: "linear",
onUpdate: (animation) => {
setWidth(animation.targets[0].width)
},
},
)
}, [])
return <box style={{ width, backgroundColor: "#6a5acd" }} />
}Parameters:
options?: OptionalTimelineOptionsobject with properties:duration?: Animation duration in milliseconds (default: 1000)loop?: Whether the timeline should loop (default: false)autoplay?: Whether to automatically start the timeline (default: true)onComplete?: Callback when the timeline completesonPause?: Callback when the timeline pauses
Returns: A Timeline instance with methods:
add(target, properties, startTime): Add animation to timelineplay(): Start the timelinepause(): Pause the timelinerestart(): Restart the timeline from beginning
Components
Layout & Display Components
Text Component
Display text with rich formatting.
function App() {
return (
<box>
{/* Simple text */}
<text>Hello World</text>
{/* Rich text with children */}
<text>
<span fg="red">Red Text</span>
</text>
{/* Text modifiers */}
<text>
<strong>Bold</strong>, <em>Italic</em>, and <u>Underlined</u>
</text>
</box>
)
}Box Component
Container with borders and layout capabilities.
function App() {
return (
<box flexDirection="column">
{/* Basic box */}
<box border>
<text>Simple box</text>
</box>
{/* Box with title and styling */}
<box title="Settings" titleColor="yellow" border borderStyle="double" padding={2} backgroundColor="blue">
<text>Box content</text>
</box>
{/* Styled box */}
<box
style={{
border: true,
width: 40,
height: 10,
margin: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<text>Centered content</text>
</box>
</box>
)
}Scrollbox Component
A scrollable box.
function App() {
return (
<scrollbox
style={{
rootOptions: {
backgroundColor: "#24283b",
},
wrapperOptions: {
backgroundColor: "#1f2335",
},
viewportOptions: {
backgroundColor: "#1a1b26",
},
contentOptions: {
backgroundColor: "#16161e",
},
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
focused
>
{Array.from({ length: 1000 }).map((_, i) => (
<box
key={i}
style={{ width: "100%", padding: 1, marginBottom: 1, backgroundColor: i % 2 === 0 ? "#292e42" : "#2f3449" }}
>
<text content={`Box ${i}`} />
</box>
))}
</scrollbox>
)
}ASCII Font Component
Display ASCII art text with different font styles.
import { useState } from "react"
function App() {
const text = "ASCII"
const [font, setFont] = useState<"block" | "shade" | "slick" | "tiny">("tiny")
return (
<box style={{ border: true, paddingLeft: 1, paddingRight: 1 }}>
<box
style={{
height: 8,
border: true,
marginBottom: 1,
}}
>
<select
focused
onChange={(_, option) => setFont(option?.value)}
showScrollIndicator
options={[
{
name: "Tiny",
description: "Tiny font",
value: "tiny",
},
{
name: "Block",
description: "Block font",
value: "block",
},
{
name: "Slick",
description: "Slick font",
value: "slick",
},
{
name: "Shade",
description: "Shade font",
value: "shade",
},
]}
style={{ flexGrow: 1 }}
/>
</box>
<ascii-font text={text} font={font} />
</box>
)
}Input Components
Input Component
Text input field with event handling.
import { useState } from "react"
function App() {
const [value, setValue] = useState("")
return (
<box title="Enter your name" style={{ border: true, height: 3 }}>
<input
placeholder="Type here..."
focused
onInput={setValue}
onSubmit={(value) => console.log("Submitted:", value)}
/>
</box>
)
}Textarea Component
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useRenderer } from "@opentui/react"
import { useEffect, useRef } from "react"
function App() {
const renderer = useRenderer()
const textareaRef = useRef<TextareaRenderable>(null)
useEffect(() => {
renderer.console.show()
}, [renderer])
useKeyboard((key) => {
if (key.name === "return") {
console.log(textareaRef.current?.plainText)
}
})
return (
<box title="Interactive Editor" style={{ border: true, flexGrow: 1 }}>
<textarea ref={textareaRef} placeholder="Type here..." focused />
</box>
)
}Select Component
Choose one option from a vertical list.
import type { SelectOption } from "@opentui/core"
import { useState } from "react"
function App() {
const [selectedIndex, setSelectedIndex] = useState(0)
const options: SelectOption[] = [
{ name: "Option 1", description: "Option 1 description", value: "opt1" },
{ name: "Option 2", description: "Option 2 description", value: "opt2" },
{ name: "Option 3", description: "Option 3 description", value: "opt3" },
]
return (
<box style={{ border: true, height: 24 }}>
<select
style={{ height: 22 }}
options={options}
focused={true}
onChange={(index, option) => {
setSelectedIndex(index)
console.log("Selected:", option)
}}
/>
</box>
)
}Code & Diff Components
Code Component
Create a theme once for its renderer, not during a component render. Release it after you destroy its consumers.
import { createCliRenderer, RGBA, SyntaxStyle } from "@opentui/core"
import { createRoot } from "@opentui/react"
const renderer = await createCliRenderer()
const syntaxStyle = SyntaxStyle.fromStyles(
{
keyword: { fg: RGBA.fromHex("#ff6b6b"), bold: true }, // red, bold
string: { fg: RGBA.fromHex("#51cf66") }, // green
comment: { fg: RGBA.fromHex("#868e96"), italic: true }, // gray, italic
number: { fg: RGBA.fromHex("#ffd43b") }, // yellow
default: { fg: RGBA.fromHex("#ffffff") }, // white
},
renderer.nativeScene!,
)
const codeExample = `function hello() {
// This is a comment
const message = "Hello, world!"
const count = 42
return message + " " + count
}`
function App() {
return (
<box style={{ border: true, flexGrow: 1 }}>
<code content={codeExample} filetype="javascript" syntaxStyle={syntaxStyle} />
</box>
)
}
try {
createRoot(renderer).render(<App />)
await renderer.closed
} finally {
renderer.destroy()
syntaxStyle.destroy()
}Line Number Component
Display code with line numbers, and optionally add diff highlights or diagnostic indicators.
/* Detailed source-code truncated for AI context efficiency. */For a more complete example with interactive diff highlights and diagnostics, see examples/line-number.tsx.
Diff Component
Display a unified or split diff with syntax highlighting, line numbers, word wrapping, and custom colors.
For a complete interactive example with theme switching and keybindings, see examples/diff.tsx.
Examples
Login Form
/* Detailed source-code truncated for AI context efficiency. */Counter with Timer
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
import { useEffect, useState } from "react"
function App() {
const [count, setCount] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCount((prev) => prev + 1)
}, 1000)
return () => clearInterval(interval)
}, [])
return (
<box title="Counter" style={{ padding: 2 }}>
<text fg="#00FF00">{`Count: ${count}`}</text>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)System Monitor Animation
/* Detailed source-code truncated for AI context efficiency. */Styled Text Showcase
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return (
<>
<text>Simple text</text>
<text>
<strong>Bold text</strong>
</text>
<text>
<u>Underlined text</u>
</text>
<text>
<span fg="red">Red text</span>
</text>
<text>
<span fg="blue">Blue text</span>
</text>
<text>
<strong fg="red">Bold red text</strong>
</text>
<text>
<strong>Bold</strong> and <span fg="blue">blue</span> combined
</text>
</>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)Component Extension
To create a custom component, extend an OpenTUI Core renderable class and register it with extend().
React calls the constructor with { id, ...props }. After construction, React assigns the props to the instance again.
This example overrides the renderSelf() paint hook. The hook uses screen cell coordinates, so it places the label
relative to this.x and this.y:
/* Detailed source-code truncated for AI context efficiency. */Using React DevTools
OpenTUI React supports React DevTools for debugging your terminal applications. To enable DevTools integration:
- Install the optional peer dependency:
bun add --dev react-devtools-core@7- Start the standalone React DevTools:
npx react-devtools@7- Run your app with the
DEVenvironment variable:
DEV=true bun run your-app.tsAfter the app starts, React DevTools shows the component tree. You can inspect and change props while the app runs. The terminal UI shows each change.
Process Exit with DevTools
When DevTools is connected, the WebSocket connection can keep your process from exiting on its own.
Overview (`packages/solid/README.md`)
@opentui/solid
Solid.js support for OpenTUI.
Installation
bun install solid-js @opentui/solidUsage
- Add jsx config to tsconfig.json:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}- Add preload script to bunfig.toml:
preload = ["@opentui/solid/preload"]- Add render function to index.tsx:
import { render } from "@opentui/solid"
render(() => <text>Hello, World!</text>)import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./index.tsx"],
target: "bun",
outdir: "./build",
plugins: [solidPlugin],
compile: {
target: "bun-darwin-arm64",
outfile: "app-macos",
},
})Table of Contents
Core Concepts
Components
OpenTUI Solid exposes intrinsic JSX elements that map to OpenTUI renderables:
- Layout & Display:
text,box,scrollbox,ascii_font - Input:
input,textarea,select,tab_select - Code & Diff:
code,line_number,diff - Text Modifiers:
span,strong,b,em,i,u,br,a
API Reference
`render(node, rendererOrConfig?)`
Render a Solid component tree into a CLI renderer. If you omit rendererOrConfig, render() creates a renderer with default options. render() returns a promise that resolves after the initial mount. Destroying the renderer disposes the Solid root.
import { render } from "@opentui/solid"
render(() => <App />)Parameters:
node: Function returning a JSX element.rendererOrConfig?:CliRendererinstance orCliRendererConfig.
`testRender(node, options?)`
Create a test renderer for snapshots and interaction tests.
import { testRender } from "@opentui/solid"
const testSetup = await testRender(() => <App />, { width: 40, height: 10 })`extend(components)`
Register custom renderables as JSX intrinsic elements.
import { extend } from "@opentui/solid"
extend({ customBox: CustomBoxRenderable })Solid calls the constructor with { id } only. After construction, Solid applies each JSX prop to the instance. A class
that reads a value only from its constructor options does not get that value from JSX.
`getComponentCatalogue()`
Returns the current component catalogue that powers JSX tag lookup.
Hooks
useRenderer()onResize(callback)onFocus(callback)onBlur(callback)useTerminalDimensions()useKeyboard(handler, options?)usePaste(handler)useSelectionHandler(handler)useTimeline(options?)
`Portal`
Render children into a different mount node. Use it for overlays and tooltips.
import { Portal } from "@opentui/solid"
;<Portal mount={renderer.root}>
<box border>Overlay</box>
</Portal>`Dynamic`
Render arbitrary intrinsic elements or components dynamically.
import { Dynamic } from "@opentui/solid"
;<Dynamic component={isMultiline() ? "textarea" : "input"} />Components
Layout & Display
text: styled text containerbox: layout container with borders, padding, and flex settingsscrollbox: scrollable containerascii_font: ASCII art text renderer
For QR codes, call registerQRCode() from @opentui/qrcode/solid. It registers the qr_code element.
Input
input: single-line text inputtextarea: multi-line text inputselect: list selectiontab_select: tab-based selection
Code & Diff
code: syntax-highlighted code blocksline_number: line-numbered code display with diff/diagnostic helpersdiff: unified or split diff viewer
Text Modifiers
Use these only inside a text component:
span: inline styled textstrong/b: bold textem/i: italic textu: underline textbr: line breaka: link text withhref
Overview (`packages/ssh/README.md`)
@opentui/ssh
Serve OpenTUI apps over SSH.
@opentui/ssh gives each incoming SSH shell its own OpenTUICliRenderer. The renderer reads input from the SSH channel, writes
output to it, and tracks the size of the client's PTY. You choose what to render.
The package depends only on @opentui/core, not on @opentui/react or@opentui/solid, so the same server works with all three.
import { createServer } from "@opentui/ssh"
import { BoxRenderable, TextRenderable } from "@opentui/core"
const server = createServer({
hostKey: { path: "./host_key" }, // auto-generated & persisted on first run
auth: { publicKey: "any" }, // accepts any verified key, so every client gets an identity
}).serve((session) => {
const { renderer, identity } = session
const box = new BoxRenderable(renderer, { width: "100%", height: "100%", border: true })
box.add(new TextRenderable(renderer, { content: `Hello, ${identity.username}!` }))
renderer.root.add(box)
// the server destroys the renderer when the session closes. Use onClose only for your own cleanup.
})
await server.listen(2222)ssh -p 2222 localhostInstall
bun add @opentui/ssh
# or
npm install @opentui/ssh@opentui/core is a peer dependency. Supported runtimes are Bun ≥ 1.3.14 and
Node.js ≥ 26.4.0. CI runs the SSH integration suite with Bun on macOS, Linux,
and Windows. CI also installs the packed ESM packages and runs a real renderer
session with Node.js and Bun. Start Node applications that create renderers
with node --experimental-ffi server.mjs.
Use Bun ≥ 1.4.0 on native Windows arm64.
The shape: `createServer(config).serve(handler)`
Static setup goes in the createServer({...}) config object. Add cross-cutting
concerns with .use(). serve(handler) seals the chain with the per-session
handler and returns a startable server. The handler goes on serve(), not in
the config. This lets the builder collect the typed context that each use()
adds and pass it to the handler. A server without a handler is a compile error,
because the builder has no listen() until you call serve().
const server = createServer({
// optional, all with sensible defaults:
// auth, hostKey, idleTimeout, maxTimeout, limits, startupBanner, onError
})
.use(logging()) // optional middleware (see "Middleware" below)
.serve((session) => {
/* mount your app on session.renderer — REQUIRED */
})
await server.listen() // defaults to port 2222 on 127.0.0.1; pass (port, host) to changelisten(port = 2222, host = "127.0.0.1") returns { host, port, fingerprints }.
Pass 0 for an ephemeral port. Pass a host like "0.0.0.0" or "::" to listen
on all interfaces, which is common in containers. With no auth, listening on a
host other than localhost, 127.0.0.1, or ::1 logs a warning. It does not
throw, because an intentionally exposed TUI is valid.
`Session`
The handler you pass to serve() receives a Session with the live renderer.
Middleware receives a MiddlewareSession without renderer. The server
creates the renderer only after the chain authorizes the session, so a gating
middleware that declines never creates one. Everything else is shared:
| Field | What it is |
|---|---|
renderer |
A CliRenderer bound to this SSH channel, sized to the client PTY. Handler-only. A middleware's MiddlewareSession does not have it. The server destroys it when the session closes. |
identity |
Who connected and how, narrowed to your configured auth (see below). |
context |
Per-session object with the typed fields that upstream middleware added with next({...}). It is {} with no middleware. |
term |
The client's TERM (for example "xterm-256color"). |
cols / rows |
Current terminal size. See Native transport for resize timing. |
hasPty |
Whether the client requested a PTY. Use it for requirePty-style middleware. |
remoteAddress |
{ address, port? } client socket endpoint for logging, rate limiting, and policy. |
onResize(cb) |
Runs after the renderer accepts a new client size. |
onClose(cb) |
Runs once when the session closes for any reason. Do your own per-session cleanup here. The server destroys the renderer for you. |
write(data) |
Sends raw bytes to the client without the renderer's frame diffing. Use it for terminal control that the renderer does not model (OSC 52 clipboard, window title, a bell). |
end() |
Closes only this session. |
Native transport
Every shell uses Core's native scene and bounded Session output. Core, React, and
Solid applications use the same renderer attached to the SSH session.
createServer({}).serve((session) => {
session.renderer.root.add(
new TextRenderable(session.renderer, {
content: "Native SSH",
}),
)
})Each shell gets one Core native Session before middleware runs. Middleware
output, terminal setup, frames, raw writes, and restoration share its ordered
output budget. Core's Session driver writes to the SSH channel itself. It counts
bytes as delivered when each write callback runs, not on drain. SSH adds no
second output queue or wrapper stream. Core's production limits allow 8 MiB of retained output and
reserve one 64 KiB chunk of it for terminal control and restoration. One raw
write can therefore be at most 8,323,072 bytes (127 × 64 KiB) when nothing else
is queued. Queued and unacknowledged bytes count against the limit.
session.write() is synchronous and returns void. If the output is temporarily
full, it throws OutputPressureError with code "OUTPUT_PRESSURE", and the call
accepts no bytes. A single write larger than the Session limit throws RangeError
before string encoding. SSH does not queue, drop, or replay rejected writes.
Writes after the session closes do nothing. deny(reason) always closes the
session and throws DenyError, even when it cannot write the reason. The write
error goes to onError. Denial never creates a renderer.
Before the renderer attaches, cols and rows track the requested PTY size.
After it attaches, Core combines window changes while output is pending. The
session dimensions and onResize report only the size Core accepts, and the
renderer is already resized.
end() and onClose mark the logical close, not completed terminal restoration.
Before the renderer attaches, SSH waits for the Session to close before it closes
the channel. After the renderer attaches, SSH waits for renderer.closed. Core
allows one second for that output after the close starts. If output fails or the
time expires, the error goes to onError, Core cancels pending output without
restoration, and renderer.closed rejects. server.close() closes every live
session this way. Connection loss and peer shell close cancel the Session at once,
including during middleware. Cleanup does not wait for the ssh2 channel close
event, which unread input can delay.
The three hand-offs
The package gives you a CliRenderer. You mount any front-end onsession.renderer. Runnable versions of all three are inexamples/.
Imperative (`@opentui/core`)
createServer().serve((session) => {
const box = new BoxRenderable(session.renderer, { border: true })
session.renderer.root.add(box)
// no teardown to wire: the server destroys the renderer when the session closes
})React (`@opentui/react`)
createRoot adopts the existing renderer as it is. Seeexamples/react.tsx.
import { createRoot } from "@opentui/react"
createServer().serve((session) => {
const root = createRoot(session.renderer)
root.render(<App name={session.identity.username} />)
session.onClose(() => root.unmount()) // your own teardown
})Solid (`@opentui/solid`)
render(node, renderer) checks instanceof CliRenderer and adopts the
renderer you pass. The app draws onto the SSH channel, not the host terminal.
See examples/solid.tsx.
import { render } from "@opentui/solid"
createServer().serve(async (session) => {
// Solid disposes its root when the renderer is destroyed, so there is nothing to wire.
await render(() => <App name={session.identity.username} />, session.renderer)
})
@opentui/reactand@opentui/solidare not runtime dependencies of this
package. The framework examples use workspace dev dependencies only to show
the hand-off. Run the Solid example withbun run packages/ssh/examples/solid.tsx. Its launcher registers the required
JSX transform before it loads the app.
Auth & type-flowing identity
createServer infers the identity type from your auth config.session.identity covers only the methods you enabled, so you can read only the
fields you required. auth is optional and defaults to "open" (no auth), so
the getting-started snippet works on localhost.
// publickey-only → fingerprint is guaranteed present
createServer({ auth: { publicKey: "any" } }).serve((s) => s.identity.fingerprint) // ✅ string, no null check
// publickey + password → a union; discriminate on .method
createServer({ auth: { publicKey: "any", password: checkPw } }).serve((s) => {
if (s.identity.method === "publickey") s.identity.fingerprint // ✅ narrowed
})Supported methods (you can combine them, and the server advertises exactly what
you configure):
| Config | Behavior |
|---|---|
auth: "open" (or omit) |
Allow unauthenticated access. This is the default. Listening on a non-loopback host with it logs a warning. |
publicKey: "any" |
Accept and identify any key. The server verifies the signature (proof of possession), then sets identity.fingerprint. |
publicKey: { allow: (ctx) => boolean } |
Your own allow/deny over { username, fingerprint, publicKey }. It runs after the signature verifies. |
publicKey: { authorizedKeys: path | string[] } |
Allowlist from plain public-key lines. Blank lines and # comments are allowed. A line with OpenSSH options throws ConfigError. |
publicKey: { authorizedKeys, allow } |
Both. Admit the key if it is on the allowlist or allow returns true. |
password: (ctx) => boolean |
Password check over { username, password }. |
keyboardInteractive: (ctx) => boolean |
Prompt/response flow. |
@opentui/sshverifies public-key signatures itself, becausessh2does not.
SopublicKey: "any"proves possession of the private key instead of trusting
a claimed key.
For a restricted public-key server, see examples/authorized-keys.ts.
Middleware (`.use()`)
.use() wraps your handler with cross-cutting concerns: gating, enrichment, and
logging. Each middleware is one layer of an onion: (session, next) => Handoff.
Call next() to continue, or next({ ... }) to add typed fields tosession.context. Return that hand-off. If you forget, it is a compile error.
To gate, call session.deny(reason). It throws to unwind the chain, so you do
not return it.
- Registration order is execution order. The first-registered middleware
is the outermost link. await next()resolves when the session ends. The handler is the innermost
link. The server wraps it so that it does not resolve until the session closes.
So thefinallyblock intry { return await next() } finally { ... }runs
as teardown.- Contributions are inferred.
next({ tier: "free" })widenssession.contextby{ tier: string }with no generic to declare. Each.use
adds to the type, so a later link reads earlier links' typed fields and the
handler reads all of them.
import { createServer, type Middleware } from "@opentui/ssh"
// SETUP/TEARDOWN — author a reusable middleware by typing it as `Middleware`. Before
// next() is setup; the finally (after next() resolves when the session closes) is teardown.
const logging: Middleware = async (session, next) => {
const start = Date.now()
try {
return await next() // resolves when the session closes
} finally {
console.log(`${session.identity.username} stayed ${Date.now() - start}ms`)
}
}
createServer({ auth: "open" })
.use(logging)
// GATE — deny() throws to bounce; otherwise continue with next().
.use((s, next) => {
if (s.identity.username === "banned") s.deny("no entry")
return next()
})
// ENRICH — next({...}) contributes typed context the handler reads.
.use((_s, next) => next({ tier: "free" as const }))
.serve((s) => {
s.context.tier // "free" — typed, no cast
})Those three patterns (setup/teardown, gate, and enrich) cover almost
every case. Inline arrows need no annotation, because their identity andcontext types come from the builder. To name and reuse one, type it asMiddleware, or as MiddlewareFunction when it must read upstream context.
A gating middleware's deny() runs before the handler, so the handler never runs
and the server never creates the renderer. If the server can write the reason,
it appears on the main screen, and the alternate screen is never entered. If the
write fails, deny() still closes the session and throws DenyError.
Middleware sees a MiddlewareSession without the renderer. Only the handler'sSession has it. See examples/middleware.ts.
Built-in: `logging`
@opentui/ssh includes one ready-made middleware. logging() is a
setup/teardown link. It emits a connect event on entry and a disconnect event
(with duration) on teardown. It only observes. It never reports errors, soonError stays the single error sink. A throwing handler is logged as a normal
disconnect and still goes to onError.
import { createServer, logging } from "@opentui/ssh"
createServer({ auth: { publicKey: "any" } })
.use(logging()) // one line per event to console.log…
.use(logging({ log: (e) => metrics.record(e) })) // …or a structured sink
.serve((s) => mountApp(s.renderer))The log sink receives a LogEvent (type, identity, remoteAddress, term,cols/rows, and durationMs on disconnect). Omit it for a one-line default.
Host key
hostKey: {
path: "./host_key"
} // load if present; else generate ed25519 & persist (0600)
hostKey: {
pem: "..."
} // provide PEM directly
// omit entirely → ephemeral key, regenerated each start (fine for dev)The first run with a path generates and saves an ed25519 key. listen() prints
its fingerprint so clients can verify it. When you pass several PEMs, listen()
returns and prints every fingerprint in the same order.
Lifecycle, errors & shutdown
By default, the server allows one shell session per SSH connection and 100
across the server. It rejects excess shell requests without closing the SSH
connection or reporting an error. Adjust both positive-integer limits when an
application needs more concurrency:
const server = createServer({
limits: {
session: {
perConnection: 2,
global: 200,
},
},
}).serve(handler)A shell counts from the time the server accepts it, before middleware runs. It
keeps its slot until the server closes its SSH channel. These limits bound
application renderers. They do not replace authentication, network access
controls, connection-rate limiting, or process resource limits.
There is no lifecycle event bus and no pluggable logger. The work splits by
verb, with no overlap:
- react to a session → middleware +
session.onClose(deny, enrich, tear down) - observe the connection lifecycle → the
loggingmiddleware (connect/disconnect/duration) - report an error →
onError, the single error sink
let live = 0 // server-wide aggregate is just a counter
const server = createServer({
auth: "open",
idleTimeout: "10m", // reap a session after no client input ("30s", "500ms", or ms)
maxTimeout: "1h", // absolute session lifetime, regardless of activity
startupBanner: true, // set false to silence listen()'s summary
onError: (err) => console.error(err), // the one error sink; this is the default
}).serve((session) => {
live++
session.onClose(() => {
live-- // your own per-session bookkeeping (the renderer is torn down for you)
})
})- The handler and
session.onCloseare the per-session lifecycle. Set up on
entry, and tear down when the session closes. Put anything reusable or
cross-cutting in a middleware. onError(err)is the runtime error sink. Contained application and
transport errors go here: a throwing handler or middleware, a throwingonResizeoronClose, a throwing auth predicate, Session output failures,
and connection-level and server-levelssh2errors. It defaults toconsole.error. It reports errors. To react to a session, use middleware oronClose. To observe the lifecycle, use theloggingmiddleware. A bind
failure rejectslisten()instead of going here. The logger ignores its own
sink failures, so logging cannot affect a session.idleTimeoutcloses a session after that long with no client input.
Any client input restarts the timer. Only the idle session closes. Other sessions
and the listener continue. Durations must be between1msand24h.maxTimeoutcloses a session after that absolute lifetime, even when the
client keeps sending input. Durations must be between1msand24h.listen()binds, prints the startup banner (URL, host-key fingerprints,
auth methods, allowlist fingerprints) to stdout unlessstartupBanner: false,
and returns{ host, port, fingerprints }.close()rejects new shell requests and closes every live session, which
destroys its renderer. Then it ends every client connection and closes the
listener.
@opentui/ssh ▸ ssh://localhost:2222
host key SHA256:nThbg6kX…0bGQ (ssh-ed25519, generated ./host_key)
auth publickey, password
authorized 2 keys · SHA256:abc… SHA256:def…License
MIT
Overview (`packages/core/src/testing/README.md`)
Testing Utilities
Test utilities for opentui terminal UI testing.
Test Renderer
import { createTestRenderer } from "@opentui/core/testing"
const { renderer, mockInput, mockMouse, renderOnce, captureCharFrame, resize } = await createTestRenderer({
width: 80,
height: 24,
})
// Render once and capture output
await renderOnce()
const output = captureCharFrame()
// Resize terminal
resize(100, 30)Terminal Capabilities
import { createTerminalCapabilities, setRendererCapabilities } from "@opentui/core/testing"
const capabilities = createTerminalCapabilities({ kitty_keyboard: true })
setRendererCapabilities(renderer, { notifications: true })Mock Keyboard Input
import { createMockKeys, KeyCodes } from "@opentui/core/testing"
const mockInput = createMockKeys(renderer)
// Type text
mockInput.typeText("hello world")
await mockInput.typeText("hello", 10) // 10ms delay between keys
// Press single keys
mockInput.pressKey("a")
mockInput.pressKey(KeyCodes.ENTER)
// Press keys with modifiers
mockInput.pressKey("a", { ctrl: true })
mockInput.pressKey("f", { meta: true })
mockInput.pressKey("z", { ctrl: true, shift: true })
mockInput.pressKey(KeyCodes.ARROW_LEFT, { meta: true })
// Press multiple keys
mockInput.pressKeys(["h", "e", "l", "l", "o"])
await mockInput.pressKeys(["a", "b"], 10) // with delay
// Convenience methods
mockInput.pressEnter()
mockInput.pressEnter({ meta: true })
mockInput.pressEscape()
mockInput.pressTab()
mockInput.pressBackspace()
mockInput.pressArrow("up" | "down" | "left" | "right")
mockInput.pressArrow("left", { meta: true })
mockInput.pressCtrlC()
mockInput.pasteBracketedText("paste content")KeyCodes
Special keycodes available: RETURN, LINEFEED, TAB, BACKSPACE, DELETE, HOME, END, ESCAPE, ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, F1-F12
Modifiers
All pressKey(), pressEnter(), pressEscape(), pressTab(), pressBackspace(), and pressArrow() methods support an optional modifiers object:
{ ctrl?: boolean; shift?: boolean; meta?: boolean }Mock Mouse Input
import { createMockMouse, MouseButtons } from "@opentui/core/testing"
const mockMouse = createMockMouse(renderer)
// Click
await mockMouse.click(x, y)
await mockMouse.click(x, y, MouseButtons.RIGHT)
await mockMouse.click(x, y, MouseButtons.LEFT, {
modifiers: { ctrl: true, shift: true, alt: true },
delayMs: 10,
})
// Double click
await mockMouse.doubleClick(x, y)
// Press and release
await mockMouse.pressDown(x, y, MouseButtons.MIDDLE)
await mockMouse.release(x, y, MouseButtons.MIDDLE)
// Move
await mockMouse.moveTo(x, y)
await mockMouse.moveTo(x, y, { modifiers: { shift: true } })
// Drag
await mockMouse.drag(startX, startY, endX, endY)
await mockMouse.drag(startX, startY, endX, endY, MouseButtons.RIGHT, {
modifiers: { alt: true },
})
// Scroll
await mockMouse.scroll(x, y, "up" | "down" | "left" | "right")
await mockMouse.scroll(x, y, "up", { modifiers: { shift: true } })
// State
const pos = mockMouse.getCurrentPosition() // { x, y }
const buttons = mockMouse.getPressedButtons() // MouseButton[]MouseButtons
LEFT (0), MIDDLE (1), RIGHT (2)
WHEEL_UP (64), WHEEL_DOWN (65), WHEEL_LEFT (66), WHEEL_RIGHT (67)
Spy
Simple function spy for testing callbacks.
import { createSpy } from "@opentui/core/testing"
const spy = createSpy()
// Use as callback
someFunction(spy)
// Assertions
spy.callCount() // number
spy.calledWith(arg1, arg2) // boolean
spy.calls // any[][]
spy.reset()Test Recorder
Record frames during rendering for testing or analysis.
import { TestRecorder } from "@opentui/core/testing"
const { renderer, renderOnce } = await createTestRenderer({ width: 80, height: 24 })
const recorder = new TestRecorder(renderer)
// Start recording
recorder.rec()
// Add content and trigger renders
const text = new TextRenderable(renderer, { content: "Hello" })
renderer.root.add(text)
await Bun.sleep(1) // Wait for automatic render from add()
text.content = "World"
await Bun.sleep(1) // Wait for automatic render from content change
// Stop recording
recorder.stop()
// Access recorded frames
const frames = recorder.recordedFrames
console.log(`Recorded ${frames.length} frames`)
frames.forEach((frame) => {
console.log(`Frame ${frame.frameNumber} at ${frame.timestamp}ms:`)
console.log(frame.frame)
})
// Clear and start new recording
recorder.clear()
recorder.rec()TestRecorder API
rec()- Start recording framesstop()- Stop recording framesclear()- Clear all recorded framesisRecording- Check if currently recordingrecordedFrames- Get array of recorded frames (returns a copy)
RecordedFrame
Each frame contains:
frame: string- The captured frame contenttimestamp: number- Time in milliseconds since recording startedframeNumber: number- Sequential frame number (0-indexed)
Example
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
test("button click", async () => {
const { renderer, mockMouse, renderOnce, captureCharFrame } = await createTestRenderer({ width: 80, height: 24 })
const clicked = createSpy()
const button = new Button("btn", { text: "Click me", onClick: clicked })
renderer.add(button)
await renderOnce()
await mockMouse.click(10, 5)
expect(clicked.callCount()).toBe(1)
})Overview (`packages/examples/src/magick/README.md`)
Magick Arena
Magick Arena renders a Three.js scene through a two-slot NativeImagePool and ImageRenderable.
The demo requires Bun and WebGPU.
Controls
| Key | Action |
|---|---|
| WASD | Move the red wizard |
| Space | Pause or resume the scene |
| R | Reset time and player position |
| T | Cycle Kitty transport: raw, zlib, file |
| C | Show GPU, image transport, and renderer diagnostics |
Backtick or " |
Toggle the captured console |
. |
Toggle renderer statistics |
| Ctrl+G | Dump the hit grid |
| Shift+L / Shift+S / Shift+A | Start / stop / use automatic rendering |
| Escape | Return to the selector, or quit in standalone mode |
| Ctrl+C | Quit |
| Q | Quit in standalone mode only |
Pause stops continuous GPU readbacks, but pending or reset frames can still render.
Diagnostics remain available. Movement stops while the console is open or the terminal
loses focus. Legacy keyboard input uses approximate movement pulses instead of key-release events.
Rendering
The framebuffer defaults to 640x360 and stays fixed when the terminal resizes. Press T
to cycle transport without restarting the scene, including while paused. The status line
shows the requested and effective Kitty transport, or marks it inactive for other image
protocols. File and zlib modes can fall back to raw output. C shows the full transport
status. Returning to the selector restores its previous transport setting.
The demo reads GPU pixels into CPU memory. File transport sends a temporary file path,
not a shared GPU image.
The private GPU adapter is experimental and uses pinned bun-webgpu internals. It releases
per-frame GPU objects. Repeated GPU renderer creation still has a separate provider issue,
so the selector reuses one renderer across visits. It releases that renderer when the
terminal renderer closes. Each return to the selector releases scene resources, images,
and input listeners.
Overview (`packages/native/src/bench/README.md`)
OpenTUI Benchmarks
This directory contains benchmarks for the OpenTUI core library.
Running Benchmarks
From the packages/core directory:
bun run bench:native
# Include memory statistics
bun run bench:native --mem
# Or from packages/native:
bun run bench
bun run bench --memAdding New Benchmarks
To add a new benchmark:
- Create a new
*_bench.zigfile in thebench/directory - Import shared types from
bench-utils.zig:zigconst bench_utils = @import("../bench-utils.zig"); const BenchResult = bench_utils.BenchResult; const MemStats = bench_utils.MemStats; - Implement a
pub fn run(allocator: std.mem.Allocator, show_mem: bool) ![]BenchResultfunction:- Set up any benchmark-specific dependencies (grapheme pool, Unicode data, etc.)
- Run your benchmarks and collect results
- Return a slice of
BenchResult(caller will free it) - The
show_memflag indicates whether to include memory statistics
- Import it in
bench.zig:zigconst my_new_bench = @import("bench/my_new_bench.zig"); - Call it and print results in
main():zigconst my_results = try my_new_bench.run(allocator, show_mem); defer allocator.free(my_results); try bench_utils.printResults(stdout, my_results);
Each benchmark manages its own dependencies, so you only set up what you need.
See bench/text-buffer-view_bench.zig for a complete example.
Overview (`packages/native/src/tests/README.md`)
Test Suite
This directory contains the test suite for the OpenTUI Zig components.
Run All Tests
Run this command from packages/core:
bun run test:nativeIt checks the vendored Zig dependency checksums, runs zig build test, then
tests and runs examples/hello.
Measure-target lifetime
The default suite already checks that destroying a view or buffer clears
matching Yoga measure targets, then layouts again. For allocator accounting on
that same C export path, run:
bun run test:native:lifetimeThat Debug probe turns on GPA leak checks and fails if requested bytes grow
across teardown cycles. Use it after Yoga ownership or measure-target lifetime
changes.
Adding New Test Files
- Create a new
*_test.zigfile in this directory - Import it in
../test.zig:zigconst new_tests = @import("tests/new_test.zig"); - Reference it in the
testblock at the end of../test.zig, or Zig skips its tests:zig_ = new_tests; - Update the build system if needed to include any new dependencies
Overview (`packages/web/public/recordings/README.md`)
Terminal Illustration Recordings
The files in this directory contain terminal recordings for <TerminalIllustration>.
The recording tools are in scripts/recordings/. They convert an asciinema .cast file to a story .json file.
Install asciinema on your PATH before you use the tools. The package supplies @xterm/headless as a development dependency.
Player Options
Each story can set these options:
tone="inherit"recolors cells from their luminance. It uses the component color or theinkoverrides.tone="recorded"uses the recorded foreground colors.background="transparent"does not paint cell backgrounds.background="recorded"paints recorded backgrounds and restores the recorded default foreground.padding={false}removes the terminal-colored margin added around a recorded background.fadeTop,fadeRight,fadeBottom, andfadeLeftset an edge fade from0to1.titleandcaptionsupply text for the controls.
The default values are tone="inherit" and background="transparent".
Use tone="recorded" for content in which color conveys information. Use a recorded background when foreground contrast depends on it.
The player calculates fades from cell opacity to keep glyphs sharp. Horizontal fades use the midpoint of each styled run.
Do not use a horizontal fade for prose with long runs. Set fadeLeft and fadeRight to 0.
Multiple stories and chapters play in order and repeat. The controls option accepts these values:
hidden: Do not show controls. This is the default.hover: Show overlay controls on hover or focus.visible: Always show overlay controls.below: Show controls and captions below the recording.
The player loads a recording when it is within 800px of the viewport.
See StoryConfig and Props in src/components/TerminalIllustration.astro for all options and defaults.
Use In Scrollback
Use the Scrollback wrapper in an .mdx post:
import ScrollbackRecording from "../../components/ScrollbackRecording.astro"
<ScrollbackRecording
label="OpenTUI terminal recording"
stories={[
{
src: "/recordings/anomaly.json",
title: "OpenTUI",
tone: "inherit",
},
]}
/>Use controls="below" when chapter captions are part of the article.
Record A Cast
Run the record tool from packages/web:
scripts/recordings/bin/record out.cast
scripts/recordings/bin/record out.cast -- node demo.mjs
scripts/recordings/bin/record --headless out.cast -- node demo.mjs
scripts/recordings/bin/record --window-size 100x56 out.cast -- bun src/app.tsThe tool is also available as bun run recordings:record.
If the application uses another working directory, store the absolute tool path first:
record="$PWD/scripts/recordings/bin/record"Automatic Applications
Use --headless for an application that needs no input. Use timeout to set the duration.
cd ~/src/tuiexperiments/some-headless-app
"$record" --headless --window-size 210x42 /tmp/my-recording.cast \
-- timeout 16 bun src/app.tsUse a wide, short window for a recording that fills a wide media area.
Applications With Setup Input
Use tmux when an application needs a small amount of setup input:
tmux new-session -d -s rec -x 200 -y 46
tmux send-keys -t rec -l "cd ~/src/tuiexperiments/some-app && $record /tmp/my-recording.cast -- bun start"
tmux send-keys -t rec Enter
sleep 3
tmux send-keys -t rec -l 's'
sleep 30
tmux send-keys -t rec -l 'q'
tmux kill-session -t recMake sure that setup keys changed the application before you keep a long recording.
Use tmux capture-pane -e -p to examine colors and escape sequences. Plain-text output cannot confirm a color change.
Interactive Applications
Record continuous interaction directly:
cd ~/src/tuiexperiments/some-app
"$record" /tmp/my-recording.cast -- bun src/app.tsUse ctrl+x to add chapter markers. Use many small inputs when motion must appear continuous.
The build tool keeps only changed frames. A few large inputs produce only a few frames.
Build A Story
Copy the cast to the tool directory and build it:
cp /tmp/my-recording.cast scripts/recordings/my-recording.cast
bun run recordings:build scripts/recordings/my-recording.castThe first build creates scripts/recordings/my-recording.story.config.json. It also writes public/recordings/my-recording.json.
Edit the generated config to set chapter boundaries, captions, and durations. Then build from the config:
bun run recordings:build scripts/recordings/my-recording.story.config.jsonCommit the config and the generated story. The repository ignores the cast.
Check The Story
Check these items before you ship the story:
- Set
atMsafter transient shell output and before the first useful frame. - Remove persistent title bars, borders, labels, and instructions that do not belong in the composition.
- Set
durationMsto one cycle for a deterministic animation. The player repeats the story. - Make sure that setup keys changed the recorded mode. Examine
rowswhen you must confirm cell colors. - Use a recorded tone for prose, tables, and other content that must remain legible.
- Check the final aspect ratio after each crop.
The fit="cover" mode sets the font size from the available height. A narrow recording can leave horizontal empty space.
Use this minimum ratio for a full-width composition:
cols / rows >= (boxWidth / boxHeight) * (lineToEm / chToEm)For OpenTUI Mono, chToEm is approximately 0.6 and lineToEm is approximately 1.1.
A media-area ratio from 1.2 to 1.3 usually needs a recording ratio from approximately 2.2 to 2.4.
Record a wider grid or crop rows if the recording does not meet the minimum ratio.
Ship The Story
Reference the generated file from a page or a Scrollback post:
<TerminalIllustration
label="OpenTUI terminal recording"
stories={[{ src: "/recordings/my-recording.json", tone: "inherit" }]}
controls="visible"
/>The build writes the file to public/recordings/. Do not copy it after the build.
Overview (`packages/web/src/font/README.md`)
OpenTUI Mono
OpenTUI Mono is the typeface for the site. Iosevka compilesprivate-build-plans.toml into static WOFF2 files. Iosevka does not write
an OpenType variable font.
The live files are Regular, Bold, Italic, and Bold Italic inpublic/fonts/. /lab/font compares those files with the next compile inpublic/fonts/candidate/.
Plan
The plan sets glyphs, metrics, weights (100 to 900), and widths (Normal +
SemiCondensed). Ligatures are off. The plan makes one web-subset family.
Change keys in the plan. Do not start from a blank UFO or FontForge file.
Identity glyphs: a g l I 1 0 8 6 9 @ { } ( ) _ *.
These keys change the look first: sb, width shape, xHeight,archDepth, and the identity keys in variants.design.
Compile
You need Node 18+ and a clone of Iosevka
at v34.4.0. You do not need ttfautohint for webfont-unhinted.
git clone --depth 1 --branch v34.4.0 https://github.com/be5invis/Iosevka.git
cp packages/web/src/font/private-build-plans.toml Iosevka/
cd Iosevka && npm install
npm run build -- --jCmd=4 webfont-unhinted::OpenTUIMonoCopy the next compile into packages/web/public/fonts/candidate/ asOpenTUIMono-{Regular,Bold,Italic,BoldItalic}.woff2. Compare it with
the live files at /lab/font. When you accept the next compile, copy those
four files to packages/web/public/fonts/.
Overview (`packages/core/src/renderables/composition/README.md`)
Composition Exploration
This is a simple exploration of how to compose renderables into a tree.
It's not react and not reactive, it's just a way to compose renderables
and mount them into a parent container.
It's a work in progress and not in any way meant to be a replacement for react.
Overview (`packages/core/src/lib/tree-sitter/assets/README.md`)
Tree-sitter Assets
This directory contains downloaded tree-sitter language parsers and highlight queries for the default parsers used by OpenTUI.
They are included in the repository to avoid downloading them every time the project is built or tests are run.
Asset Management
Parser definitions are configured in ../parsers-config.ts for OpenTUI's built-in parser set.
Update Script
The update.ts script downloads parsers and queries from the configured URLs and generates the ../default-parsers.ts file.
Usage
For OpenTUI Core Development (using default paths):
# Run from this directory
bun update.ts
# Or from the project root
bun packages/core/src/lib/tree-sitter/assets/update.tsFor Application Developers (using custom paths):
# CLI usage with custom paths
bun update.ts \
--config ./my-parsers-config.json \
--assets ./src/tree-sitter/assets \
--output ./src/tree-sitter/parsers.ts
# Show help
bun update.ts --helpProgrammatic Usage:
import { updateAssets } from "@opentui/core/tree-sitter/update-assets"
await updateAssets({
configPath: "./my-parsers-config.json",
assetsDir: "./src/tree-sitter/assets",
outputPath: "./src/tree-sitter/parsers.ts",
})What it does
- Downloads Language Parsers: Downloads
.wasmfiles from GitHub releases - Downloads Query Files: Downloads
.scmhighlight query files from repositories - Combines Queries: For languages like TypeScript, combines multiple query files (JS base + TS extensions)
- Generates Imports: Creates a TypeScript file with proper file imports using Bun's
with { type: "file" }syntax
Adding New Parsers
For Application Developers
If you're using OpenTUI in your application and want to add support for additional languages:
1. Create a parsers configuration file
Create a parsers-config.json file in your project with the structure:
{
"parsers": [
{
"filetype": "python",
"wasm": "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.20.4/tree-sitter-python.wasm",
"queries": {
"highlights": [
"https://raw.githubusercontent.com/tree-sitter/tree-sitter-python/refs/heads/master/queries/highlights.scm"
]
}
}
]
}2. Add to your build pipeline
Add the update script to your package.json:
{
"scripts": {
"prebuild": "bun node_modules/@opentui/core/lib/tree-sitter/update-assets.js --config ./parsers-config.json --assets ./src/parsers --output ./src/parsers.ts",
"build": "bun build ./src/index.ts"
}
}3. Use the generated parsers
import { getTreeSitterClient } from "@opentui/core"
import { getParsers } from "./parsers"
const client = getTreeSitterClient()
// Register your custom parsers
for (const parser of getParsers()) {
client.addFiletypeParser(parser)
}For more information about using Tree-Sitter in your application, see the Tree-Sitter guide.
For OpenTUI Core Developers
To add a new default parser to OpenTUI Core:
- Update Configuration: Add the new parser to
../parsers-config.ts - Run Update Script: Execute
bun update.tsto download assets and regenerate imports - Commit Generated Assets: Include the downloaded parser/query files and regenerated
../default-parsers.ts
Packages Web Src Content Docs Getting Started (`packages/web/src/content/docs/getting-started.mdx`)
title: Getting started skill: entry: true intents: [getting-started, intro, examples, agent-skill]
Getting started
OpenTUI is a native terminal UI core written in Zig with TypeScript bindings.
It renders a component tree, lays it out with Flexbox, handles terminal input, and updates the cells that changed.
OpenTUI's TypeScript packages call the Zig library through its native interface. OpenTUI powers OpenCode in production.
Start with the quickstart
Build your first app in the quickstart. In the quickstart, you create a project
from an empty directory, compose a small interface, handle keyboard input, and run it in your terminal.
The quickstart uses @opentui/core directly to teach the renderer, component tree, input, and layout model once. You
do not need to choose a framework before you complete it.
Check Runtime and platform support before you use Node.js, cross-compile, or
deploy to another target.
Choose a framework
@opentui/coreuses imperative renderables and events directly.@opentui/reactuses React components, hooks, and state.@opentui/soliduses Solid components, signals, and effects.
If you use Core, read Renderables after the quickstart.
Find a topic
- Components compares component availability across Core, React, and Solid.
- Renderer covers terminal setup, the root, scheduling, and central events.
- Layout covers Yoga-based sizing and positioning.
- Interaction, focus, and selection explains shared pointer and focus behavior.
- Testing renders apps in memory for assertions and snapshots.
- Package entry points lists supported import paths.
- Lifecycle and cleanup owns terminal restoration and resource release.
- Native rendering explains the scene model and native C and Zig interfaces.
Explore the examples
Run the OpenTUI example browser without cloning the repository. Release executables support macOS x64 and arm64,
Linux x64, and Windows x64. On those targets, including x64 WSL and Git Bash:
curl -fsSL https://raw.githubusercontent.com/anomalyco/opentui/main/packages/examples/install.sh | shOn Windows, download the latest release from GitHub Releases.
Give an agent the docs
OpenTUI includes a skill that teaches AI coding assistants its APIs and patterns. It holds these docs and the release
notes. Install the skill of the latest release with npx skills:
npx skills add https://opentui.com --skill opentuiAdd -g to install the skill globally for every project. OpenCode uses the same install command. npx skills update
moves the skill to the newest release. For the main branch, install from https://opentui.com/docs/next instead.
A skill installed with npx skills add anomalyco/opentui follows the main branch. Run the command above to switch it
to releases.
Versions
These docs describe one release. The site header names it, and next beside it opens the docs of the main branch,
which include unreleased changes. Releases lists what changed in each release, and the API reference
names the release that added each export. Above its previous and next links, a page lists its changes in recent
releases.
Packages Web Src Content Docs Components Overview (`packages/web/src/content/docs/components/overview.mdx`)
title: Components skill: entry: true intents: [components, component, component-support, support-matrix, react-components, solid-components]
Components
The tables compare each component's availability across Core, React, and Solid.
An automatic framework element needs no component-specific registration. Separately registered elements require the package setup shown on their component page. Unavailable means that the shipped API does not expose that surface.
Display and layout
- Text displays labels, prose, and styled text. Choose Code for parsed source or Markdown for documents.
- Box lays out children and can draw a background, border, and title. Choose ScrollBox when the children must scroll.
| Component | Package | Core renderable | React | Solid | Status |
|---|---|---|---|---|---|
| Text | @opentui/core |
TextRenderable |
<text> (automatic) |
<text> (automatic) |
Built in |
| Box | @opentui/core |
BoxRenderable |
`` (automatic) | `` (automatic) | Built in |
Inline text elements
React and Solid also register , , , , , , ``, and <a>. These elements are
built-in text children, not standalone layout components. Use them only inside <text>. The <a> element acceptshref and creates terminal hyperlink metadata.
| Surface | Package | Core renderable | React | Solid | Status |
|---|---|---|---|---|---|
| Inline styling | @opentui/react, @opentui/solid |
TextNodeRenderable |
, , , , , |
, , , , , |
Built-in framework text nodes |
| Line break | @opentui/react, @opentui/solid |
TextNodeRenderable |
`` | `` | Built-in framework text node |
| Hyperlink | @opentui/react, @opentui/solid |
TextNodeRenderable |
<a href> |
<a href> |
Built-in framework text node |
Input and selection
- Input edits one line of text. Choose Textarea for multi-line editing.
- Textarea edits multiple lines with cursor and selection support. Choose Input for one line.
- Select shows discrete choices in a vertical list. Choose TabSelect for a horizontal strip or Slider for a continuous value.
- TabSelect shows peer choices in a horizontal tab strip. Choose Select for a vertical list.
- Slider selects a continuous value with a draggable thumb. Choose Select for discrete choices.
| Component | Package | Core renderable | React | Solid | Status |
|---|---|---|---|---|---|
| Input | @opentui/core |
InputRenderable |
`` (automatic) | `` (automatic) | Built in |
| Textarea | @opentui/core |
TextareaRenderable |
<textarea> (automatic) |
<textarea> (automatic) |
Built in |
| Select | @opentui/core |
SelectRenderable |
<select> (automatic) |
<select> (automatic) |
Built in |
| TabSelect | @opentui/core |
TabSelectRenderable |
<tab-select> (automatic) |
<tab_select> (automatic) |
Built in |
| Slider | @opentui/core |
SliderRenderable |
Unavailable | Unavailable | Built-in Core renderable |
Scrolling
- ScrollBox owns scrollable children, clipping, and scrollbars. Use it for normal scrolling containers.
- ScrollBar controls an external scroll position. Use it when another model owns the scroll state.
| Component | Package | Core renderable | React | Solid | Status |
|---|---|---|---|---|---|
| ScrollBox | @opentui/core |
ScrollBoxRenderable |
<scrollbox> (automatic) |
<scrollbox> (automatic) |
Built in |
| ScrollBar | @opentui/core |
ScrollBarRenderable |
Unavailable | Unavailable | Built-in Core renderable |
Rich content
- Code displays syntax-highlighted source. Choose Markdown for documents or Diff for patches.
- Markdown renders document structure and fenced code. Choose Code when all content is source code.
- Line number gutter adds numbers and signs to line-aware content. Diff manages its own gutters.
- Diff displays the first file patch in unified or split form. Create one instance for each file patch.
- TextTable lays out styled cell data. Choose Markdown when the table starts as Markdown source.
| Component | Package | Core renderable | React | Solid | Status |
|---|---|---|---|---|---|
| Code | @opentui/core |
CodeRenderable |
<code> (automatic) |
<code> (automatic) |
Built in |
| Markdown | @opentui/core |
MarkdownRenderable |
<markdown> (automatic) |
<markdown> (automatic) |
Built in |
| Line number gutter | @opentui/core |
LineNumberRenderable |
<line-number> (automatic) |
<line_number> is runtime built in. The Solid binding lacks an exact props declaration. |
Built in with a Solid typing limitation |
| Diff | @opentui/core |
DiffRenderable |
<diff> (automatic) |
<diff> is runtime built in. The Solid binding lacks an exact props declaration. |
Built in with a Solid typing limitation |
| TextTable | @opentui/core |
TextTableRenderable |
Unavailable | Unavailable | Built-in Core renderable |
OpenTUI bundles parsers for JavaScript and JSX, TypeScript and TSX, Markdown and Markdown inline, and Zig. Other grammars require Tree-sitter configuration.
Graphics and media
- ASCIIFont draws short text with bundled ASCII art fonts. Choose Text for labels and prose.
- FrameBuffer exposes direct cell drawing. Choose Image when you only need to display an image source.
- Image displays encoded images or a
NativeImage. Use theNativeImageAPI to decode or transform pixels. - QR code displays text as a scannable symbol. Use the QR encoder for matrices and serialized output.
- Embedded terminal parses VT output and draws a child terminal. It does not start a process.
| Component | Package | Core renderable | React | Solid | Status |
|---|---|---|---|---|---|
| ASCIIFont | @opentui/core |
ASCIIFontRenderable |
<ascii-font> (automatic) |
<ascii_font> (automatic) |
Built in |
| FrameBuffer | @opentui/core |
FrameBufferRenderable |
Unavailable | Unavailable | Advanced |
| Image | @opentui/core |
ImageRenderable |
`` (automatic) | `` (automatic) | Built in |
| QR code | @opentui/qrcode |
QRCodeRenderable |
Register @opentui/qrcode/react, then use <qr-code> |
Register @opentui/qrcode/solid, then use <qr_code> |
Separately registered |
| Embedded terminal | @opentui/core |
EmbeddedTerminalRenderable |
Unavailable | Unavailable | Built-in Core renderable |
Use the Buffer API for low-level OptimizedBuffer ownership and drawing operations.
Diagnostics
TimeToFirstDraw is a diagnostic, not a general display component. See Rendering diagnostics for its timestamp behavior.
Packages Web Src Content Docs Getting Started Quickstart (`packages/web/src/content/docs/getting-started/quickstart.mdx`)
title: Quickstart skill: entry: true intents: [installation, quickstart]
{/* docs-lint-disable mdx-esm-import */}
import ScrollbackRecording from "../../../components/ScrollbackRecording.astro"
Quickstart
Build a small counter controlled by the arrow keys. The finished app uses layout, text, keyboard input, live updates,
and explicit cleanup.
Prerequisite
This guide uses Bun to run TypeScript source. See Runtime and platform support
for other runtime and platform requirements.
Create an empty project and add OpenTUI:
mkdir my-tui && cd my-tui
bun init -y
bun add @opentui/coreCreate the app
Create index.ts:
import { BoxRenderable, TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({
exitOnCtrlC: true,
backgroundColor: "#1131E9",
})
let count = 0
const counter = new TextRenderable(renderer, {
id: "counter",
content: "Count 0",
fg: "#FFFFFF",
})
const panel = new BoxRenderable(renderer, {
width: 42,
height: 9,
backgroundColor: "#1131E9",
alignItems: "center",
justifyContent: "center",
})
const content = new BoxRenderable(renderer, {
width: 38,
height: 7,
backgroundColor: "#2947F0",
padding: 1,
flexDirection: "column",
gap: 1,
alignItems: "center",
})
content.add(new TextRenderable(renderer, { content: "Hello, OpenTUI!", fg: "#DCE3FF" }))
content.add(counter)
content.add(new TextRenderable(renderer, { content: "left/right change | q quit", fg: "#AEBBFF" }))
panel.add(content)
renderer.root.add(panel)
renderer.keyInput.on("keypress", (key) => {
if (key.name === "q") {
renderer.destroy()
return
}
if (key.name === "left") count--
else if (key.name === "right") count++
else return
counter.content = `Count ${count}`
})createCliRenderer() takes control of the terminal and returns a CliRenderer. Its root property is the root of the
component tree. BoxRenderable and TextRenderable are imperative tree nodes. The event handler updates the counter'scontent property directly.
The renderer emits parsed keyboard events through keyInput. The handler changes counter.content, which schedules a
new frame.
Run it
bun index.tsPress the Left and Right arrow keys. The count updates in place.
The application creates and owns the renderer. It must call renderer.destroy() on every shutdown path to release
resources and restore the terminal. This example calls it when you press q. exitOnCtrlC: true handles the Ctrl+C
path.
{/* docs-lint-disable mdx-component-node */}
<ScrollbackRecording
label="The quickstart app changing a count with arrow keys, then returning to the shell"
controls="hidden"
stories={[
{
src: "/recordings/quickstart.json",
tone: "recorded",
background: "recorded",
padding: false,
},
]}
/>
Continue
- Return to the OpenTUI introduction for the documentation map.
- React bindings apply the shared model through React.
- Solid bindings apply the shared model through Solid.
- Components compares component availability across Core, React, and Solid.
- Renderables covers imperative tree creation and mutation.
- Lifecycle and cleanup covers shutdown paths and resource ownership.
Packages Web Src Content Docs Keymap Overview (`packages/web/src/content/docs/keymap/overview.mdx`)
title: Keymap skill: entry: true intents: [keymap, keybindings, shortcuts, commands, leader, ex-commands]
Keymap
@opentui/keymap is an independent, host-agnostic package for layered key bindings, commands, and key sequences.
The package lets an app register an action once and resolve it from the current focus. An editor can save from every
view, close only the focused dialog, and show its current shortcuts.
Install
bun add @opentui/keymapThe engine is JavaScript. The OpenTUI adapter connects it to terminal input from CliRenderer.
The HTML adapter connects the same engine to browser keyboard events.
Create an OpenTUI keymap
Create the renderer first. Then create a default OpenTUI keymap and register an application layer.
import { createCliRenderer } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
const renderer = await createCliRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
keymap.registerLayer({
commands: [
{
name: "file.save",
run() {
console.log("save")
},
},
{
name: "app.quit",
run() {
renderer.destroy()
},
},
],
bindings: [
{ key: "ctrl+s", cmd: "file.save" },
{ key: "q", cmd: "app.quit" },
],
})createDefaultOpenTuiKeymap() installs the default parser, event matcher, enabled fields, and metadata fields.
Each registration returns a disposer. Destroying the renderer also ends the host lifecycle and releases keymap resources.
See Lifecycle and cleanup for application shutdown.
ctrl+s file.save
q app.quitRegister, dispatch, and query
Keymap uses one short model:
| Step | What happens | Main APIs |
|---|---|---|
| Register | The app adds commands, key bindings, layers, tokens, patterns, and addons. | registerLayer(), registerToken(), addon registration functions |
| Dispatch | The host sends an event. The engine applies focus, conditions, layer order, sequences, and command resolution. | Host listeners, dispatchCommand(), intercepts |
| Query | The app reads the commands and next keys that are active now. | getActiveKeys(), getCommands(), getPendingSequence() |
A layer can be global or attached to a host target. Higher priority values run first.
Newer layers run first when priorities match. A focused target controls which local layers are active.
A key sequence stays pending until another key completes it, rejects it, or clears it.
Queries use that pending state, so a shortcut view can show only the next reachable keys.
The Core keymap API defines key syntax, field compilation, runtime data, command queries,
dispatch results, ordering, errors, and sequence limits.
Choose the input level
Use direct keyboard events when one owner needs the original KeyEvent.
Examples include a global Ctrl+C handler or terminal-protocol diagnostics.
Use a component's local key binding options for behavior that belongs only to that component.
Examples include cursor movement in an input or selection movement in a list.
Move to Keymap when the same application command has more than one owner or context. Keymap is useful when you need:
- focus-scoped or priority-ordered layers
- user-configurable key bindings
- multi-key sequences or leader keys
- named commands for palettes and help
- one command model across Core, React, and Solid code
Keymap does not replace component text input. The OpenTUI textarea addon can route editing commands through Keymap when
an application needs to manage those key bindings centrally.
Hosts and framework state
The Hosts page defines KeymapHost and the built-in OpenTUI and HTML adapters.
Hosts supply focus, hierarchy, key events, metadata, and lifecycle signals.
React and Solid do not create a keymap. Their providers consume a pre-created OpenTUI keymap and expose reactive state:
- React keymap integration supplies React context and hooks.
- Solid keymap integration supplies Solid context, selectors, and hooks.
Add features
The bare engine has no fixed key language. Addons install parsers, field compilers, sequence behavior, diagnostics, and
OpenTUI integrations through public registration APIs.
- Built-in keymap addons lists every shipped universal and OpenTUI addon.
- Custom keymap addons explains addon callbacks, ordering, cleanup, and tests.
Next
- Hosts
- Core keymap API
- React keymap integration
- Solid keymap integration
- Built-in keymap addons
- Custom keymap addons
Packages Web Src Content Docs Native Overview (`packages/web/src/content/docs/native/overview.mdx`)
title: Native rendering skill: entry: true intents: [native, native-rendering, native-scene, native-api]
Native rendering
OpenTUI's native library turns a tree of UI nodes into terminal output.
You can call it from C or Zig without JavaScript.
This walkthrough follows one small interface: a bordered box containing the label Hello.
Its drawing area is 12 columns wide and three rows high:
┌──────────┐
│Hello │
└──────────┘Your program describes the box and label. The native library calculates their positions, draws their cells, and produces terminal output.
The program then delivers that output to its destination.
After the first frame, you will change the label to Ready and render again without rebuilding the tree.
Describe the interface as a tree
The box and label need three native nodes. Each node stores one part of the interface:
┌─ Scene ─────────────────────────┐
│ root │
│ └─ box: single-line border │
│ └─ label: "Hello" │
└─────────────────────────────────┘The root is the top of the tree. The box contains the label.
Adding the label to the box establishes that relationship. It does not specify where to draw the first character.
Nodes store layout properties, such as width and padding, and drawing properties, such as background color and borders.
A text node also stores its content. Node creation, property changes, and parent-child relationships are native API operations.
The native library keeps this tree, or scene, between frames.
This is the retained scene model: change the nodes you already have, then render them again.
You do not submit a fresh list of drawing commands for each frame.
Calculate positions and sizes
Layout turns the tree's relationships and size rules into positions and dimensions.
The scene uses Yoga, a flexbox layout engine, with one Yoga node for each scene node.
Dimensions count terminal cells, not pixels.
The root fills the drawing area, and the box stretches across it.
The border takes one cell on each side, leaving ten cells for the label.
That places Hello at column 1, row 1, just inside the border.
Coordinates start at zero.
You did not calculate the text position yourself. The border and parent-child relationship determine it.
The same layout rules also handle padding, alignment, flexible sizes, and nested containers.
When you change a layout property, the next layout pass calculates the result.
Layout queries read stored geometry. They do not start a new pass.
Draw a grid of cells
After layout, the scene paints into an in-memory grid called the framebuffer.
The box draws its border, then the label draws Hello inside it.
Each cell stores character information, foreground and background colors, and attributes such as bold or underline.
The H in Hello occupies one cell. Other text can occupy several cells or combine several code points into one displayed character.
Text drawing APIs handle that conversion. A UTF-8 byte count is not a terminal-cell width.
Painting changes the framebuffer, not the terminal. No terminal connection is necessary at this stage.
Send the result to an output destination
A terminal accepts bytes, not a node tree or a framebuffer.
The native renderer encodes the cells as text, cursor movement, colors, and other control sequences.
It compares cells with its existing encoding state, so an update need not send the entire screen again.
The bytes wait in an output queue until your program delivers them.
Your program can copy them out and write them itself.
It can also let the native library write them to standard output or pass them to a writer function that your program supplies.
The delivery path is the transport. You can write to a file, a local terminal, or a connection.
The diagram shows the copy path:
┌─ Native library ────────────────┐
│ Scene nodes │
│ ↓ layout and painting │
│ Grid of terminal cells │
│ ↓ terminal encoding │
│ Queued output bytes │
└────────────────┬────────────────┘
↓ copy
┌─ Your program ──────────────────┐
│ Write bytes to the destination │
│ Report completed delivery │
└─────────────────────────────────┘A slow connection can still be sending a frame after painting finishes.
The program reports delivery as bytes reach the transport. Once delivery covers the frame's output, the native library marks that frame as presented.
This does not prove that a remote terminal visibly displayed it.
Change the label, not the tree
After the first frame completes, set the label's text to Ready and repeat painting, encoding, and delivery:
┌──────────┐
│Ready │
└──────────┘The root, box, and label are the same nodes. Their relationships and border settings did not change.
Only the label's content changed. The retained scene produces the new frame.
Layout, painting, and encoding still have a cost.
Retaining the scene means your program describes changes instead of directing every node's drawing on every frame.
Build the program around the scene
Your program is the host. It owns application state and decides when to render.
The native library does not start an application event loop or a rendering worker.
An interactive host reads input, changes nodes, and requests frames.
The programs in the language guides create this box and label, render Hello, then change it to Ready.
They write both frames to files, using the same operations as a terminal application.
Continue with Resources and ownership for object lifetimes, then Frames and output for drawing and delivery.
The C application binary interface (ABI) is experimental. Use the native library, headers, and bindings from the same revision.
Core provides a TypeScript application API over this library through Renderable and CliRenderer, not the native frame protocol.
How Core uses native explains that integration separately.
Read Host I/O and time before connecting a terminal transport.
Then choose the C or Zig guide to build and run the box-and-label program.
Packages Web Src Content Docs Reference Api Index (`packages/web/src/content/docs/reference/api-index.mdx`)
title: API and symbol index skill: entry: true intents: [api, symbols, exports, public-api, api-index, lookup]
API and symbol index
Use this page to find every published OpenTUI entry point and named export. The tables come from the generated publish maps and source barrels. They include runtime values and type-only exports.
The API reference lists the same exports with their signatures and the release that added each one. Releases lists the API changes of each release.
Support classes
| Class | Meaning |
|---|---|
| Supported | This is a normal supported API. |
| Advanced | This API needs direct ownership, lifecycle, rendering, or integration knowledge. |
| Experimental | This API can change as its design develops. |
| Runtime-specific | This API works only in the runtime shown in its row. |
| Deprecated | Keep this API only while you move to its replacement. |
| Implementation-looking public export | The export is publicly reachable today, but OpenTUI does not endorse it for ordinary use. It is still indexed here. |
A row can have more than one class. Read Runtime and platform support for the supported Bun and Node.js versions.
Entry point index
The runtime column describes the published target. A Node stub throws during import for each Bun-only support entry point.
| Package entry point | Runtime | Class | Canonical documentation |
|---|---|---|---|
@opentui/core |
Bun and Node.js | Supported | Renderer |
@opentui/core/testing |
Bun and Node.js | Supported | Testing |
@opentui/core/runtime-plugin |
Bun only | Runtime-specific, Advanced | Runtime module loading |
@opentui/core/runtime-plugin-support |
Bun only | Runtime-specific, Advanced | Runtime module loading |
@opentui/core/runtime-plugin-support/configure |
Bun only | Runtime-specific, Advanced | Runtime module loading |
@opentui/core/yoga |
Bun and Node.js | Advanced | Yoga API |
@opentui/core/tree-sitter/update-assets |
Build time, with Bun CLI | Runtime-specific | Tree-sitter |
@opentui/core/parser.worker |
Bun and Node.js worker | Implementation-looking public export | Tree-sitter |
@opentui/core/node-assets |
Node.js build time | Runtime-specific | Standalone executables |
@opentui/react |
Bun and Node.js | Supported | React bindings |
@opentui/react/renderer |
Bun and Node.js | Supported, Implementation-looking public export | React bindings |
@opentui/react/test-utils |
Bun and Node.js | Supported | Testing |
@opentui/react/runtime-plugin-support |
Bun only | Runtime-specific, Advanced | Runtime module loading |
@opentui/react/runtime-plugin-support/configure |
Bun only | Runtime-specific, Advanced | Runtime module loading |
@opentui/react/jsx-runtime |
Compiler selected | Supported | React bindings |
@opentui/react/jsx-dev-runtime |
Compiler selected | Supported | React bindings |
@opentui/solid |
Bun and Node.js | Supported | Solid bindings |
@opentui/solid/preload |
Bun only | Runtime-specific | Solid bindings |
@opentui/solid/bun-plugin |
Bun only | Runtime-specific, Advanced | Solid bindings |
@opentui/solid/runtime-plugin-support |
Bun only | Runtime-specific, Advanced | Runtime module loading |
@opentui/solid/runtime-plugin-support/configure |
Bun only | Runtime-specific, Advanced | Runtime module loading |
@opentui/solid/components |
Bun and Node.js | Advanced | Solid bindings |
@opentui/solid/jsx-runtime |
Compiler selected | Supported | Solid bindings |
@opentui/solid/jsx-dev-runtime |
Compiler selected | Supported | Solid bindings |
@opentui/keymap |
JavaScript runtimes | Supported | Core keymap API |
@opentui/keymap/extras |
JavaScript runtimes | Supported | Core keymap API |
@opentui/keymap/extras/graph |
JavaScript runtimes | Advanced | Core keymap API |
@opentui/keymap/addons |
JavaScript runtimes | Supported | Built-in keymap addons |
@opentui/keymap/addons/opentui |
Bun and Node.js | Supported | Built-in keymap addons |
@opentui/keymap/testing |
JavaScript test runtimes | Supported | Core keymap API |
@opentui/keymap/html |
Browser DOM | Runtime-specific | Keymap hosts |
@opentui/keymap/opentui |
Bun and Node.js | Runtime-specific | Keymap hosts |
@opentui/keymap/react |
OpenTUI React | Runtime-specific | React keymap integration |
@opentui/keymap/solid |
OpenTUI Solid | Runtime-specific | Solid keymap integration |
@opentui/keymap/runtime-modules |
Bun runtime loading | Runtime-specific, Advanced | Runtime module loading |
@opentui/qrcode |
Bun and Node.js | Supported | QR encoder |
@opentui/qrcode/react |
OpenTUI React | Runtime-specific | QR code component |
@opentui/qrcode/solid |
OpenTUI Solid | Runtime-specific | QR code component |
@opentui/ssh |
Bun 1.3.14 or Node.js 26.4.0 or later | Runtime-specific | SSH |
@opentui/three |
Bun only | Runtime-specific, Advanced | Three.js WebGPU |
@opentui/three/runtime-modules |
Bun only | Runtime-specific, Advanced | Runtime module loading |
See Package entry points for each entry point's role and import constraints.
`@opentui/core`
The root works in Bun and supported Node.js releases. A class in these tables applies to every symbol in its row.
Renderer and render tree
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Renderer | CliRenderer, CliRendererConfig, CliRendererErrorEvent, CliRendererExternalOutputEvent, CliRendererFrameEvent, CliRendererHandlerErrorEvent, CliRendererStats, CliRenderEvents, ConsoleMode, createCliRenderer, ExternalOutputMode, KittyKeyboardOptions, MouseButton, MouseEvent, PixelResolution, RendererControlState, ScreenMode |
Supported | Renderer |
| Scheduling and scrollback | RendererSchedulerState, ScrollbackRenderContext, ScrollbackSnapshot, ScrollbackSurface, ScrollbackSurfaceCommitOptions, ScrollbackSurfaceOptions, ScrollbackWriter, SplitFooterReplayResetOptions |
Advanced | Renderer |
| Shared renderer types | CapturedFrame, CapturedLine, CapturedSpan, CursorStyle, CursorStyleOptions, DebugOverlayCorner, ImageRenderProtocol, MousePointerStyle, RenderContext, RendererEvents, TargetChannel, TerminalCapabilities, TerminalCapabilityState, TerminalInfo, TerminalMultiplexer, ThemeMode, Timeout, ViewportBounds, WidthMethod |
Supported | Renderer, Interaction, and Terminal capabilities |
| Cell and highlight types | ATTRIBUTE_BASE_BITS, ATTRIBUTE_BASE_MASK, getBaseAttributes, Highlight, LineInfo, LineInfoProvider, TextAttributes |
Advanced | Text and terminal cells |
| Renderables | BaseRenderable, BaseRenderableOptions, isRenderable, LayoutEvents, LayoutOptions, Position, Renderable, RenderableEvents, RenderableOptions, RootRenderable |
Supported | Renderables |
| Composition constructs | ASCIIFont, Box, Code, FrameBuffer, Generic, Input, ScrollBox, Select, TabSelect, Text, vstyles |
Implementation-looking public export | Renderables |
| Composition internals | Construct, DelegateMap, delegate, FunctionalConstruct, h, InstantiateFn, instantiate, isVNode, maybeMakeRenderable, PendingCall, ProxiedVNode, RenderableConstructor, ValidateShape, VChild, VNode, VRenderable, VRenderableOptions, wrapWithDelegates |
Implementation-looking public export | Renderables |
| Layout string parsers | AlignString, BoxSizingString, DimensionString, DirectionString, DisplayString, EdgeString, FlexDirectionString, GutterString, JustifyString, LogLevelString, MeasureModeString, OverflowString, parseAlign, parseAlignItems, parseBoxSizing, parseDimension, parseDirection, parseDisplay, parseEdge, parseFlexDirection, parseGutter, parseJustify, parseLogLevel, parseMeasureMode, parseOverflow, parsePositionType, parseUnit, parseWrap, PositionTypeString, UnitString, WrapString |
Advanced | Layout |
| Tree inspection | visualizeRenderableTree |
Advanced | Renderables |
Input and selection
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Keyboard events | KeyEvent, KeyHandler, KeyHandlerEventMap, PasteEvent |
Supported | Keyboard input |
| Keyboard implementation | buildKittyKeyboardFlags, InternalKeyHandler |
Implementation-looking public export | Keyboard input |
| Key parser | KeyEventType, nonAlphanumericKeys, ParsedKey, parseKeypress, ParseKeypressOptions, terminalNamedSingleStrokeKeys |
Advanced | Keyboard input |
| Raw input parser | StdinEvent, StdinParser, StdinParserOptions, StdinParserProtocolContext, StdinResponseProtocol |
Advanced | Keyboard input |
| Mouse parser | MouseEventType, MouseParser, RawMouseEvent, ScrollInfo |
Advanced | Interaction, focus, and selection |
| Paste decoding | decodePasteBytes, PasteKind, PasteMetadata, stripAnsiSequences |
Advanced | Keyboard input |
| Scroll acceleration | LinearScrollAccel, MacOSScrollAccel, ScrollAcceleration |
Advanced | Interaction, focus, and selection |
| Selection | ASCIIFontSelectionHelper, convertGlobalToLocalSelection, LocalSelectionBounds, Selection, SelectionBehavior, SelectionOccupancy |
Advanced | Interaction, focus, and selection |
Text, color, links, and clipboard
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Styled text | bg, bgBlack, bgBlue, bgCyan, bgGreen, bgMagenta, bgRed, bgWhite, bgYellow, black, blink, blue, bold, brightBlack, brightBlue, brightCyan, brightGreen, brightMagenta, brightRed, brightWhite, brightYellow, Color, cyan, dim, fg, green, isStyledText, italic, link, magenta, red, reverse, strikethrough, stringToStyledText, StylableInput, StyleAttrs, StyledText, t, underline, white, yellow |
Supported | Text and terminal cells |
| Color values | ansi256IndexToRgb, ColorInput, ColorIntent, DEFAULT_BACKGROUND_RGB, DEFAULT_FOREGROUND_RGB, hexToRgb, hsvToRgb, normalizeColorValue, NormalizedColorValue, normalizeIndexedColorIndex, parseColor, RGBA, rgbToHex, RGBTriplet |
Supported | Colors |
| Attributes and links | attributesWithLink, createTextAttributes, getLinkId, detectLinks, MAX_LINK_URL_BYTES |
Advanced | Text and terminal cells |
| HAST conversion | HASTElement, HASTNode, HASTText, hastToStyledText |
Advanced | Text and terminal cells |
| Clipboard | ClipboardClearResult, ClipboardOptions, ClipboardReadOptions, ClipboardReadResult, ClipboardRepresentation, ClipboardSelection, ClipboardService, ClipboardTarget, ClipboardWriteDestination, ClipboardWriteOptions, ClipboardWriteResult, createClipboard, createHostClipboard, createRendererClipboardAdapter, HostClipboardBackend, HostClipboardClearResult, HostClipboardOperationOptions, HostClipboardOptions, HostClipboardReadOptions, HostClipboardService, HostClipboardWriteOptions, HostClipboardWriteResult, RendererClipboardBoundary, TerminalClipboardAdapter, TerminalClipboardOperationResult |
Supported | Clipboard |
Tree-sitter and syntax styles
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Client lifecycle | addDefaultParsers, destroyTreeSitterClient, getTreeSitterClient, TreeSitterClient |
Supported | Tree-sitter |
| File type lookup | basenameToFiletype, extensionToFiletype, extToFiletype, infoStringToFiletype, pathToFiletype |
Supported | Tree-sitter |
| Styled output | treeSitterToStyledText, TreeSitterToStyledTextOptions, treeSitterToTextChunks |
Supported | Tree-sitter |
| Parser and result types | FiletypeParserOptions, HighlightMeta, HighlightRange, HighlightResponse, InjectionMapping, SimpleHighlight, TreeSitterClientEvents, TreeSitterClientOptions |
Supported | Tree-sitter |
| Worker protocol | BufferState, Edit, ParsedBuffer, PerformanceStats, TreeSitterWorkerLogType, TreeSitterWorkerRequest, TreeSitterWorkerResponse |
Advanced | Tree-sitter |
| Syntax styles | convertThemeToStyles, MergedStyle, StyleDefinition, StyleDefinitionInput, SyntaxStyle, ThemeTokenStyle |
Supported | Tree-sitter |
Plugins, console, and process services
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Generic slots | createSlotRegistry, Plugin, PluginContext, PluginErrorEvent, PluginErrorPhase, PluginErrorReport, PluginErrorSource, ResolvedSlotRenderer, SlotMode, SlotRegistry, SlotRegistryOptions, SlotRenderer |
Supported | Plugin slots |
| Core slots | CoreManagedSlot, CorePlugin, CoreResolvedSlotRenderer, CoreSlotContribution, CoreSlotFailurePlaceholder, CoreSlotMode, CoreSlotRegistry, CoreSlotRenderer, createCoreSlotRegistry, registerCorePlugin, resolveCoreSlot, SlotRenderable, SlotRenderableOptions |
Supported | Core plugin slots |
| Console overlay | capture, ConsoleAction, ConsoleKeyBinding, ConsoleOptions, ConsolePosition, TerminalConsole |
Supported | Console overlay |
| Environment access | clearEnvCache, env, EnvVarConfig, registerEnvVar |
Advanced | Environment variables |
| Environment registry output | envRegistry, generateEnvColored, generateEnvMarkdown |
Implementation-looking public export | Environment variables |
| Terminal palette | buildTerminalPaletteSignature, createTerminalPalette, GetPaletteOptions, NormalizedTerminalPalette, normalizeTerminalPalette, OscSubscriptionSource, TerminalColors, TerminalPalette, TerminalPaletteDetector, TerminalPaletteOptions, WriteFunction |
Advanced | Terminal capabilities |
| Application paths | DataPaths, DataPathsEvents, DataPathsManager, getDataPaths |
Advanced | Application data paths |
| Clocks | Clock, SystemClock, TimerHandle |
Advanced | Animation and Timeline |
Components and component helpers
| Component or area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| ASCII font | ASCIIFontName, ASCIIFontOptions, ASCIIFontRenderable, coordinateToCharacterIndex, fonts, getCharacterPositions, measureText, renderFontToFrameBuffer |
Supported | ASCII font |
| Box and borders | BorderCharacters, BorderCharArrays, BorderChars, borderCharsToArray, BorderConfig, BorderSides, BorderSidesConfig, BorderStyle, BoxDrawOptions, BoxOptions, BoxRenderable, getBorderFromSides, getBorderSides, isValidBorderStyle, parseBorderStyle |
Supported | Box |
| Code | ChunkRenderContext, CodeOptions, CodeRenderable, HighlightContext, OnChunksCallback, OnHighlightCallback |
Supported | Code |
| Diff | DiffRenderable, DiffRenderableOptions |
Supported | Diff |
| Embedded terminal | EmbeddedTerminalDataSource, EmbeddedTerminalOptions, EmbeddedTerminalRenderable, EmbeddedTerminalScreen |
Supported | Embedded terminal |
| Editable buffer renderable | ContentChangeEvent, CursorChangeEvent, EditBufferOptions, EditBufferRenderable, EditBufferRenderableEvents, EditorCapture, EditorTraits, isEditBufferRenderable |
Advanced | Editing buffers and views |
| Frame buffer | FrameBufferOptions, FrameBufferRenderable |
Advanced | Frame buffer |
| Image display | ImageFit, ImageRenderable, ImageRenderableOptions, ImageRenderableSource, resolveImageRenderProtocol |
Supported | Image |
| Kitty transport | KittyImageTransport, KittyImageTransportStatus |
Supported | Kitty transport |
| Input | InputAction, InputKeyBinding, InputRenderable, InputRenderableEvents, InputRenderableOptions |
Supported | Input |
| Line number gutter | LineColorConfig, LineNumberOptions, LineNumberRenderable, LineSign |
Supported | Line number gutter |
| Markdown | BlockState, createMarkdownCodeBlockRenderer, MarkdownCodeBlockRenderer, MarkdownCodeBlockRendererMap, MarkdownOptions, MarkdownRenderable, MarkdownTableOptions, MarkdownTableStyle, RenderNodeContext |
Supported | Markdown |
| Markdown parser state | ParseState |
Implementation-looking public export | Markdown |
| Scroll bar | ArrowOptions, ArrowRenderable, ScrollBarOptions, ScrollBarRenderable, ScrollUnit |
Advanced | Scroll bar |
| Scroll box | ScrollBoxOptions, ScrollBoxRenderable |
Supported | Scroll box |
| Select | SelectAction, SelectKeyBinding, SelectOption, SelectRenderable, SelectRenderableEvents, SelectRenderableOptions |
Supported | Select |
| Slider | SliderOptions, SliderRenderable |
Supported | Slider |
| Tab select | TabSelectAction, TabSelectKeyBinding, TabSelectOption, TabSelectRenderable, TabSelectRenderableEvents, TabSelectRenderableOptions |
Supported | Tab select |
| Text | TextOptions, TextRenderable |
Supported | Text |
| Text nodes | isTextNodeRenderable, RootTextNodeRenderable, TextNodeOptions, TextNodeRenderable |
Advanced | Text and terminal cells |
| Text table | TextTableCellContent, TextTableColumnFitter, TextTableColumnWidthMode, TextTableContent, TextTableOptions, TextTableRenderable |
Supported | Text table |
| Textarea | defaultTextareaKeyBindings, KeyBinding, SubmitEvent, TextareaAction, TextareaKeyAliasMap, TextareaOptions, TextareaRenderable |
Supported | Textarea |
| Text buffer renderable | TextBufferOptions, TextBufferRenderable |
Advanced | Editing buffers and views |
| First-draw timestamp | TimeToFirstDrawOptions, TimeToFirstDrawRenderable |
Supported | Time to first draw |
Buffers, editing, animation, and effects
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Cell buffer | OptimizedBuffer |
Advanced | Buffer API |
| Text buffer | TextBuffer, TextChunk, TextBufferView |
Advanced | Editing buffers and views |
| Editing | EditBuffer, EditorView, measureEditText, Viewport |
Advanced | Editing buffers and views |
| Extmarks | createExtmarksController, Extmark, ExtmarkOptions, ExtmarksController |
Experimental, Advanced | Editing buffers and views |
| Timeline | AnimationOptions, createTimeline, getTimelineEngine, EasingFunctions, JSAnimation, Timeline, TimelineOptions |
Supported | Animation and Timeline |
| Animation engine | engine |
Implementation-looking public export | Animation and Timeline |
| Effects | CloudsEffect, CRTRollingBarEffect, DistortionEffect, FlamesEffect, RainbowTextEffect, VignetteEffect |
Experimental, Advanced | Post-processing effects |
| Filters | applyAsciiArt, applyBrightness, applyChromaticAberration, applyGain, applyInvert, applyNoise, applySaturation, applyScanlines, BloomEffect |
Experimental, Advanced | Post-processing effects |
| Matrices | ACHROMATOPSIA_MATRIX, DEUTERANOPIA_COMP_MATRIX, DEUTERANOPIA_SIM_MATRIX, GRAYSCALE_MATRIX, GREENSCALE_MATRIX, INVERT_MATRIX, PROTANOPIA_COMP_MATRIX, PROTANOPIA_SIM_MATRIX, SEPIA_MATRIX, SOLARIZATION_MATRIX, SYNTHWAVE_MATRIX, TECHNICOLOR_MATRIX, TRITANOPIA_COMP_MATRIX, TRITANOPIA_SIM_MATRIX |
Advanced | Color matrices |
Images, audio, and native span output
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
NativeImage API |
BlendMode, CompositeOptions, ExtendOptions, ExtractOptions, ImageColorStatus, ImageError, ImageErrorCode, ImageFormat, imageInfo, ImageInfo, ImageLoadError, ImageLoadErrorCode, ImageLoadOptions, ImageSource, NativeImage, NativeImagePool, NativeImagePoolOptions, OwnedRawImage, PixelFormat, PixelImportOptions, RawImage, ResizeKernel, ResizeOptions |
Advanced | NativeImage |
| Audio playback and mixing | Audio, AudioAction, AudioErrorContext, AudioEvents, AudioGroup, AudioInitializationAction, AudioInitializationError, AudioPlaybackDevice, AudioPlayOptions, AudioSetupOptions, AudioSound, AudioStartOptions, AudioVoice, setupAudio |
Supported | Audio |
| Streaming audio | AudioStream, AudioStreamAction, AudioStreamBody, AudioStreamBodyOptions, AudioStreamBufferOptions, AudioStreamConnectContext, AudioStreamConnection, AudioStreamConnector, AudioStreamContentTypeContext, AudioStreamContentTypePolicy, AudioStreamDemuxer, AudioStreamDemuxerFactory, AudioStreamDemuxOutput, AudioStreamError, AudioStreamErrorContext, AudioStreamEvents, AudioStreamFormat, AudioStreamMetadata, AudioStreamMetadataFormat, AudioStreamOptions, AudioStreamReconnectEvent, AudioStreamReconnectOptions, AudioStreamRetryContext, AudioStreamRetryDecision, AudioStreamRetryPhase, AudioStreamSourceOptions, AudioStreamState, AudioStreamStats, AudioStreamUrlOptions, createIcyStreamDemuxer, IcyStreamDemuxerOptions |
Supported | Streaming audio |
| Audio capture and recording | AudioCaptureDevice, AudioCaptureOptions, AudioCaptureReadResult, AudioCaptureStats, AudioCaptureStream, AudioCaptureStreamAction, AudioCaptureStreamError, AudioCaptureStreamErrorContext, AudioCaptureStreamEvents, AudioCaptureStreamOptions, AudioCaptureStreamState, AudioCaptureStreamStats, AudioRecorder, AudioRecorderAction, AudioRecorderError, AudioRecorderErrorContext, AudioRecorderEvents, AudioRecorderState, AudioRecorderStats, AudioRecordToFileOptions |
Supported | Audio capture and recording |
| Native span output | DataHandler, NativeSpanFeed |
Advanced | NativeSpanFeed |
Low-level native binding exports
These names expose the TypeScript side of the native binding. Use the higher-level API unless a low-level guide requires one of them.
Start with Native rendering for the scene model.
The C and Zig guides cover their native interfaces, which are separate from npm entry points.
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Standalone ownership | ResourceContext, NativeResourceOwner, BufferAccess, EncodedUnicode |
Advanced | Core resource ownership |
| Session driver | NativeSession, NativeSessionDriverOptions, NativeSessionScheduler |
Advanced | How Core uses native |
| Renderable integration | NativeRenderableIntegration |
Advanced | Custom renderables |
| Image ownership | ImageCreateOptions |
Advanced | NativeImage ownership |
| Checked errors | NativeError, NativeStatus |
Advanced | C results |
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Native handles and binding types | AudioEngineHandle, AudioEngineLib, ClipboardOperationHandle, ClipboardServiceHandle, ContextBufferHandle, ContextEditBufferHandle, ContextEditorViewHandle, ContextEmbeddedTerminalHandle, ContextImageHandle, ContextImagePixelsHandle, ContextObjectHandle, ContextSyntaxStyleHandle, ContextTextBufferHandle, ContextTextBufferViewHandle, ContextUnicodeHandle, CursorState, EmbeddedTerminalCursor, EmbeddedTerminalKey, EmbeddedTerminalMouse, FFIRenderLib, ImageHandle, LogicalCursor, LogLevel, MeasureResult, NativeAudioStreamCloseReason, NativeAudioStreamFormat, NativeAudioStreamState, NativeBufferedOutput, NativeClipboardCancelStatus, NativeClipboardCopyStatus, NativeClipboardDestroyStatus, NativeClipboardOperationStatus, NativeClipboardShutdownStatus, NativeClipboardStartStatus, NativeContextHandle, NativeHandle, NativeSpanFeedEventHandler, NativeYogaDirtiedCallback, NativeYogaLayout, NativeYogaMeasureCallback, RenderLib, resolveRenderLib, SceneNodeHandle, SessionHandle, setRenderLibPath, VisualCursor |
Implementation-looking public export | This section |
| Context and buffer bindings | NATIVE_BUFFER_TEXT_BYTES_MAX, NativeBufferDraw, NativeBufferGrid, NativeBufferStack, NativeContextBufferLease, NativeContextBufferOptions, NativeContextImageDraw, NativeContextOptions, NativeDrawingTarget |
Implementation-looking public export | This section |
| Scene bindings | NATIVE_EDGE_NONE, NativeBorder, NativeSceneArrowOptions, NativeSceneBoxDetails, NativeSceneEditorOptions, NativeSceneFrame, NativeSceneFrameOptions, NativeSceneFrameRequest, NativeSceneHook, NativeSceneLayout, NativeScenePaint, NativeScenePaintPhase, NativeScenePaintSlot, NativeScenePaintUpdate, NativeSceneSliderOptions, NativeSceneTextMetrics, NativeSceneTextOptions, NativeSceneTextSelectionOptions |
Implementation-looking public export | This section |
| Session bindings | acquireSessionBufferLease, NATIVE_SESSION_CONTROL_PACKET_BYTES, NativeOutputTicket, NativeSessionBufferLease, NativeSessionCapabilities, NativeSessionControl, NativeSessionCursorOptions, NativeSessionKittyImageTransportStatus, NativeSessionOptions, NativeSessionPumpResult, NativeSessionPumpStatus, NativeSessionRendererOptions, NativeSessionRendererState, NativeSessionRenderStatus, NativeSessionSplitControl, NativeSessionState, NativeSessionTerminalOptions, NativeSessionTerminalPhase, NativeSplitSnapshot, SessionBuffer |
Implementation-looking public export | This section |
| Text and editor bindings | NativeContextEditBufferOptions, NativeEditBufferInfo, NativeEditCommand, NativeEditEvent, NativeEditEventName, NativeEditHighlightOperation, NativeEditorCommand, NativeEditorPositionQuery, NativeEditorReplacement, NativeEditorSelection, NativeEditorSelectionOperation, NativeEditorStyle, NativeEditorStyleMask, NativeEditorViewInfo, NativeEditorViewport, NativeEditPositionQuery, NativeEncodedStyledText, NativeTextBufferInfo, NativeTextViewCommand |
Implementation-looking public export | This section |
| Native result structures | AllocatorStats, AudioStreamCreateOptions, BuildOptions, GrowthPolicy, NativeAudioCaptureStats, NativeAudioStreamStats, NativeRenderStats, NativeSpanFeedOptions, NativeSpanFeedStats |
Advanced | This section |
| Bundled asset resolution | resolveBundledFilePath |
Implementation-looking public export | Standalone executables |
| Yoga namespace | Yoga |
Advanced | Yoga API |
Other `@opentui/core` entry points
Testing
@opentui/core/testing works in Bun and Node.js.
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Renderer setup | createTestRenderer, MockInput, MockMouse, TestExternalOutput, TestExternalOutputCommit, TestFlushOptions, TestRenderer, TestRendererOptions, TestRendererSetup, TestVisualIdleOptions, TestWaitForOptions |
Supported | Testing |
| Keyboard and mouse | createMockKeys, createMockMouse, KeyCodes, KeyInput, MockKeysOptions, MouseButton, MouseButtons, MouseEventOptions, MouseEventType, MouseModifiers, MousePosition, pasteBytes |
Supported | Testing |
| Capabilities | createTerminalCapabilities, setRendererCapabilities, TerminalCapabilitiesOverrides |
Supported | Testing |
| Deterministic helpers | createSpy, ManualClock, MockTreeSitterClient |
Supported | Testing |
| Recording | RecordedFrame, TestRecorder |
Supported | Testing |
Runtime plugin support
These entry points are Bun-only. Their Node.js targets are stubs that throw during import.
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/core/runtime-plugin |
createRuntimePlugin, CreateRuntimePluginOptions, isCoreRuntimeModuleSpecifier, RuntimeModuleEntry, RuntimeModuleExports, runtimeModuleIdForSpecifier, RuntimeModuleLoader, RuntimePluginRewriteOptions, RuntimeSpecifierPreserve |
Runtime-specific, Advanced | Runtime module loading |
@opentui/core/runtime-plugin-support |
createRuntimePlugin, CreateRuntimePluginOptions, ensureRuntimePluginSupport, RuntimeModuleEntry, RuntimeModuleExports, runtimeModuleIdForSpecifier, RuntimeModuleLoader, RuntimeSpecifierPreserve |
Runtime-specific, Advanced | Runtime module loading |
@opentui/core/runtime-plugin-support/configure |
createRuntimePlugin, CreateRuntimePluginOptions, ensureRuntimePluginSupport, RuntimeModuleEntry, RuntimeModuleExports, runtimeModuleIdForSpecifier, RuntimeModuleLoader, RuntimeSpecifierPreserve |
Runtime-specific, Advanced | Runtime module loading |
Yoga
@opentui/core/yoga works in Bun and Node.js. The root Yoga namespace exposes this module. See Yoga API for methods and ownership.
| Purpose | Symbols | Class |
|---|---|---|
| Objects and callback types | Config, Node, default, DirtiedFunction, Layout, MeasureFunction, Size, Value |
Advanced |
| Errors | YogaError, YogaStatus |
Advanced |
| Alignment | Align, ALIGN_AUTO, ALIGN_BASELINE, ALIGN_CENTER, ALIGN_FLEX_END, ALIGN_FLEX_START, ALIGN_SPACE_AROUND, ALIGN_SPACE_BETWEEN, ALIGN_SPACE_EVENLY, ALIGN_STRETCH |
Advanced |
| Box sizing | BoxSizing, BOX_SIZING_BORDER_BOX, BOX_SIZING_CONTENT_BOX |
Advanced |
| Dimensions | Dimension, DIMENSION_HEIGHT, DIMENSION_WIDTH |
Advanced |
| Direction | Direction, DIRECTION_INHERIT, DIRECTION_LTR, DIRECTION_RTL |
Advanced |
| Display | Display, DISPLAY_CONTENTS, DISPLAY_FLEX, DISPLAY_NONE |
Advanced |
| Edges | Edge, EDGE_ALL, EDGE_BOTTOM, EDGE_END, EDGE_HORIZONTAL, EDGE_LEFT, EDGE_RIGHT, EDGE_START, EDGE_TOP, EDGE_VERTICAL |
Advanced |
| Compatibility errata | Errata, ERRATA_ABSOLUTE_PERCENT_AGAINST_INNER_SIZE, ERRATA_ABSOLUTE_POSITION_WITHOUT_INSETS_EXCLUDES_PADDING, ERRATA_ALL, ERRATA_CLASSIC, ERRATA_NONE, ERRATA_STRETCH_FLEX_BASIS |
Advanced |
| Experimental feature | ExperimentalFeature, EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS |
Experimental, Advanced |
| Flex direction | FlexDirection, FLEX_DIRECTION_COLUMN, FLEX_DIRECTION_COLUMN_REVERSE, FLEX_DIRECTION_ROW, FLEX_DIRECTION_ROW_REVERSE |
Advanced |
| Gaps | Gutter, GUTTER_ALL, GUTTER_COLUMN, GUTTER_ROW |
Advanced |
| Justification | Justify, JUSTIFY_CENTER, JUSTIFY_FLEX_END, JUSTIFY_FLEX_START, JUSTIFY_SPACE_AROUND, JUSTIFY_SPACE_BETWEEN, JUSTIFY_SPACE_EVENLY |
Advanced |
| Logging | LogLevel, LOG_LEVEL_DEBUG, LOG_LEVEL_ERROR, LOG_LEVEL_FATAL, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE, LOG_LEVEL_WARN |
Advanced |
| Measurement | MeasureMode, MEASURE_MODE_AT_MOST, MEASURE_MODE_EXACTLY, MEASURE_MODE_UNDEFINED |
Advanced |
| Node type | NodeType, NODE_TYPE_DEFAULT, NODE_TYPE_TEXT |
Advanced |
| Overflow | Overflow, OVERFLOW_HIDDEN, OVERFLOW_SCROLL, OVERFLOW_VISIBLE |
Advanced |
| Position | PositionType, POSITION_TYPE_ABSOLUTE, POSITION_TYPE_RELATIVE, POSITION_TYPE_STATIC |
Advanced |
| Units | Unit, UNIT_AUTO, UNIT_PERCENT, UNIT_POINT, UNIT_UNDEFINED |
Advanced |
| Wrapping | Wrap, WRAP_NO_WRAP, WRAP_WRAP, WRAP_WRAP_REVERSE |
Advanced |
Tree-sitter assets, worker, and Node assets
| Package entry point | Runtime | Symbols | Class | Canonical documentation |
|---|---|---|---|---|
@opentui/core/tree-sitter/update-assets |
Build time, with Bun CLI | runUpdateAssetsCli, updateAssets, UpdateOptions |
Runtime-specific | Tree-sitter |
@opentui/core/parser.worker |
Bun and Node.js worker | No named application exports | Implementation-looking public export | Tree-sitter |
@opentui/core/node-assets |
Node.js build time | getNodeAssets, NodeAsset, NodeAssetTarget |
Runtime-specific | Standalone executables |
`@opentui/react`
The root and compiler-selected JSX entry points work in Bun and Node.js.
Root exports
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Root rendering | createElement, createPortal, createRoot, flushSync, Root |
Supported | React bindings |
| Application context | AppContext, useAppContext |
Supported | React bindings |
| Hooks | useBlur, useFocus, useKeyboard, UseKeyboardOptions, useOnResize, usePaste, useRenderer, useSelectionHandler, useTerminalDimensions, useTimeline |
Supported | React bindings |
| Catalogue extension | extend, ExtendedComponentProps, ExtendedIntrinsicElements, getComponentCatalogue, OpenTUIComponents, RenderableConstructor |
Advanced | React bindings |
| Catalogue objects | baseComponents, componentCatalogue |
Implementation-looking public export | React bindings |
| Component prop types | AsciiFontProps, BoxProps, CodeProps, DiffProps, GetNonStyledProperties, ImageProps, InputProps, LineBreakProps, LineNumberProps, LinkProps, MarkdownProps, NonStyledProps, ReactProps, ScrollBoxProps, SelectProps, SpanProps, TabSelectProps, TextareaProps, TextProps |
Supported | Components overview |
| Plugin slots | createReactSlotRegistry, createSlot, ReactBoundSlotProps, ReactPlugin, ReactRegistrySlotComponent, ReactSlotComponent, ReactSlotOptions, ReactSlotProps, Slot, SlotMode |
Supported | React plugin slots |
| First-draw timestamp | TimeToFirstDraw, TimeToFirstDrawProps |
Supported | Time to first draw |
Other React entry points
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/react/renderer declarations |
createPortal, createRoot, flushSync, Root |
Supported | React bindings |
@opentui/react/renderer runtime |
All runtime values from @opentui/react |
Implementation-looking public export | React bindings |
@opentui/react/test-utils |
testRender |
Supported | Testing |
@opentui/react/runtime-plugin-support |
ensureRuntimePluginSupport, ReactRuntimePluginSupportOptions |
Runtime-specific, Advanced | Runtime module loading |
@opentui/react/runtime-plugin-support/configure |
ensureRuntimePluginSupport, ReactRuntimePluginSupportOptions |
Runtime-specific, Advanced | Runtime module loading |
@opentui/react/jsx-runtime |
Fragment, jsx, jsxs, JSX |
Supported | React bindings |
@opentui/react/jsx-dev-runtime |
Fragment, jsxDEV, JSX |
Supported | React bindings |
The generated React map points /renderer runtime imports to index.js. Its declaration path points to reconciler/renderer.d.ts. Treat root values outside the four declared names as a build leak.
`@opentui/solid`
The root, /components, and compiler-selected JSX entry points work in Bun and Node.js.
Root exports
| Area | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Root rendering | render, testRender |
Supported | Solid bindings |
| Hooks | onBlur, onFocus, onResize, useKeyboard, UseKeyboardOptions, usePaste, useRenderer, useSelectionHandler, useTerminalDimensions, useTimeline |
Supported | Solid bindings |
| Renamed hook | useKeyHandler |
Deprecated | Use useKeyboard in Solid bindings. |
| Components | createDynamic, Dynamic, DynamicProps, Portal |
Supported | Solid bindings |
| Scrollback | createScrollbackWriter, SolidScrollbackNode, SolidScrollbackWriterOptions, writeSolidToScrollback |
Advanced | Renderer |
| Catalogue extension | extend, ExtendedComponentProps, ExtendedIntrinsicElements, getComponentCatalogue, OpenTUIComponents, RenderableConstructor |
Advanced | Solid bindings |
| Catalogue and text wrappers | baseComponents, BoldSpanRenderable, componentCatalogue, ItalicSpanRenderable, LineBreakRenderable, LinkOptions, LinkRenderable, TextNodeKey, textNodeKeys, UnderlineSpanRenderable |
Implementation-looking public export | Solid bindings |
| Reconciler | _render, createComponent, createElement, createSlotNode, createTextNode, DomNode, effect, insert, insertNode, memo, mergeProps, removeNode, setProp, spread, use |
Implementation-looking public export | Solid bindings |
| Reconciler context and wrappers | LayoutSlotRenderable, RenderableContext, RendererContext, SlotRenderable, TextSlotRenderable |
Implementation-looking public export | Solid bindings |
| Component prop types | AsciiFontProps, BoxProps, CodeProps, ElementProps, GetNonStyledProperties, ImageProps, InputProps, LinkProps, MarkdownProps, NonStyledProps, ScrollBoxProps, SelectProps, SpanProps, TabSelectProps, TextareaProps, TextProps |
Supported | Components overview |
| Plugin slots | createSlot, createSolidSlotRegistry, Slot, SlotMode, SolidBoundSlotProps, SolidPlugin, SolidRegistrySlotComponent, SolidSlotComponent, SolidSlotOptions, SolidSlotProps |
Supported | Solid plugin slots |
| JSX namespace | JSX |
Supported | Solid bindings |
| First-draw timestamp | TimeToFirstDraw, TimeToFirstDrawProps |
Supported | Time to first draw |
Other Solid entry points
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/solid/preload |
No named exports. Importing it installs the Bun source transform. | Runtime-specific | Solid bindings |
@opentui/solid/bun-plugin |
createSolidTransformPlugin, CreateSolidTransformPluginOptions, default, ensureSolidTransformPlugin, resetSolidTransformPluginState |
Runtime-specific, Advanced | Solid bindings |
@opentui/solid/runtime-plugin-support |
ensureRuntimePluginSupport, SolidRuntimePluginSupportOptions |
Runtime-specific, Advanced | Runtime module loading |
@opentui/solid/runtime-plugin-support/configure |
ensureRuntimePluginSupport, SolidRuntimePluginSupportOptions |
Runtime-specific, Advanced | Runtime module loading |
@opentui/solid/components |
extend, ExtendedComponentProps, ExtendedIntrinsicElements, getComponentCatalogue, OpenTUIComponents |
Advanced | Solid bindings |
@opentui/solid/jsx-runtime |
Fragment, jsx, jsxDEV, jsxs, JSX |
Supported | Solid bindings |
@opentui/solid/jsx-dev-runtime |
Fragment, jsxDEV, JSX |
Supported | Solid bindings |
`@opentui/keymap`
Root engine
The root engine is host-agnostic. Use an adapter entry point to connect it to a browser or OpenTUI renderer.
| Kind | Symbols | Class | Canonical documentation |
|---|---|---|---|
| Values | Keymap, KEYMAP_EXTENSION_CONTEXT, stringifyKeySequence, stringifyKeyStroke |
Supported | Core keymap API |
| Types | ActiveBinding, ActiveKey, ActiveKeyOptions, Attributes, Binding, BindingCommand, BindingEvent, BindingExpander, BindingExpanderContext, BindingExpansion, BindingFieldCompiler, BindingFieldContext, BindingParser, BindingParserContext, BindingParserResult, Bindings, BindingsValidationResult, BindingTransformer, BindingTransformerContext, Command, CommandBindingsQuery, CommandContext, CommandEntry, CommandFieldCompiler, CommandFieldContext, CommandFilter, CommandHandler, CommandQuery, CommandQueryValue, CommandResolver, CommandResolverContext, CommandResult, CommandTransformer, CommandTransformerContext, DispatchBinding, DispatchEvent, DispatchLayer, DispatchPhase, ErrorEvent, EventData, EventMatchResolver, EventMatchResolverContext, EventName, Events, HostCapability, HostMetadata, HostModifier, HostPlatform, InterceptName, Intercepts, KeyAfterInputContext, KeyAfterReason, KeyDeferredDisambiguationContext, KeyDeferredDisambiguationDecision, KeyDeferredDisambiguationHandler, KeyDisambiguationContext, KeyDisambiguationDecision, KeyDisambiguationResolver, KeyInputContext, KeyInterceptOptions, KeyLike, KeymapEvent, KeymapExtensionContext, KeymapExtensionProvider, KeymapHost, KeyMatch, KeySequencePart, KeyStringifyInput, KeyStrokeInput, KeyToken, Layer, LayerAnalysisContext, LayerAnalyzer, LayerBindingAnalysis, LayerBindingsTransformer, LayerBindingsTransformerContext, LayerFieldCompiler, LayerFieldContext, Listener, NormalizedKeyStroke, ParsedBinding, ParsedCommand, RawInputContext, RawInterceptOptions, ReactiveMatcher, ResolvedKeyToken, ResolvedSequencePattern, RunCommandOptions, RunCommandResult, SequencePattern, SequencePatternMatch, StringifyOptions, TargetMode, WarningEvent |
Supported | Core keymap API |
Extras and graph
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/keymap/extras |
BindingCommandMap, BindingConfig, BindingConfigItem, BindingDefaults, BindingDefaultsContext, BindingLookup, BindingValue, CommandBindingMap, commandBindings, CommandBindingsError, CommandBindingsOptions, CommandBindingsOverrideWarning, createBindingLookup, CreateBindingLookupOptions, formatCommandBindings, FormatCommandBindingsOptions, formatKeySequence, FormatKeySequenceOptions, KeyModifierName, KeySequenceFormatPart, SequenceBindingLike, TokenDisplayResolver |
Supported | Core keymap API |
@opentui/keymap/extras/graph |
createGraphExtra, getGraphSnapshot, GraphBinding, GraphCommand, GraphInactiveReason, GraphLayer, GraphSequenceNode, GraphSnapshot, GraphSnapshotOptions, KeymapGraphExtra |
Advanced | Core keymap API |
Addons
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/keymap/addons |
Aliases, BackspacePopsPendingSequenceOptions, defaultBindingParser, defaultEventMatchResolver, Enabled, EscapeClearsPendingSequenceOptions, ExCommand, ExCommandPayload, LeaderOptions, LeaderTrigger, NeovimDisambiguationOptions, registerAliasesField, registerBackspacePopsPendingSequence, registerBindingOverrides, registerCommaBindings, registerDeadBindingWarnings, registerDefaultBindingParser, registerDefaultEventMatchResolver, registerDefaultKeys, registerEmacsBindings, registerEnabledFields, registerEscapeClearsPendingSequence, registerExCommands, registerLeader, registerMetadataFields, registerModBindings, registerNeovimDisambiguation, registerTimedLeader, registerUnresolvedCommandWarnings, TimedLeaderOptions |
Supported | Built-in keymap addons |
@opentui/keymap/addons/opentui |
Every export from @opentui/keymap/addons, plus createTextareaBindings, EditBufferCommandName, EditBufferCommandOptions, EditBufferFineGroup, registerBaseLayoutFallback, registerEditBufferCommands, registerManagedTextareaLayer, registerTextareaMappingSuspension |
Supported | Built-in keymap addons and Custom keymap addons |
Testing and hosts
| Package entry point | Runtime | Symbols | Class | Canonical documentation |
|---|---|---|---|---|
@opentui/keymap/testing |
JavaScript test runtimes | captureKeymapDiagnostics, createTestHostMetadata, createTestKeymap, createTestKeymapHost, CreateTestKeymapHostOptions, CreateTestKeymapOptions, DEFAULT_TEST_HOST_METADATA, TestDiagnosticCapture, TestHostMetadataOptions, TestKeymapEvent, TestKeymapHarness, TestKeymapHost, TestKeymapTarget, TestKeyModifierOptions |
Supported | Core keymap API |
@opentui/keymap/html |
Browser DOM | createDefaultHtmlKeymap, createHtmlKeymap, createHtmlKeymapEvent, createHtmlKeymapHost, htmlEventMatchResolver, HtmlKeymapEvent, normalizeHtmlKeyName |
Runtime-specific | Keymap hosts |
@opentui/keymap/opentui |
Bun and Node.js | createDefaultOpenTuiKeymap, createOpenTuiKeymap, createOpenTuiKeymapHost |
Runtime-specific | Keymap hosts |
Frameworks and runtime modules
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/keymap/react |
KeymapProvider, KeymapProviderProps, reactiveMatcherFromStore, useActiveKeys, useBindings, UseBindingsLayer, UseBindingsTargetRef, useKeymap, usePendingSequence |
Runtime-specific | React keymap integration |
@opentui/keymap/solid |
KeymapProvider, KeymapProviderProps, reactiveMatcherFromSignal, useBindings, UseBindingsLayer, UseBindingsTarget, useKeymap, useKeymapSelector |
Runtime-specific | Solid keymap integration |
@opentui/keymap/runtime-modules |
runtimeModules |
Runtime-specific, Advanced | Runtime module loading |
The runtimeModules map contains the root, both extras paths, both addon paths, and both host adapters. It loads the React and Solid paths lazily. It does not contain /testing or /runtime-modules.
`@opentui/qrcode`
The package root works in Bun and Node.js. Each framework path re-exports the full root and adds its registration function.
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/qrcode |
ByteEncoding, createQrSvg, EciAssignment, EncodeOptions, encodeTextBytes, ErrorCorrectionLevel, Fnc1SecondPositionInfo, Gs1Element, QRCode, QRCodeFitMode, QRCodeOptions, QRCodeRenderable, QrSegment, StructuredAppendInfo, TerminalRenderOptions |
Supported | QR encoder and QR code component |
@opentui/qrcode/react |
Every root export, plus registerQRCode |
Runtime-specific | QR code component |
@opentui/qrcode/solid |
Every root export, plus registerQRCode |
Runtime-specific | QR code component |
`@opentui/ssh`
The package supports Bun 1.3.14 or later and Node.js 26.4.0 or later. Use Bun 1.4.0 or later on native Windows arm64.
It has one public entry point.
| Symbols | Class | Canonical documentation |
|---|---|---|
AuthConfig, AuthMethods, ConfigError, createServer, DenyError, Handoff, Identity, IdentityFor, ListenInfo, LogEvent, logging, LoggingOptions, Middleware, MiddlewareFunction, MiddlewareSession, Next, OutputPressureError, PublicKey, PublicKeyPolicy, RemoteAddress, Server, ServerBuilder, ServerConfig, Session, SessionHandler, SshError |
Runtime-specific | SSH |
`@opentui/three`
Both entry points are Bun-only.
| Package entry point | Symbols | Class | Canonical documentation |
|---|---|---|---|
@opentui/three |
AnimationDefinition, AnimationStateConfig, CLICanvas, DEFAULT_EXPLOSION_PARAMETERS, DEFAULT_PHYSICS_EXPLOSION_PARAMETERS, ExplodingSpriteEffect, ExplosionCreationData, ExplosionEffectParameters, ExplosionHandle, ExplosionManager, InstanceManager, InstanceManagerOptions, MeshPool, MeshPoolOptions, ParticleEffectParameters, PhysicsExplodingSpriteEffect, PhysicsExplosionCreationData, PhysicsExplosionEffectParameters, PhysicsExplosionHandle, PhysicsExplosionManager, PhysicsSpriteRecreationData, PlanckPhysicsWorld, PlanckRigidBody, RapierPhysicsWorld, RapierRigidBody, ResolvedAnimationState, ResourceConfig, SheetProperties, SheetSprite, SpriteAnimator, SpriteDefinition, SpriteParticleGenerator, SpriteRecreationData, SpriteResource, SpriteResourceManager, SpriteUtils, SuperSampleAlgorithm, SuperSampleType, TextureUtils, THREE, ThreeCliRenderer, ThreeCliRendererOptions, ThreeRenderable, ThreeRenderableOptions, TiledSprite |
Runtime-specific, Advanced | Three.js WebGPU |
@opentui/three/runtime-modules |
runtimeModules |
Runtime-specific, Advanced | Runtime module loading |
The THREE namespace exposes the installed Three.js module. The runtimeModules map contains only the @opentui/three root.
Native distribution packages
The build generates eight platform packages:
@opentui/core-darwin-x64@opentui/core-darwin-arm64@opentui/core-linux-x64@opentui/core-linux-arm64@opentui/core-linux-x64-musl@opentui/core-linux-arm64-musl@opentui/core-win32-x64@opentui/core-win32-arm64
Each package has one default export that resolves the native library path. These packages are internal distribution surfaces, not application APIs. Let @opentui/core select the correct package. See Runtime and platform support.
`opentui.h`
The npm API and the native application binary interface (ABI) are separate surfaces. opentui.h declares the experimental C ABI. See C. The C ABI reference lists every declaration with the release that added it.
OpenTUI's TypeScript packages call the native library through internal bindings that can change with them. Use the TypeScript exports indexed above for application code.
These rows name the declarations that the native guides document. The comments in opentui.h document every declaration.
| Area | Symbols | Canonical documentation |
|---|---|---|
| ABI version | OT_CONTEXT_ABI_VERSION |
Build the library |
| Contexts | ot_context, ot_context_create, ot_context_destroy, ot_context_options |
Group resources in a Context |
| Handles | ot_handle |
A handle names an object |
| Results | ot_status, OT_OK, OT_INVALID_ARGUMENT, OT_UNSUPPORTED_VERSION, OT_WRONG_CONTEXT, OT_WRONG_SESSION, OT_WRONG_KIND, OT_STALE_HANDLE, OT_WRONG_THREAD, OT_CONTEXT_BUSY, OT_FRAME_BUSY, OT_OUTPUT_BACKPRESSURE, OT_STALE_FRAME, OT_STALE_OUTPUT, OT_STALE_LEASE |
Initialize records and check results |
| Sessions | ot_session_create, ot_session_options, ot_session_attach_renderer, ot_session_renderer_options, ot_session_setup_terminal, OT_INVALID_PHASE |
Add a Session for output |
| Shared resources | ot_buffer_create, ot_text_buffer_create, ot_text_buffer_view_create, ot_edit_buffer_create, ot_editor_view_create, ot_syntax_style_create, ot_scene_set_text_view, OT_SCENE_TEXT_VIEW |
Create shared resources in one Context |
| Images | ot_image_decode, ot_image_inspect, ot_image_create_pixels, ot_image_take_pixels, ot_image_pixels_release, ot_image_clone, ot_image_retain |
Create images in a Context |
| Buffer leases | ot_buffer_acquire_lease, ot_buffer_lease_validate, ot_buffer_lease_get_real_char_size, ot_buffer_lease_write_resolved_chars, ot_buffer_lease_release |
Borrow cells for one synchronous operation |
| Owned buffers | ot_scene_set_surface, ot_scene_frame_draw_buffer, ot_session_draw_buffer, OT_SCENE_CUSTOM |
Compose an owned buffer |
| Text and geometry | ot_scene_get_layout, ot_scene_set_styled_text, ot_styled_text_chunk, ot_scene_text_info, OT_LAYOUT_PUBLIC, OT_LAYOUT_YOGA, OT_LAYOUT_PAINT |
Read text and geometry |
| Teardown | ot_scene_destroy_node, ot_session_cancel, ot_session_close, ot_session_destroy, OT_CLIPBOARD_START_SHUTTING_DOWN |
Finish work before destroying owners |
| Scene nodes | ot_scene_create_node, ot_scene_move_node, ot_scene_set_paint, ot_scene_paint_options, ot_scene_set_text, OT_SCENE_ROOT, OT_SCENE_BOX, OT_SCENE_TEXT |
Add the box and label |
| Host drawing | ot_buffer_draw_text_record, ot_scene_record_draw, ot_scene_record_slot, OT_BUFFER_DRAW_TEXT, OT_SCENE_RECORD_DRAW, OT_SCENE_RECORD_SLOT, OT_SCENE_RECORD_PHASE_AFTER |
Add host drawing |
| Paint and deliver | ot_scene_paint, ot_scene_frame_request, ot_scene_frame_commit, ot_session_complete_output, OT_UNSUPPORTED_RESOURCE |
Paint, submit, and deliver |
| Frame requests | ot_scene_frame_step_with_geometry, ot_scene_frame_geometry, ot_scene_frame_options, ot_scene_set_hooks, ot_scene_hooks, OT_SCENE_FRAME_DONE, OT_SCENE_FRAME_UPDATE, OT_SCENE_FRAME_RESIZE, OT_SCENE_FRAME_LAYOUT_CHANGED, OT_SCENE_FRAME_RECORD, OT_SCENE_FRAME_YIELD, OT_SCENE_HOOK_UPDATE, OT_SCENE_HOOK_RESIZE, OT_SCENE_HOOK_LAYOUT_CHANGED |
Read and acknowledge requests |
| Paint hooks | ot_scene_frame_get_paint_slots, ot_scene_paint_slot, ot_scene_record_header, ot_scene_hit_test, ot_scene_set_image, OT_SCENE_HOOK_RENDER_BEFORE, OT_SCENE_HOOK_RENDER_SELF, OT_SCENE_HOOK_RENDER_AFTER, OT_SCENE_RECORD_BYTES_MAX, OT_BUFFER_DRAW_CLEAR |
Record paint hooks |
| Measurement | ot_scene_set_measure, ot_scene_measure_callback |
Measure custom content |
| Property updates | ot_scene_flush, OT_SCENE_PROPERTY_STYLE, OT_SCENE_MUTATIONS_MAX, OT_SCENE_PROPERTY_BYTES_MAX |
Submit property updates |
| Changes during a frame | ot_scene_frame_cancel, ot_scene_set_viewport, OT_FRAME_REQUEST_LIMIT, OT_LAYOUT_LIMIT |
Change the scene during a frame |
| Framebuffer access | ot_scene_frame_acquire_buffer_lease, ot_session_acquire_buffer_lease |
Access the framebuffer |
| Rendering and output | ot_session_render, ot_session_render_split, ot_session_read_output, ot_session_drain_output, ot_session_drain_stdout, ot_output_ticket, OT_RENDER_PRESENTED, OT_RENDER_PENDING, OT_RENDER_SKIPPED, OT_RENDER_FAILED |
Commit the draft, then complete output |
| Output storage | ot_session_get_write_limit, OT_SESSION_CONTROL_PACKET_BYTES |
Reserve output storage |
| Terminal work | ot_session_pump, ot_session_control, OT_PUMP_IDLE, OT_PUMP_AGAIN, OT_PUMP_WAIT_UNTIL, OT_PUMP_OUTPUT_PENDING, OT_PUMP_CLOSED, OT_CONTROL_CAPABILITY_RESPONSE |
Pump terminal work and close |
| Host output | OT_OUTPUT_BUSY, OT_OUTPUT_FAILED |
Output without a host copy |
| Deadlines | ot_session_pump_exit, ot_session_poll_kitty_image_transport |
One Session deadline clock |
| Core integration | ot_context_set_edit_event_callback, ot_edit_event_callback, OT_BUFFER_TEXT_BYTES_MAX |
How Core uses native |
Changes since 0.5.14
Layout, hit testing, and painting now run in a native scene. Native resources belong to an owner Context.
This section lists each 0.5.14 API and behavior that was removed or changed, and what to use instead.
Each item names the changed API or behavior first. New APIs are in the tables above.
Renderer
addToHitGrid(),pushHitGridScissorRect(),popHitGridScissorRect(), andclearHitGridScissorRects(): removed from the renderer andRenderContext. Native code adds a hit area for each visible node and clips it tooverflowancestors. Userenderer.hitTest(x, y)to query the hit grid.dumpBuffers()anddumpOutputBuffer(): removed. Use test frame capture,renderer.currentRenderBuffer.withBuffers(), or a customstdout.dumpHitGrid()remains.renderer.useThreadand theuseThreadoption ofcreateCliRenderer()andcreateTestRenderer(): removed. Core writes output on the JavaScript thread. Remove the option.renderer.rendererPtr: removed, with no replacement.OTUI_NO_NATIVE_RENDERenvironment variable: removed. UsecreateTestRenderer().suspend()andresume(): now return aPromise<void>. Await them. They throw when the terminal is not set up (also afterdestroy()) or a transition is pending.resume()also throws when the renderer is not suspended.useMouse,enableKittyKeyboard(), anddisableKittyKeyboard(): now throw during terminal setup, suspend, or resume, and while the renderer is suspended.screenModenow throws during setup, suspend, or resume. Change them after the transition completes.enableKittyKeyboard(flags): now throwsRangeErrorwhenflagsis not an integer from 0 to 31.- Output stream
errorevent: now closes the renderer and rejectsrenderer.closed. Handle the rejection ofclosed. In 0.5.14, the renderer handled the event only while Kitty file transport was active. It cancelled file transport and kept running. A failed Kitty temporary-file write still falls back to inline transport. - Renderer methods after
destroy():setTerminalTitle(),resetTerminalBgColor(),copyToClipboardOSC52(),clearClipboardOSC52(),isOsc52Supported(),setCursorPosition(),setCursorStyle(),setCursorColor(),setMousePointer(),getCursorState(),getStats(),getNativeStats(),configureDebugOverlay(),toggleDebugOverlay(),dumpHitGrid(),hitTest(), andconsole.show()(also throughconsole.toggle()) throw.getPalette()rejects.requestRender(),resize(),triggerNotification(), andgetLinkAt()do nothing. Checkrenderer.isDestroyedfirst. getPalette(): now rejects before the terminal is set up, for example on acreateTestRenderer()renderer. It also rejects while a setup, suspend, or resume is pending. Itssizemust be an integer from 1 to 256.- Renderable constructors and
engine.attach(): now throw for a destroyed renderer. Create renderables before you destroy the renderer. destroy(): restores the terminal and releases the streams asynchronously. Awaitrenderer.closedbefore you reuse the streams.- Scrollback snapshots and commits: one snapshot or commit larger than the output capacity divided by 24 bytes (about 349,000 cells by default) throws. A
rowColumnsvalue that is not an integer from 0 to the snapshot width throwsRangeError. In 0.5.14, Core truncated and clampedrowColumns.
Renderables and layout
RootRenderable.calculateLayout(): removed. Layout runs in the native frame step. ReadgetLayout()afterrenderOnce()orawait renderer.idle().Renderable.getLayoutNode()and the protectedyogaNode: removed. Use layout properties,setMeasureProvider(),invalidateIntrinsicSize(), andgetLayout(). Replace a style read such asgetLayoutNode().getMaxWidth()with the renderable method of the same name,getMaxWidth(). ReplacemarkDirty()withinvalidateIntrinsicSize(), and the computed position and size getters withgetLayout(). Computed padding, margin, and border have no replacement. Renderables cannot useDisplay.Contents. The standaloneYogaAPI remains.render(),updateLayout(),updateFromLayout(),canReuseRenderCommandList(), and theRenderCommandtype: removed. OverriderenderSelf(buffer, deltaTime), or use therenderBeforeandrenderAfterpaint hooks. In a subclass ofTextBufferRenderableorEditBufferRenderable, the override has one parameter:renderSelf(buffer).- Protected
_x,_y,_widthValue,_heightValue,_childrenInZIndexOrder,_getVisibleChildren(),_hasVisibleChildFilter(), andgetScissorRect(): removed. Usex,y,width,height,screenX, andscreenY. For child culling, use theviewportCullingoption ofScrollBox. - Protected
renderCursor()of edit buffer renderables: removed. Use the cursor options. - Protected
updateViewportOffset():TextRenderableno longer calls it when it scrolls or resizes. Native code applies the viewport. - Protected color fields of
ASCIIFontRenderable: now holdRGBAvalues. - Custom
RenderContext: must now implementnativeScene,requestAnimationFrame(), andcancelAnimationFrame(). - Paint hooks (
renderBefore,renderSelf, andrenderAfter): now receive a recording buffer.buffers,withBuffers(),getRealCharBytes(), andgetSpanLines()throw in a hook. Draw into a buffer that you own and compose it, or use a post-process function. Abuffered: truecustom renderable is different: its hooks receive its own frame buffer. - Changes made in a paint hook: a layout, position, visibility, child, or
zIndexchange takes effect in the next frame. Color and text changes paint in the same frame. on("resized")andon("layout-changed"): now throw on a renderable that is not the root. These listeners never ran in 0.5.14. UseonSizeChange.- Geometry after
destroy():x,y,screenX,screenY,width, andheightof a renderable that is notselectablereturn 0 or the styled size, not the last layout. Read them before you destroy the renderable. RenderContext.getLifecyclePasses(): now contains only renderables whose lifecycle pass is active.- Box
customBorderChars: wide, combining, or control characters now throw.
Buffers
OptimizedBuffer.create(width, height, widthMethod, options): now requiresoptions.owner. Passrenderer.nativeSceneor aResourceContext.buffer.buffers: works only in a post-process function. Usebuffer.withBuffers(callback)elsewhere.- Drawing into
renderer.nextRenderBuffer: works only in a post-process function.renderer.currentRenderBufferdoes not accept drawing. In 0.5.14, frame callbacks could draw intonextRenderBuffer, for example withThreeCliRenderer.drawScene(). Draw into aFrameBufferRenderableinstead. encodeUnicode(text): now returns{ data }and nevernull. Itscharvalues are opaque tokens fordrawChar()on a buffer with the same owner. A tab encodes as two one-cell spaces.buffer.ptrandgetNativeId(): removed. Keep the buffer object.buffer.idis only a label.- Drawing methods: now throw
RangeErrorfor fractional,NaN, or infinite coordinates and sizes. Round them first. drawText()and box titles: now throwRangeErrorfor text larger than 64 KiB of UTF-8.pushOpacity()and color-matrix strength: a non-finite value now throws.- Composing a buffer into itself now throws.
setRespectAlpha()on a renderer buffer throws. setCell(),setCellWithAlphaBlending(),drawChar(), anddrawText(): now reject attribute bits 8 to 31. Pass only text attributes. Use the styled-textlink()helper for hyperlinks.
Text and editing
SyntaxStyle.create(),fromStyles(styles), andfromTheme(theme): nowcreate(owner),fromStyles(styles, owner), andfromTheme(theme, owner). Passrenderer.nativeScene, or aResourceContextfor standalone use.TextBuffer.create(widthMethod)andEditBuffer.create(widthMethod): nowcreate(widthMethod, owner)..ptronTextBuffer,TextBufferView,EditBuffer,EditorView, andSyntaxStyle: removed. Their constructors take Context handles. Use thecreate()factories.EditBuffer.setText(),insertText(), andreplaceText(), the same Textarea and Input methods, TextareainitialValue,Input.value, and theplaceholderof both: now show control characters other than tab, carriage return, and line feed as notation, such as^Lor<85>. In 0.5.14, the cursor could not move past or delete them, and the terminal received them.TextBuffer.loadFile(): now rejects invalid UTF-8.- Text buffer and text view offsets: must be integers. Other values throw
RangeError. - Highlight
priorityabove 255 orhlRefabove 65535: now throws. In 0.5.14, the value wrapped. - Color getters of text, text table, ASCII font, Markdown, Diff, line number, and QR code renderables, and the
SyntaxStylestyle getters: now return copies. Change a color through its setter.ASCIIFont.colorreturns anRGBA.
Resources and native bindings
- Minimum Bun version: now 1.3.14 (was 1.3.0), the tested minimum. Bun 1.3.0 to 1.3.3 convert some FFI pointer arguments to wrong addresses. Bun 1.3.4 to 1.3.6 can crash after a Worker exits. Bun 1.3.7 intermittently hangs while it loads modules.
Clipboardclass: removed. Userenderer.copyToClipboardOSC52()andclearClipboardOSC52(), orcreateClipboard()withcreateHostClipboard()andcreateRendererClipboardAdapter(renderer).- Native resources: each resource now belongs to one owner Context. Resources from different owners cannot be used together. Drawing a
NativeImageinto a renderer copies it into the renderer's Context. - Images that
ImageRenderableloads: belong to the renderer's Context and throw afterrenderer.destroy(). To keep an image, load it yourself, for example withNativeImage.load(), and pass it assource. getAllocatorStats(): does not count Context memory.getArenaAllocatedBytes()always returns 0. Useprocess.memoryUsage().rssfor total memory.- Handle types
RendererHandle,OptimizedBufferHandle,TextBufferHandle,TextBufferViewHandle,EditBufferHandle,EditorViewHandle,SyntaxStyleHandle,EventSinkHandle,NativeRenderableHandle,EmbeddedTerminalHandle,NativeMeasureTargetHandle,NativeMeasureTargetKind,NativeRenderOperationResult, andNativeRendererCreateOptions: removed. Use theContext*Handletypes,SessionHandle, andSceneNodeHandle.ImageHandleis now an alias ofContextImageHandle. - Pointer-based
RenderLibmethods, such ascreateRenderer,createOptimizedBuffer,bufferDrawText,setUseThread, andnativeRenderableSetMeasureTarget: removed. Use the public classes. StandaloneYoganodes measure only with JavaScript measure functions. - Standalone
Yoga:Config.free()now throws while nodes from that config remain. Invalid arguments, such as an out-of-range child index or enum value, throwYogaError. Measure and dirtied functions must return synchronously and must not change Yoga nodes. See Yoga API.
SSH
@opentui/sshsession.write(): can now throwOutputPressureErrororRangeError.colsandrowshold the requested size until the renderer attaches.onCloseruns at logical close, before the renderer finishes closing.
Native libraries
- Legacy native exports, such as
createRendererandbufferDrawText: removed. Use theot_*C ABI inopentui.h. See C. - Zig
GraphemePool.alloc(),GraphemePool.allocUnowned(), andLinkPool.alloc(): replaced byacquire(). It copies the bytes and returns an ID that holds one reference. Calldecref()to release it.alloc()returned an ID with no references, andallocUnowned()did not copy. - Zig
GraphemePool.freeUnreferenced()and the global pools (initGlobalPool(),initGlobalPoolWithOptions(),deinitGlobalPool(),initGlobalLinkPool(), anddeinitGlobalLinkPool()): removed. Use explicit pools oropentui.Context. See Zig. - Zig
UnifiedTextBuffer.setStyledText()andStyledChunk: removed. UsereplaceOwnedStyledText()withOwnedStyledChunk.clear()andreset()can now return errors. - Zig
CliRenderer.dumpBuffers()anddumpOutputBuffer(): removed.
Packages React Docs EXTEND (`packages/react/docs/EXTEND.md`)
OpenTUI React Component Extension
The extend function adds custom renderable classes to the component catalogue of the OpenTUI React reconciler. It works like extend in @react-three/fiber, which adds Three.js objects.
Basic Usage
Extending Components
import { BoxRenderable, OptimizedBuffer, RGBA, type BoxOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/react"
class ConsoleButton extends BoxRenderable {
public label: string = "Button"
constructor(ctx: RenderContext, options: BoxOptions & { label: string }) {
super(ctx, options)
// Custom initialization
this.height = 3
this.width = 24
}
protected renderSelf(buffer: OptimizedBuffer): void {
super.renderSelf(buffer)
const centerX = this.x + Math.floor(this.width / 2 - this.label.length / 2)
const centerY = this.y + Math.floor(this.height / 2)
buffer.drawText(this.label, centerX, centerY, RGBA.fromInts(255, 255, 255, 255))
}
}
declare module "@opentui/react" {
interface OpenTUIComponents {
consoleButton: typeof ConsoleButton
}
}
// Extend components
extend({
consoleButton: ConsoleButton,
})
// Now you can use them in JSX
function App() {
return <consoleButton label="Click me!" />
}React calls the constructor with { id, ...props }. After construction, React assigns each prop to the instance again. In this example, React sets label after the class field sets it to "Button".
renderSelf() is a paint hook. It uses screen cell coordinates, so the example places the label relative to this.x and this.y.
TypeScript Support
For full TypeScript support, declare your extended components with module augmentation:
// In your component file or declaration file
declare module "@opentui/react" {
interface OpenTUIComponents {
consoleButton: typeof ConsoleButton
}
}
// Then extend and use with full type safety
extend({
consoleButton: ConsoleButton,
})
// TypeScript will now know about these components
<consoleButton label="Typed!" />API Reference
`extend(components)`
Adds renderable components to the component catalogue.
Parameters:
components: Object mapping component names to renderable constructors
Returns:
void
`getComponentCatalogue()`
Returns the current component catalogue. The reconciler uses it to find the class for each JSX element.
Best Practices
Declare types with module augmentation: TypeScript then completes and checks the props of each custom element.
Call
requestRender(): CallrequestRender()in each property setter that changes what the component draws.Extend from appropriate base classes: Use
BoxRenderablefor containers,TextRenderablefor text, and so on.
Limitations
- Extended components must extend from OpenTUI's core renderable classes
extend()replaces an existing entry with the same name, including a built-in component. Use unique names.- TypeScript support requires manual module augmentation declarations
Packages Core Src Benchmark Docs Ffi Fast Path (`packages/core/src/benchmark/docs/ffi-fast-path.md`)
FFI Fast Path Benchmarks
Purpose
This suite measures OpenTUI FFI wrapper overhead in Bun and Node without changing native behavior. It exists because an optimization can help one runtime and regress the other.
Constraints
- The suite measures Bun and Node together. An optimization for one runtime must not materially regress the other.
- Public result objects stay fresh, and returned byte ranges stay independently owned.
- Experimental native signature or layout changes are not retained.
Rejected Alternatives
- Packed/descriptor ABI rewrites that regressed a runtime, regressed another workload, or did not produce reliable reports.
- One-pointer seven-argument specialization because it was slower.
- Shared transfer-based trimming because it regressed Bun.
Method
Scenarios use production wrappers and live native objects. Setup, calibration, verification, and teardown are outside the retained sample. Output probes reject incorrect work.
The retired RendererHandle split-transition and split-snapshot calls are no longer benchmark scenarios.
The remaining scenarios cover standalone buffers, text, editors, images, audio, and span decoding.
Use the render workload preflights and packed-distribution fixtures for retained Context/Session frame and split-output checks.
Historical comparisons must select matching retained scenarios from recorded reports or frozen revisions.
ffi-fast-path-paired-benchmark.ts is the preferred comparison. It balances revision order, runs retained batches sequentially, records provenance and diagnostics, and reports paired nominal and multiplicity-adjusted bootstrap intervals. Negative deltas are faster. Safety requires at least 10 pairs and an adjusted upper bound at or below a 3% regression.
Calibration failures retry a complete pair. The report includes all retained timing and pair-gap drift without censoring. Lifecycle failures abort the run.
ffi-fast-path-benchmark.ts creates independent reports, ffi-fast-path-compare.ts applies the stricter ABI-admission gate to them, and ffi-fast-path-stress.ts diagnoses x64 Node process lifecycle failures. The repository intentionally stores no results for this suite. Regenerate them for the revisions and environment that you evaluate.
Run
From packages/core, set NODE26_PATH to Node 26.4 or later:
export NODE26_PATH=/absolute/path/to/node
bun run bench:ffi-fast-path --list-targeted-scenarios
# Run in the prepared baseline worktree.
bun run bench:ffi-fast-path --suite=default --runs=9 --json=/tmp/base.json
# Run in the candidate worktree.
bun run bench:ffi-fast-path --suite=default --runs=9 --json=/tmp/candidate.json
bun run bench:ffi-fast-path-paired --baseline-root=/absolute/base --candidate-root=/absolute/candidate --runs=40
bun run bench:ffi-fast-path-compare /tmp/base.json /tmp/candidate.jsonDefault runs omit the separately listed reusable-storage scenarios. Pass their comma-separated names with --scenario=<names> to either runner.
Run paired comparisons from the candidate worktree. Roots must be absolute and use matching scenario/calibration sources. Their native libraries must match unless you pass --allow-native-drift. Pair counts must be even. Worktrees must be clean unless you pass --allow-dirty.
If the baseline predates this suite, copy ffi-fast-path-scenarios.ts and ffi-fast-path-calibration.ts from the candidate into the same baseline paths, then pass --allow-dirty. The report records the copied files.
Packages Web Src Content Docs Application Apis Animation (`packages/web/src/content/docs/application-apis/animation.mdx`)
title: Animation and Timeline skill: entry: true intents: [animation, timeline, easing, use-timeline]
Animation and Timeline
OpenTUI animates mutable numeric properties with Timeline. Each renderer can own an independent timeline engine.
Use a timeline for numeric renderable properties or application state. The API mutates each target directly.
Animate a renderable
Pass the renderer when you create a registered timeline.
import { BoxRenderable, createCliRenderer, createTimeline, getTimelineEngine } from "@opentui/core"
const renderer = await createCliRenderer()
const engine = getTimelineEngine(renderer)
const root = renderer.root
const box = new BoxRenderable(renderer, {
id: "moving-box",
position: "absolute",
left: 0,
top: 1,
width: 8,
height: 3,
backgroundColor: "blue",
})
root.add(box)
let finish!: () => void
const finished = new Promise<void>((resolve) => {
finish = resolve
})
const timeline = createTimeline(
{
duration: 600,
autoplay: false,
onComplete: finish,
},
renderer,
)
timeline.add(box, {
left: 30,
duration: 600,
ease: "outQuad",
})
timeline.play()
try {
await finished
} finally {
timeline.pause()
engine.unregister(timeline)
renderer.destroy()
await renderer.closed
}The engine calls renderer.requestLive() and renderer.dropLive() as registered timelines start and stop. Do not call requestLive() for a registered timeline.
See Renderer live rendering for the shared live counter.
Imports
Core exports the class, factory, renderer-owned engine lookup, default engine, and public types from the package root:
import {
Timeline,
createTimeline,
getTimelineEngine,
engine,
type AnimationOptions,
type EasingFunctions,
type JSAnimation,
type TimelineOptions,
} from "@opentui/core"Framework hooks use their framework package roots:
import { useTimeline } from "@opentui/react"import { useTimeline } from "@opentui/solid"Choose a constructor
Construction and registration are separate behaviors.
| API | Initial state | Registration |
|---|---|---|
new Timeline(options?) |
Always paused, including autoplay: true |
Not registered |
createTimeline(options?, renderer?) |
Plays unless autoplay is false |
Uses the renderer's engine, or engine when omitted |
React or Solid useTimeline(options?) |
Plays on mount unless autoplay is false |
Registers with the component's renderer engine on mount |
new Timeline() stores the autoplay option but does not act on it. When you use the class directly, call play() and getTimelineEngine(renderer).register(timeline).
createTimeline() calls play() before it registers the timeline. Registration then updates the renderer's live state.
The framework hooks create the timeline during framework setup. Their mount handlers play and register it.
Timeline options
TimelineOptions field |
Default | Behavior |
|---|---|---|
duration |
1000 |
Timeline cutoff in milliseconds |
loop |
false |
Restarts the timeline at its cutoff only when exactly true |
autoplay |
true |
Used by the factory and framework mount handlers |
onComplete |
None | Runs when a non-looping timeline reaches its cutoff |
onPause |
None | Runs on every pause() call |
duration: 0 falls back to 1000 because the constructor uses a truthy fallback. Use a positive finite duration.
The timeline duration does not derive from its items. A shorter timeline cuts off a longer animation.
A longer timeline stays active after all its items finish. Its onComplete runs only at the timeline cutoff.
Add numeric animation
add(target, properties, startTime?) accepts one target or an array of targets. It returns the same timeline.
Additional numeric keys in properties are animation end values. The timeline ignores non-numeric end values.
When the item first becomes active, OpenTUI reads each matching top-level target property. It captures only existing numeric values.
The target properties must remain writable numbers. A missing or non-numeric initial property can produce NaN, and a read-only property can throw.
Initial values stay captured across timeline loops and restart(). The timeline does not recapture them on each run.
The timeline does not support nested property paths, arrays of component values, or object traversal. Animate a top-level numeric property instead.
`AnimationOptions`
| Field | Type | Behavior |
|---|---|---|
duration |
number |
Item duration in milliseconds. Required by the TypeScript interface |
ease |
EasingFunctions |
Named easing function. Default is linear |
onUpdate |
(animation: JSAnimation) => void |
Runs for each active update |
onComplete |
() => void |
Runs after the final item cycle |
onStart |
() => void |
Runs when the item first becomes active |
onLoop |
() => void |
Runs when an intermediate item cycle changes |
loop |
boolean | number |
Infinite cycles for true, or a numeric cycle count |
loopDelay |
number |
Delay after each item cycle. Default is 0 |
alternate |
boolean |
Reverses every odd item cycle. Default is false |
once |
boolean |
Removes the item after completion. Default is false |
| Other numeric keys | number |
End values for target properties |
Untyped code that omits item duration currently falls back to 1000. TypeScript callers must supply it.
Use a positive integer for numeric loop. false, undefined, and 1 all run one cycle.
Update callback data
onUpdate receives a JSAnimation object:
| Field | Meaning |
|---|---|
targets |
The normalized target array after property mutation |
deltaTime |
The update delta supplied for this evaluation |
progress |
Eased forward progress before alternate reverses assignment |
currentTime |
Current time of this timeline |
Back and elastic easing can make progress less than 0 or greater than 1. OpenTUI clamps the easing input itself to 0..1.
Callbacks run synchronously inside update(). A thrown error propagates to the caller of update().
In an engine update, the error stops that update. Later items and timelines do not update in that frame. The renderer catches the error from the engine's frame callback, logs it, and continues the frame.
Easing names
EasingFunctions accepts exactly these names:
linearinQuadoutQuadinOutQuadinExpooutExpoinOutSineoutBounceoutElasticinBounceinCircoutCircinOutCircinBackoutBackinOutBack
The API does not accept a custom easing function through ease.
Schedule items
Use numeric start times in milliseconds.
timeline.add(firstTarget, { x: 20, duration: 300 }, 0)
timeline.call(() => startSecondPhase(), 300)
timeline.add(secondTarget, { opacity: 1, duration: 200 }, 300)add() and call() accept number | string at runtime. Every string currently resolves to time 0.
Strings do not name labels or relative positions. Use finite non-negative numbers for predictable scheduling.
call(callback, startTime?) runs the callback once when timeline time reaches its start. A loop or restart resets that execution flag.
once(target, properties) adds an animation at the current timeline time. OpenTUI removes that item after its completion.
Normal completed items and callbacks remain in items. Only a completed once animation removes itself.
Timeline methods
| Method | Behavior |
|---|---|
add(target, properties, startTime = 0) |
Adds a numeric animation and returns this |
once(target, properties) |
Adds a removable animation at currentTime and returns this |
call(callback, startTime = 0) |
Adds a scheduled callback and returns this |
sync(timeline, startTime = 0) |
Gives this timeline scheduled control of one child |
play() |
Starts or resumes. A completed timeline restarts |
pause() |
Pauses this timeline and all synced children |
restart() |
Sets time to zero, resets item flags, and starts playback |
resetItems() |
Resets item and child flags without changing parent time or play state |
update(deltaTime) |
Evaluates synced children, items, loops, and completion |
addStateChangeListener(listener) |
Adds a play-state listener used by the engine |
removeStateChangeListener(listener) |
Removes matching play-state listeners |
pause() invokes onPause even when the timeline is already paused. It also calls pause() on every synced child.
restart() does not restore target properties immediately. The next active update calculates values from the captured initial values.
play() on a completed non-looping timeline calls restart(). play() on a paused incomplete timeline keeps currentTime.
update() expects elapsed milliseconds. An attached engine supplies its renderer's frame deltas, but tests and headless code can call it directly.
Use finite non-negative values for deltaTime, item duration, loopDelay, and start times. The current implementation does not validate these timing values.
A large delta evaluates each item once for that update. It can skip intermediate onUpdate and onLoop calls across multiple cycles.
For a looping timeline, OpenTUI keeps only the final duration overshoot. Large deltas do not replay every skipped parent loop.
Timeline state
Timeline exposes these mutable public fields:
| Field | Meaning |
|---|---|
items |
Animation and callback items |
subTimelines |
Synced-child items |
currentTime |
Current parent time in milliseconds |
isPlaying |
Whether parent items advance |
isComplete |
Whether a non-looping parent reached its cutoff |
duration |
Parent cutoff |
loop |
Parent loop flag |
synced |
Whether another timeline claimed this timeline as a child |
Prefer the methods for state transitions. Direct item-array mutation relies on internal item shapes that OpenTUI does not export.
Sync child timelines
parent.sync(child, startTime) starts the child when the parent reaches that time. The child receives any overshoot from the crossing update.
Later parent updates pass their full delta to the child. Parent loops reset and restart child scheduling.
A parent pause pauses children that already started. A later parent play resumes those started children.
You can sync a timeline only once. A second claim throws exactly Error("Timeline already synced").
Sync has no inverse operation. The child's synced flag remains true, even after engine unregistration.
The engine skips a registered timeline when synced is true. Its parent becomes the only normal update owner.
Current limitation: update() evaluates synced children before it checks whether the parent is playing. A paused parent can start a future child when another manual or engine update crosses its start time.
Do not depend on a pending child staying idle after its parent pauses. Unregister the parent during the pause, or add the child after resume.
Engine ownership
getTimelineEngine(renderer) returns the engine for that renderer. It throws when the renderer is destroyed. It also restores the engine's frame callback if application code cleared renderer callbacks. Renderer destruction unregisters its timelines and detaches the engine.
The exported engine is the default engine. createTimeline() uses it when you omit the renderer. It is separate from renderer-owned engines.
engine.attach(renderer) moves the default engine to that renderer. React createRoot(renderer).render(), Solid render(), and Solid testRender() call it. The renderer from the most recent call drives timelines that you create without a renderer. While the default engine is not attached, nothing updates its timelines. Renderer destruction detaches the default engine but keeps its timelines.
Detach a renderer-owned engine before you attach it to a different renderer. A timeline can belong to only one engine at a time.
| Method | Behavior |
|---|---|
attach(renderer) |
Installs a frame callback. Only engine can move renderers |
detach() |
Removes the frame callback and drops engine-owned live state |
register(timeline) |
Adds one timeline and watches its state |
unregister(timeline) |
Removes one timeline and its state listener |
clear() |
Unregisters all timelines from this engine |
update(deltaTime) |
Updates every registered timeline that is not synced |
attach() checks already-playing timelines and updates the renderer's live state. You do not need an explicit attach call when you use getTimelineEngine(renderer) or createTimeline(options, renderer).
The engine requests live rendering when any registered, unsynced timeline is playing and incomplete. It drops live rendering after the last such timeline stops.
createTimeline() never unregisters its result. A completed timeline remains in the engine set until application cleanup unregisters it.
An engine can serve several components in one renderer. Do not use clear() for component cleanup or for one independently owned timeline.
engine.defaults.frameRate currently exists, but the engine does not read it. Renderer frame scheduling controls update cadence.
Cleanup
The canonical Core cleanup sequence is:
- Call
timeline.pause(). - Call
engine.unregister(timeline). - Call
engine.detach()only when this owner explicitly controls the whole engine. - Destroy the renderer through the application's normal lifecycle.
Unregister synced children that were registered separately. A child made with new Timeline() does not need unregistration unless you registered it.
Do not call renderer.requestLive() or renderer.dropLive() for a registered timeline. The engine balances its own live request.
See Lifecycle and cleanup for renderer ownership and shutdown order.
Framework mapping
| API | Setup | Cleanup |
|---|---|---|
Core new Timeline() |
Application registers and plays it | Application pauses and unregisters it |
Core createTimeline() |
Factory registers and normally plays it | Application pauses and unregisters it |
React useTimeline() |
Hook mount effect registers and normally plays it | Effect cleanup pauses and unregisters the mount instance |
Solid useTimeline() |
Hook mount handler registers and normally plays it | Cleanup pauses and unregisters it |
React and Solid use the component's renderer-owned engine. Creating or destroying a root for another renderer does not move that engine or unregister its timelines.
Both hooks treat their option input as setup-time data. Later option changes do not update the mounted timeline.
React behavior
The React hook creates its Timeline once through lazy useState. Rerenders return the same registered timeline.
The first render also captures autoplay and all constructor options. Later option values do not reconfigure that timeline.
The React package has a regression test that checks timeline identity across a state-driven rerender.
Solid behavior
The Solid hook creates one timeline during component setup. Its options are not reactive after setup.
Both framework packages test that hook timelines on different renderers stay independent.
Animation callbacks
Use renderer.requestAnimationFrame(callback) and renderer.cancelAnimationFrame(handle) for one-shot animation work. Requests and cancellation belong to that renderer. Renderer destruction cancels its pending requests.
Each pending request holds one live request until its callback runs or you cancel it. requestAnimationFrame() returns -1 and stores nothing when the renderer cannot render, for example after destruction.
The callback receives the elapsed milliseconds since the previous frame, not a timestamp. A request made during a frame runs in the next frame.
Each frame runs animation-frame callbacks first. It then runs callbacks from renderer.setFrameCallback(), which include timeline engines. Both run before Core sends staged property changes to native code and runs the native frame step. Changes that these callbacks make appear in the same frame.
A thrown animation-frame callback error stops that frame. The renderer emits render:error, or logs the error when the event has no listener.
OpenTUI no longer installs or replaces process-global requestAnimationFrame, cancelAnimationFrame, or window properties. Migrate terminal applications to the renderer methods. OpenTUI does not change browser animation APIs.
After each animation-frame callback and each setFrameCallback() callback, the renderer yields to the shared Session scheduler. Between callbacks, that scheduler runs ready work for every Session that shares it. It parks Sessions that wait for output or host replies. It cannot interrupt a synchronous JavaScript callback. Long callbacks and large timelines still delay that work.
Limits and unsupported features
Timeline arrays have no built-in item limit. Bound registered timelines, items, and synced children with application policy.
Each engine update is proportional to registered timelines and their items and children. Nested synced timelines add their own item work.
Completed normal items remain in the scan. Use once() for temporary animations that should remove themselves.
Timeline supports top-level numeric interpolation only. It does not interpolate colors, strings, units, nested paths, or keyframe arrays.
The API has no spring or physics solver. It also has no seek, item removal, unsync, per-item pause, or public reverse method.
Use alternate for per-cycle reversal. Use application state and a new timeline when you need a scheduling change that the API does not support.
Next
Packages Web Src Content Docs Application Apis Audio Capture (`packages/web/src/content/docs/application-apis/audio-capture.mdx`)
title: Audio capture and recording skill: entry: true intents: [audio-capture, microphone, pcm, recording, wav, audio-recorder]
Audio capture and recording
OpenTUI captures microphone or input-device audio through the native miniaudio engine. Applications can consume Float32 PCM or record PCM16 WAV files.
Capture is independent from playback. It does not start output or the mixer, and playback lifecycle methods do not change capture state.
Capture a PCM stream
Create capture through audio.openCapture(options?). The AudioCaptureStream constructor is private.
import { Audio, type AudioCaptureStream } from "@opentui/core"
const audio = Audio.create({ autoStart: false })
let capture: AudioCaptureStream | null = null
audio.on("error", (error, context) => {
console.error(`${context.action}: ${error.message}`)
})
function reportPcm(pcm: Float32Array, channels: number): void {
console.log(`received ${pcm.length / channels} frames`)
}
try {
const activeCapture = await audio.openCapture({
channels: 1,
chunkFrames: 2048,
})
capture = activeCapture
activeCapture.on("error", (error, context) => {
console.error(`${context.action}: ${error.message}`)
})
const consumption = (async () => {
for await (const pcm of activeCapture.readable) {
reportPcm(pcm, activeCapture.channels)
}
})()
await new Promise((resolve) => setTimeout(resolve, 1000))
activeCapture.stop()
await consumption
await activeCapture.closed
} finally {
if (capture != null && capture.state !== "stopped" && capture.state !== "errored" && capture.state !== "disposed") {
capture.dispose()
}
await capture?.closed
audio.dispose()
}Audio and AudioCaptureStream inherit Node's EventEmitter. Attach the Audio error listener before openCapture().
Attach the stream error listener immediately after setup resolves and before reading. Setup failures reject because no stream exists for a listener yet.
An unhandled later error event throws. A parent method that returns false or null can emit and throw before it returns.
Capture options
openCapture() accepts these options:
| Option | Default | Effect |
|---|---|---|
channels |
1 |
Samples in each interleaved frame |
capacityFrames |
audio.sampleRate |
Native ring capacity, equal to one second by default |
chunkFrames |
2048 |
Frames in each normal stream chunk |
startOptions |
Capture native defaults | Low-level input-device options |
signal |
None | Disposes capture when aborted |
Frame counts and channel counts must be finite positive unsigned 32-bit integers. chunkFrames cannot exceed capacityFrames.
The product of chunkFrames and channels must also fit an unsigned 32-bit integer. Native channel and ring-allocation limits can reject larger valid integers.
Capture always uses audio.sampleRate. Create another Audio engine to use a different sample rate.
recordToFile() accepts the same options. WAV recording limits channels to 1 or 2.
Capture start-option detail
Capture has a special default for noFixedSizedCallback. The wrapper sets the native value to true when that field
is undefined. This applies when startOptions is omitted and when it is {}. PassstartOptions: { noFixedSizedCallback: false } to select false explicitly.
See Low-level start options for the complete field list.
PCM stream behavior
readable is a ReadableStream<Float32Array> with highWaterMark: 0. OpenTUI reads native PCM only when a consumer requests data.
While capture runs, the stream waits for a complete chunkFrames block. Each normal array contains chunkFrames * channels valid samples.
PCM uses interleaved 32-bit floating-point samples. Values are nominally in [-1, 1], and one frame contains one sample for each channel.
The stream does not build a JavaScript PCM queue. It keeps one in-progress chunk and uses the native ring as its bounded buffer.
The native input callback never waits for JavaScript. When the ring is full, it keeps older frames and drops the newest complete frames.
getStats().framesDropped counts that loss. A nonzero value means the captured timeline has gaps.
framesRead counts frames removed from the native ring. It includes frames discarded during an unlocked graceful stop.
Thus, framesRead does not always equal frames delivered to application code.
Stop, cancel, and dispose
stop() stops the input device and starts a graceful drain. Its state changes to stopping until unread PCM is handled.
When a consumer holds the readable lock, the stream emits every remaining full chunk. It then emits one exact-length short final chunk when needed.
The consumer must continue reading during this drain. A locked reader that stops pulling can keep graceful shutdown pending.
Release the reader lock when the consumer will not continue. The stream then switches to its no-reader drain.
Without a readable lock, OpenTUI discards unread PCM in bounded batches. It yields to the event loop after each 32 chunks.
The discard updates native framesRead, releases capture ownership, and lets closed resolve without consumer demand.
Canceling readable, calling dispose(), aborting signal, or disposing the owning Audio skips the graceful drain. The terminal state becomes disposed.
closed resolves after cleanup and the one terminal event. It never rejects.
Select an input device
List and select an input before capture starts.
const devices = audio.listCaptureDevices() ?? []
const selected = devices.find((device) => device.isDefault) ?? devices[0]
if (selected != null && !audio.selectCaptureDevice(selected.index)) {
throw new Error(`Could not select ${selected.name}`)
}Each AudioCaptureDevice has index, name, and isDefault fields. An empty array means enumeration found no inputs.
null means enumeration failed. Device indices belong to the latest enumeration and can change after hot-plug events.
clearCaptureDeviceSelection() removes the explicit selection. A later start then asks the backend for its default input.
selectCaptureDevice() fails while a capture device is open. Stop capture before selecting another input.
An owned stream or recorder also blocks clearCaptureDeviceSelection(). During direct capture, clearing changes only the selection for a later start.
Exclusive capture ownership
One Audio engine permits one capture owner. An active AudioCaptureStream or AudioRecorder owns the native capture ring exclusively.
A second openCapture() rejects with AudioCaptureStreamError. A second recordToFile() rejects with AudioRecorderError after it cleans its temporary file.
These direct methods fail while a stream or recorder owns capture:
| Direct method | Owned-capture result |
|---|---|
startCapture() |
Returns false and emits Audio.error |
readCaptureFrames() |
Returns null and emits Audio.error |
stopCapture() |
Returns false and emits Audio.error |
selectCaptureDevice() |
Returns false and emits Audio.error |
clearCaptureDeviceSelection() |
Returns void and emits Audio.error |
listCaptureDevices(), isCapturing(), and getCaptureStats() remain available. Their information can describe the active owner.
A failed native stop can retain exclusive ownership after the child enters errored. A later dispose() call retries cleanup.
The parent also retries child cleanup during audio.dispose(). Do not create another owner until cleanup releases the first one.
Poll capture directly
Use the direct API when application code controls the polling cadence.
audio.on("error", (error, context) => {
console.error(`${context.action}: ${error.message}`)
})
if (!audio.startCapture({ channels: 2, capacityFrames: 48_000 })) {
throw new Error("Could not start capture")
}
const result = audio.readCaptureFrames(1024)
if (result != null) {
const channels = audio.getCaptureStats()?.channels ?? 2
const validSamples = result.frames.subarray(0, result.framesRead * channels)
console.log(validSamples.length)
}
audio.stopCapture()Direct method reference
| Method | Result and behavior |
|---|---|
listCaptureDevices() |
Returns AudioCaptureDevice[] | null |
selectCaptureDevice(index) |
Selects one current index and returns boolean |
clearCaptureDeviceSelection() |
Clears explicit selection and returns void |
startCapture(options?) |
Starts input and returns boolean |
isCapturing() |
Reports whether the input device still runs |
readCaptureFrames(frameCount) |
Returns { frames, framesRead } | null |
getCaptureStats() |
Returns AudioCaptureStats | null |
stopCapture() |
Stops the input device and returns boolean |
readCaptureFrames() consumes up to frameCount frames. frameCount cannot exceed the configured ring capacity.
Each call allocates frameCount * channels samples. Only the first framesRead * channels samples contain captured data.
stopCapture() preserves unread PCM for later direct reads. A later successful startCapture() replaces the ring and resets all counters.
While capture runs, startCapture() is idempotent when no explicit setting differs. Matching channels and capacityFrames also succeed without a restart.
Any supplied startOptions requests reconfiguration and fails while capture runs. Stop capture before changing options.
Successful transitions emit parent captureStarted and captureStopped events. An observed external device stop can also emit captureStopped.
Record a WAV file
Create recording through audio.recordToFile(filePath, options?). The AudioRecorder constructor is private.
import { Audio, type AudioRecorder } from "@opentui/core"
const audio = Audio.create({ autoStart: false })
const recording = "recording.wav"
let recorder: AudioRecorder | null = null
audio.on("error", (error, context) => {
console.error(`${context.action}: ${error.message}`)
})
try {
const activeRecorder = await audio.recordToFile(recording, {
channels: 1,
})
recorder = activeRecorder
activeRecorder.on("error", (error, context) => {
console.error(`${context.action}: ${error.message}`)
})
await new Promise((resolve) => setTimeout(resolve, 1000))
activeRecorder.stop()
await activeRecorder.closed
if (activeRecorder.state === "errored") {
throw new Error("Recording failed")
}
} finally {
if (
recorder != null &&
recorder.state !== "stopped" &&
recorder.state !== "errored" &&
recorder.state !== "disposed"
) {
recorder.dispose()
}
await recorder?.closed
audio.dispose()
}AudioRecorder also inherits EventEmitter. Attach its error listener immediately after setup and before waiting or calling a method.
Setup errors reject recordToFile(). Later write, capture, finalization, publication, and cleanup errors emit error.
WAV format and filesystem behavior
The recorder writes little-endian PCM16 WAV. Its format property is the literal "wav".
Float samples clamp to [-1, 1] before conversion. The recorder writes chunks sequentially and keeps one capture read pending during each file write.
The destination path must be a nonempty string without NUL bytes. Its parent directory must already exist.
OpenTUI creates the temporary file in the destination directory with exclusive creation. It tries at most 64 candidate names and never opens an existing candidate.
Recording first writes a placeholder header to that temporary file. It does not modify an existing destination during capture.
stop() stops input, drains all buffered frames, and writes the final WAV header. It then syncs and closes the temporary file.
After those steps, OpenTUI calls a same-directory filesystem rename() from the temporary path to the destination. The host filesystem defines replacement and rename guarantees.
OpenTUI adds no guarantee beyond that rename behavior. Code that needs a specific durability or replacement contract must account for the target filesystem.
The rename call is the publication point of no return. After rename starts, recorder disposal, signal abort, and parent disposal let it finish.
Before rename starts, disposal tries to remove the temporary file. Header, sync, close, or capture failures do not publish that file.
A cleanup failure reports action destroy and can retain the file handle or temporary path. A later dispose() retries those retained resources.
Dropped capture frames are terminal for a recorder. OpenTUI reports an error and does not publish an incomplete recording.
Classic RIFF limits the audio data chunk to exactly 4,294,967,259 bytes. The recorder does not support RF64 or compressed WAV data.
Stream and recorder members
`AudioCaptureStream`
| Member | Behavior |
|---|---|
readable |
Demand-driven stream of interleaved Float32Array chunks |
sampleRate, channels |
Captured PCM format |
chunkFrames |
Normal requested chunk size |
state |
Current capture-stream state |
getStats() |
Refreshes and returns capture statistics |
stop() |
Stops input and starts a graceful drain |
dispose() |
Stops without a graceful drain |
closed |
Resolves after cleanup and terminal event delivery |
Capture-stream state is initializing, capturing, stopping, stopped, errored, or disposed.
Capture-stream statistics contain state, sampleRate, channels, capacityFrames, bufferedFrames, and bufferedDurationMs. They also contain framesReceived, framesRead, and framesDropped.
`AudioRecorder`
| Member | Behavior |
|---|---|
filePath, format |
Destination path and literal "wav" |
sampleRate, channels |
Recorded PCM format |
state |
Current recorder state |
getStats() |
Returns capture, file, and duration statistics |
stop() |
Drains, finalizes, syncs, closes, and renames |
dispose() |
Aborts before rename and removes the temporary file |
closed |
Resolves after cleanup and terminal event delivery |
Recorder state is initializing, recording, stopping, stopped, errored, or disposed.
Recorder statistics add framesWritten, dataBytesWritten, and durationMs to capture statistics. durationMs derives from written frames.
Events and error actions
Both child classes emit asynchronous, mutually exclusive terminal events:
| Class | Events |
|---|---|
AudioCaptureStream |
stopped, error, disposed |
AudioRecorder |
stopped, error, disposed |
Each error event receives the typed error and { action, status? }. closed resolves after the event listener runs, even if that listener throws.
The exact AudioCaptureStreamAction values are start, read, stop, stats, and destroy.
The exact AudioRecorderAction values are open, start, read, write, stop, finalize, publish, stats, and destroy.
Invalid options throw TypeError or RangeError before native work. A pre-start abort rejects with AbortError.
Capture-stream setup failures reject with AudioCaptureStreamError. Recorder setup and cleanup failures reject with AudioRecorderError.
Later failures error the readable when applicable and emit the child error event. Unhandled error events throw through EventEmitter.
Permissions and signal limits
The application that starts OpenTUI must have microphone permission. On macOS, grant access to the terminal application and restart it fully.
A packaged macOS application needs its own permission. Windows privacy controls can block microphone access for desktop applications.
Linux capture uses PulseAudio or ALSA. PipeWire works through its PulseAudio compatibility layer, subject to device permissions.
Headless sessions, containers, sandboxes, and continuous integration systems often have no input device. Enumeration can return null or an empty array.
A denied or unavailable input can reject setup, return false, or deliver clocked digital silence. Advancing frame counters does not prove that a signal exists.
OpenTUI capture does not provide compressed recording, system-output loopback, echo cancellation, or JavaScript callbacks on the native audio thread.
Run compression and signal processing outside the native callback path. Use platform-specific input facilities for system loopback.
Next
Packages Web Src Content Docs Application Apis Audio Streaming (`packages/web/src/content/docs/application-apis/audio-streaming.mdx`)
title: Streaming audio skill: entry: true intents: [audio-streaming, audio-stream, pcm, f32le, radio, mp3, flac, icy, backpressure, reconnect]
Streaming audio
OpenTUI streams PCM, MP3, or FLAC bytes into the native audio mixer. Streaming starts before the complete source arrives.
Use streaming for generated audio, external decoders, long media, or internet radio. Use loaded sounds for short, reusable audio.
Play decoded PCM
Pass format: "pcm", sampleFormat: "f32le", sampleRate, and channels to audio.playStream(). The source is a ReadableStream or AsyncIterable, just like encoded audio. Playback returns the same AudioStream with the same controls, metadata, statistics, and lifecycle.
import { Audio, type AudioStream } from "@opentui/core"
const audio = Audio.create({ autoStart: false })
audio.on("error", (error) => console.error(error))
let stream: AudioStream | undefined
async function* tone(): AsyncGenerator<Uint8Array> {
const framesPerChunk = 441
const chunk = new Uint8Array(framesPerChunk * 4)
const view = new DataView(chunk.buffer)
for (let offset = 0; offset < 44100; offset += framesPerChunk) {
for (let frame = 0; frame < framesPerChunk; frame++) {
const sample = Math.sin((2 * Math.PI * 440 * (offset + frame)) / 44100) * 0.15
view.setFloat32(frame * 4, sample, true)
}
yield chunk
// Reuse the buffer only after the generator resumes.
}
}
try {
if (!audio.start()) throw new Error("No playback device is available")
stream = await audio.playStream(tone(), {
format: "pcm",
sampleFormat: "f32le",
sampleRate: 44100,
channels: 1,
buffer: { capacityMs: 500, startupMs: 20, resumeMs: 20 },
})
stream.on("error", (error) => console.error(error))
await stream.closed
} finally {
try {
stream?.dispose()
await stream?.closed
} finally {
audio.dispose()
}
}PCM format and ownership
- Each sample is a finite 32-bit float encoded in little-endian byte order, nominally in
[-1, 1]. Input is interleaved: mono is one sample per frame; stereo is left, right, left, right. channelsis1or2.sampleRateis an integer from8000through192000Hz. The engine rate must be in that range too. Native conversion duplicates mono into stereo and resamples to the engine rate, preserving conversion history between chunks.- Byte chunks may split samples or frames at any boundary. OpenTUI carries incomplete input between reads. EOF with a partial final frame is an error. Empty chunks are accepted.
- Do not mutate, transfer, or detach a chunk while OpenTUI consumes it. An async generator may reuse its yielded buffer when the generator resumes. A
ReadableStreamproducer must not reuse a queued buffer before it is consumed. Shared backing memory is rejected.
For generated or captured Float32Array samples, encode bytes explicitly rather than relying on the machine's native byte order:
async function* pcmBytes(source: AsyncIterable<Float32Array>): AsyncGenerator<Uint8Array> {
for await (const samples of source) {
const bytes = new Uint8Array(samples.length * 4)
const view = new DataView(bytes.buffer)
for (let i = 0; i < samples.length; i++) {
view.setFloat32(i * 4, samples[i], true)
}
yield bytes
}
}Pass pcmBytes(source) to playStream() with the source's sample rate and channel count. For direct capture reads, encode only the first framesRead * channels samples.
An external decoder's f32le byte pipe can be passed directly as the source. Applications own decoder processes and must stop them on cancellation. OpenTUI cancels the reader or returns the iterator; the source must release its resources in cancel(), iterator cleanup, or a connector's close() callback. Make pending external reads respond to cancellation too.
PCM buffering and completion
PCM uses the shared buffer defaults: capacityMs: 2000, startupMs: 1000, and resumeMs: 1000. The example explicitly lowers them to 500, 20, and 20 milliseconds for low latency.
PCM uses the same native worker and 256 KiB input queue as MP3 and FLAC. The worker converts input in batches of at most 2048 frames into the output ring. A full output ring pauses the worker; a full input queue backpressures the source. Accepted bytes need not already be converted. Native code retains no JavaScript pointers.
Source EOF automatically flushes the demuxer and signals the worker to finish conversion, including its tail, then drains queued audio. Await stream.closed for terminal cleanup; there is no separate write or end call. closed never rejects, so attach an error listener to observe playback failures. It resolves after cancellation and errors too.
Start the audio device first, or call audio.startMixer() and consume audio.mixFrames() regularly. A stopped mixer cannot drain queued audio. Completion means the mixer consumed the stream, not that the device physically presented the final sample.
Use the existing setVolume(), setPan(), setGroup(), and dispose() methods. Input format stays fixed for the stream's lifetime. For a track switch or rate change, dispose the old stream and open another source. Streaming has no public pause, resume, clear, write, or end methods.
The same PCM options work with playStreamUrl() and playStreamSource(), including custom demuxers where supported. PCM reconnect is not supported: passing reconnect rejects before the source opens.
PCM uses the shared statistics: bytesReceived counts accepted input bytes, including a pending partial frame; framesDecoded counts converted engine-rate output, including the conversion tail. There is no separate input-frame counter.
Run the generated-tone example from the examples package with bun src/pcm-playback.ts. Add --headless to exercise conversion and mixing without an output device.
Play a URL
The engine does not start output when it creates a stream. Start a playback device first, or consume headless output with mixFrames().
import { Audio, AudioStreamError, type AudioStream } from "@opentui/core"
const audio = Audio.create({ autoStart: false })
const controller = new AbortController()
const stopTimer = setTimeout(() => controller.abort(), 30_000)
let stream: AudioStream | null = null
function reportSetupError(error: unknown): void {
if (error instanceof AudioStreamError) {
console.error(`${error.context.action}: ${error.message}`)
} else if (!(error instanceof DOMException && error.name === "AbortError")) {
throw error
}
}
audio.on("error", (error, context) => {
console.error(`${context.action}: ${error.message}`)
})
try {
if (!audio.start()) throw new Error("No playback device is available")
stream = await audio.playStreamUrl("https://localhost/radio", {
signal: controller.signal,
reconnect: { maxRetries: 5 },
})
stream.on("error", (error, context) => {
console.error(`${context.action}: ${error.message}`)
})
await stream.closed
} catch (error) {
reportSetupError(error)
} finally {
clearTimeout(stopTimer)
if (stream != null && stream.state !== "ended" && stream.state !== "errored" && stream.state !== "disposed") {
stream.dispose()
}
await stream?.closed
audio.dispose()
}Audio and AudioStream inherit Node's EventEmitter. Attach the Audio error listener before start() or stream setup.
Attach the AudioStream error listener immediately after setup resolves. Later stream errors emit asynchronously.
Stream setup errors reject the entry-method promise because the stream is not available for a listener yet. An unhandled later error event throws.
A control method can return false and schedule an error event. Without a listener, the event can throw after the
caller receives false.
Choose an entry method
The three entry methods own different source policies.
| Method | Source and ownership |
|---|---|
playStream(source, options?) |
Reads one ReadableStream or AsyncIterable to EOF |
playStreamUrl(url, options?) |
Owns Fetch, HTTP validation, ICY metadata, and optional reconnects |
playStreamSource(connector, options?) |
Opens custom connections and creates a demuxer for each connection |
playStream() is one-shot. It does not accept request, reconnect, content-type, or metadata-encoding options.
playStreamUrl() accepts a string or URL. It creates a new response body and ICY demuxer for each reconnect.
playStreamSource() accepts an AudioStreamConnector. Its demuxer(info) callback can create transport-specific framing for each connection.
All methods support format: "pcm", format: "mp3", and format: "flac". The default is "mp3".
The promise resolves after native playback setup is ready. Encoded streams must probe their decoder first; PCM already has an explicit input format. The stream is usually still in buffering state at that time.
Shared options
| Option | Default | Constraint or effect |
|---|---|---|
format |
"mp3" |
"pcm", "mp3", or "flac" |
sampleFormat |
None | Required for PCM; only "f32le" |
sampleRate |
None | Required for PCM; integer from 8000 through 192000 Hz |
channels |
None | Required for PCM; 1 or 2 |
volume |
1 |
Native playback clamps it to 0..4 |
pan |
0 |
Native playback clamps it to -1..1 |
groupId |
0 |
Must be an unsigned 32-bit ID for a group in the owning engine |
buffer.capacityMs |
2000 |
Decoded PCM capacity |
buffer.startupMs |
1000 |
Initial playback threshold |
buffer.resumeMs |
1000 |
Resume threshold after an underrun |
maxProbeBytes |
1048576 |
Maximum encoded bytes inspected during startup; unused for PCM |
signal |
None | Cancels setup, source work, reconnect delays, and playback |
The three buffer durations and maxProbeBytes must be positive unsigned 32-bit integers. Startup and resume durations cannot exceed capacity.
sampleFormat, sampleRate, and channels are PCM-only options. Supplying them for an encoded format rejects setup.
All formats produce stereo interleaved Float32 PCM at audio.sampleRate. PCM conversion requires an engine rate from 8000 through 192000 Hz. The maximum output capacity is 268,435,452 stereo frames.
Each stream uses one of the engine's 32 combined loaded-voice and stream slots.
Start, underrun, and EOF behavior
Decoding or conversion fills the PCM ring before playback crosses startupMs. Calls to mixFrames() return silence for that stream below the initial threshold.
After playback starts, starvation changes the stream to buffering and increments underruns once. Playback resumes when PCM reaches resumeMs.
If input reaches EOF with some PCM, playback can start or resume below either threshold. It drains the final frames before the stream ends.
Clean source EOF first flushes the demuxer. OpenTUI then finishes decoding or flushes the PCM converter and waits for output to drain.
ended and closed can remain pending when no output consumes the decoded ring. Call start(), or call startMixer() and mixFrames() at a regular cadence.
During a reconnect, a stream that already started can play PCM left in its decoded ring. Controls, counters, and the native voice slot remain in the same stream session.
Buffering and backpressure
OpenTUI bounds these internal layers:
| Layer | Bound |
|---|---|
| Native input bytes | 256 KiB per stream |
| Native output PCM | buffer.capacityMs, up to 268,435,452 stereo frames |
| Native PCM conversion | 2048 input frames per batch plus one partial frame |
| JavaScript source demand | One active read() or iterator next() |
OpenTUI copies the current source chunk into the native input queue before it requests the next chunk. A full input queue applies backpressure to that chunk. buffer.capacityMs bounds the output ring, not audio also waiting in the input queue or worker batch.
A blocked native write retries at 5 ms intervals. This polling exists only while that write has no space.
An empty input chunk yields with a zero-delay timer. This prevents an endless empty source from blocking cancellation.
OpenTUI does not limit the size of one supplied chunk. It also does not limit one custom demuxer output or the number of outputs in its iterable.
Source implementations can maintain their own queues before OpenTUI calls read() or next(). Those external queues are outside OpenTUI's bounds.
Readiness and EOF use short-lived polling. The stream does not keep a persistent idle statistics timer.
URL response policy
playStreamUrl() uses contentTypePolicy: "validate" by default.
| Format | Accepted Content-Type values |
|---|---|
| MP3 | audio/mpeg, audio/mp3, application/mp3, application/octet-stream, or missing |
| FLAC | audio/flac, audio/x-flac, application/octet-stream, or missing |
| PCM | audio/pcm, application/octet-stream, or missing |
Validation ignores case and parameters such as charset=binary. It evaluates every initial and replacement response before native stream allocation or byte ingestion.
Use contentTypePolicy: "ignore" when the server has an incorrect label. Audio input validation still applies. For PCM, headers do not replace the required sample format, rate, and channel options.
A callback policy receives { format, contentType, status, url } and must return a boolean. The url is the effective response URL after redirects.
The request option accepts RequestInit except body and signal. OpenTUI also strips those two fields at runtime.
Use the stream-level signal option for cancellation. Request headers and other request options apply again to every reconnect.
ICY metadata
URL streams add Icy-MetaData: 1 unless request.headers already contains that header. Set the header to 0 to disable negotiation explicitly.
OpenTUI copies every icy-* response header into an immutable metadata snapshot. Header names become lowercase.
A non-negative safe integer icy-metaint controls in-band framing. A positive value enables metadata blocks, while zero exposes headers without framing.
OpenTUI does not infer framing from a URL, content type, server name, or station header. An invalid or ambiguous interval rejects the response.
getMetadata() returns the latest { format: "icy", headers, fields } snapshot or null. In-band field names keep their source case.
Unknown fields remain in the snapshot. Zero-length blocks, invalid field text, and repeated equivalent fields do not emit a change.
ICY does not define one text encoding. OpenTUI defaults to ISO-8859-1, which the Encoding Standard decodes as windows-1252.
Set metadataEncoding when a station uses another encoding. OpenTUI validates the encoding before it sends a request.
Metadata is untrusted text. Remove terminal control characters before display, and do not open a metadata URL without validation.
Metadata follows response ingestion, not audible playback. A title can arrive before the related decoded PCM becomes audible.
Events are asynchronous and rapid changes can coalesce. getMetadata() always returns the latest snapshot.
Metadata found during setup remains available after the promise resolves. OpenTUI schedules its event so an immediate listener can observe it.
During reconnect, the old snapshot remains until the replacement demuxer starts. Replacement ICY fields replace the old fields.
A replacement response without ICY headers sets metadata to null and emits metadata with null.
Reconnect URL streams
Reconnect is opt-in for MP3 and FLAC. Pass a reconnect object to enable it. PCM rejects this option before opening a URL or custom source.
| Option | Default | Constraint or effect |
|---|---|---|
maxRetries |
Infinity |
Consecutive retries for one outage |
initialDelayMs |
1000 |
First retry delay |
maxDelayMs |
15000 |
Delay cap |
backoffFactor |
2 |
Exponential factor, at least 1 |
retryOnEnd |
false |
Reconnect after clean EOF and PCM drain |
retry |
None | Overrides retry classification or delay |
Use a finite maxRetries and an AbortSignal for unattended streams. The default retry count can keep initial setup pending without a limit.
URL streams retry these cases by default:
- Fetch failures
- A successful response without a body
- HTTP
408,425, or429 - HTTP status values from
500through599 - A response body that fails before clean EOF
Other HTTP responses and content-type failures stop by default. Decoder failures, native creation failures, and demuxer push() failures are terminal.
Valid Retry-After seconds or dates replace normal backoff, up to maxDelayMs.
reconnect.retry(error, context) receives the next attempt, maxRetries, and a phase of "connect" or "read".
Return false to stop. Return {} to keep a transport delay or normal backoff.
Return { delayMs } to set a finite non-negative integer delay. OpenTUI caps that value at maxDelayMs.
The callback can override default URL response classification. OpenTUI does not call it for a clean EOF retry from retryOnEnd.
Initial retries occur while playStreamUrl() is pending. They call retry, but they do not emit reconnecting because callers do not have the stream yet.
After a replacement decoder becomes ready, the consecutive attempt number and retry budget reset. A later outage starts at attempt 1 again.
getStats().reconnectAttempts remains cumulative. It includes retries that occurred during initial setup and all later outages.
Use a custom connector
An AudioStreamConnector opens one connection at a time:
interface AudioStreamConnectContext {
readonly signal: AbortSignal
readonly attempt: number
}
interface AudioStreamConnection<I> {
readonly body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
readonly info: I
close?(): void | Promise<void>
}
interface AudioStreamConnector<I> {
connect(context: AudioStreamConnectContext): Promise<AudioStreamConnection<I>>
}The initial attempt is 0. Each consecutive reconnect gets the next value until a decoder becomes ready.
Connector and body-read failures are retryable by default when reconnect exists. The same retry options and callback rules apply.
The info value goes to the connection's demuxer factory. Return undefined when the transport has no connection metadata.
Use a custom demuxer
playStream() accepts demuxer: () => AudioStreamDemuxer<M>. playStreamSource() accepts demuxer: (info) => AudioStreamDemuxer<M> | null.
Each connector attempt receives a fresh demuxer. A demuxer has this interface:
type AudioStreamDemuxOutput<M> = { type: "audio"; data: Uint8Array } | { type: "metadata"; metadata: M | null }
interface AudioStreamDemuxer<M> {
readonly initialMetadata: M | null
push(chunk: Uint8Array): Iterable<AudioStreamDemuxOutput<M>>
flush(): Iterable<AudioStreamDemuxOutput<M>>
abort?(reason: unknown): void
}OpenTUI consumes outputs in iterable order. It sends audio outputs to the decoder or PCM converter and publishes metadata outputs. For format: "pcm", demuxer audio outputs contain f32le bytes and may split samples or frames.
Clean EOF calls flush() and consumes all flush outputs before native EOF. An interruption calls abort() instead of flush().
A push() exception is terminal. A flush() exception means truncated input and follows the reconnect read policy.
Custom metadata snapshots should be immutable. The demuxer controls equivalent-update suppression for its metadata type.
Use createIcyStreamDemuxer() for ICY framing on a non-HTTP transport. It accepts metadataInterval, metadataEncoding, and optional headers.
The public ICY demuxer uses the same windows-1252 default. It copies headers and changes their names to lowercase.
Connection cleanup
OpenTUI waits for an active resource acquisition to finish before it cleans that connection.
Cleanup first calls demuxer.abort() when the demuxer did not finish. It then cancels the reader or returns the async iterator.
OpenTUI calls connection.close() after it requests source release. It waits for source and connection cleanup together.
A reconnect waits for the previous connection cleanup before it opens the replacement. This prevents two custom connections from overlapping.
Terminal shutdown gives uncooperative cleanup 50 ms of grace. The closed promise can then resolve while an external cleanup promise remains pending.
OpenTUI calls close() at most once for each returned connection. It ignores exceptions from cleanup callbacks.
dispose() can throw AudioStreamError with action destroy when native close fails. The stream retains its native ownership so a later dispose() can retry.
Members, state, and statistics
AudioStream member |
Behavior |
|---|---|
format |
Resolved "pcm", "mp3", or "flac" value |
state |
Latest cached lifecycle snapshot |
getStats() |
Refreshes native state and returns current or final statistics |
getMetadata() |
Returns the latest metadata snapshot or null |
setVolume(volume) |
Sets volume and returns boolean |
setPan(pan) |
Sets pan and returns boolean |
setGroup(groupId) |
Moves the stream and returns boolean |
dispose() |
Cancels source work, reconnects, and native playback |
closed |
Resolves after terminal event delivery and bounded cleanup |
state can be initializing, buffering, playing, reconnecting, ended, errored, or disposed. Read getStats() for an explicit native refresh.
Controls remain active across reconnects. They return false after terminal cleanup or when native control fails.
getStats() returns these fields:
| Field | Meaning |
|---|---|
state |
Refreshed lifecycle state |
sampleRate, channels |
Output format: engine sample rate and stereo |
bufferedFrames, capacityFrames |
Current output ring use and capacity |
bufferedDurationMs |
Current buffered output duration |
bytesReceived |
Audio bytes accepted from demuxer outputs |
framesDecoded, framesPlayed |
Cumulative produced and consumed output frames |
underruns |
Starvation transitions after playback starts |
reconnectAttempts |
Cumulative reconnect attempts |
bytesReceived excludes framing and metadata bytes. For PCM it includes bytes held in a pending partial frame. Integer-divide by channels * 4 using the input channel count to recover the number of complete input frames received.
framesDecoded means decoded frames for encoded formats and converted output frames for PCM. PCM output includes conversion delay and the flushed filter tail. framesPlayed excludes underrun silence. Frame counters are bigint and use the engine rate; buffered duration excludes device buffers. Final statistics and metadata remain readable after natural completion.
Events and errors
| Event | Payload and meaning |
|---|---|
metadata |
Latest metadata value or null |
reconnecting |
{ attempt, delayMs, maxRetries, error } |
ended |
Clean source and decoded PCM completion |
error |
(error, { action, status?, errorCode?, attempt? }) |
disposed |
Explicit, signal, or parent disposal |
Terminal ended, error, and disposed events are asynchronous and mutually exclusive. closed resolves after the terminal listener runs, even if that listener throws.
closed never rejects. An unhandled error event still throws through normal EventEmitter behavior.
The exact AudioStreamAction values are:
fetchresponsesourcedemuxercreatewriteendrestartstatsdecoderdestroysetVolumesetPansetGroup
Transport, response, demuxer, native creation, and decoder setup failures reject with AudioStreamError. JavaScript option validation rejects with TypeError or RangeError.
Invalid source chunks reject or emit a TypeError. Cancellation uses an AbortError and becomes disposed after setup.
The worker detects non-finite PCM samples and EOF with a partial input frame; either terminates playback with AudioStreamError. Accepting input bytes does not mean they have passed conversion. PCM follows the same setup rejection and asynchronous terminal-event rules as encoded streams.
Starting a stream after its owning Audio was disposed rejects with Error. Disposing the owner also disposes pending and active streams.
Unsupported operations
Built-in streaming decoders do not support WAV, AAC, Ogg, Opus, or HLS playlists. Applications can decode externally and supply PCM bytes instead. Streaming has no seeking, pause, resume, clear, or public restart operation.
The restart error action belongs to internal reconnect work. It is not an AudioStream method.
Streams use the combined master tap. OpenTUI does not expose an isolated stream tap.
Next
Packages Web Src Content Docs Application Apis Program Status (`packages/web/src/content/docs/application-apis/program-status.mdx`)
title: Program status skill: intents: [program-status, osc7501, terminal, progress, renderer]
Program status
OpenTUI sends OSC 7501 Program Status Protocol reports.
A report tells the terminal what the program is doing. The terminal decides how to show it, for example as a tab
indicator, an inbox entry, or a desktop notification.
renderer.setProgramStatus({ state: "working", app: "deploy", message: "Deploying v2.4.1" })
renderer.setProgramStatus({
state: "blocked",
kind: "permission",
app: "deploy",
message: "Approve production deploy?",
})
renderer.setProgramStatus({ state: "done", app: "deploy", message: "Deployed to 3 regions" })States
state |
Meaning | Extra fields |
|---|---|---|
idle |
At rest and waiting for the next instruction | |
working |
Running | progress |
blocked |
Waiting for the user | kind, progress |
done |
Finished, and the user has not seen the result yet | |
error |
Failed and stopped | |
clear |
Removes the addressed record and every record beneath it | Accepts only id |
kind is "permission", "question", or "auth". progress is an integer from 0 to 100. Omit progress when the
work has no percentage.
Report idle when the user cancels work. Report done or error before the program exits, so the terminal keeps a
record for the user to find.
Records
Each report replaces its record completely. A field missing from a report is missing from the record afterwards, so
send app and title with every report that needs them.
| Field | Value |
|---|---|
id |
Record path such as build/test. Omit it for the root record |
app |
Stable program identifier such as cargo. Up to 32 [A-Za-z0-9_.+-] bytes |
title |
Short label for the record. Up to 192 UTF-8 bytes |
message |
One line that says what the record is doing or waiting for. Up to 2048 bytes |
An id has up to 8 segments separated by /. Each segment has 1 to 32 [A-Za-z0-9_.+-] bytes, and the whole id has
at most 128 bytes. A program that runs several tasks can report one record for each task:
renderer.setProgramStatus({ state: "working", app: "deploy" })
renderer.setProgramStatus({ state: "working", id: "us-east", title: "US East", progress: 40 })
renderer.setProgramStatus({ state: "blocked", kind: "permission", id: "eu-west", title: "EU West" })
renderer.setProgramStatus({ state: "clear", id: "us-east" })title and message cannot contain control characters, including newline and tab. OpenTUI encodes them as base64.
Results
setProgramStatus() returns true when OpenTUI queues the report. It returns false in these cases:
- The renderer is destroyed.
- The terminal is not active, for example before
setupTerminal()finishes or while the renderer is suspended. - The output queue is full.
A report that breaks a rule above throws. OpenTUI checks the whole report before it writes anything.
Detection
Terminals ignore OSC sequences they do not implement, so setProgramStatus() does not wait for detection. OpenTUI
sends the OSC 7501 ; ? query at startup, except when TERM names foot, whose reduced probe omits OSC capability queries.renderer.capabilities?.program_status becomes true when the terminal replies. A false value can also mean that
the reply has not arrived yet.
renderer.on("capabilities", (capabilities) => {
console.log("program status", capabilities.program_status)
})Lifetime
Records belong to the terminal, not to the renderer. When the process exits or the shell shows a new prompt, the
terminal drops working and blocked records. done and error records stay until the terminal decides to remove
them. OpenTUI does not clear records when the renderer is destroyed or suspended.
Multiplexers
OpenTUI does not wrap reports in tmux or GNU Screen passthrough. A passthrough report would reach the outer terminal,
which cannot tell panes apart or see the program exit. A multiplexer that implements OSC 7501 receives reports
directly.
Next
- Notifications covers one-time desktop notifications.
- Terminal capabilities explains query and detection timing.
- Renderer documents the renderer and its capability event.
Packages Web Src Content Docs Bindings React (`packages/web/src/content/docs/bindings/react.mdx`)
title: React bindings skill: entry: true intents: [react, jsx, hooks, keyboard, paste, focus, blur, selection, animation, testing]
React bindings
Use React components and hooks to build an OpenTUI application.
Requirements
@opentui/react requires React >=19.2.0. See Runtime and platform support
for the current runtime, operating system, architecture, and native renderer matrix.
Installation
Create a project with Bun and create-tui:
bun create tui --template reactOr add the packages to an existing project:
bun install @opentui/react @opentui/core reactQuick start
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return <text>Hello, world!</text>
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)TypeScript configuration
Configure your tsconfig.json:
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true
}
}Runtime-loaded modules
React hosts that load TSX modules at runtime use this framework-specific import:
import "@opentui/react/runtime-plugin-support"See Load plugins and modules at runtime for Bun setup, import order, module maps, and
trust boundaries.
Components
React JSX intrinsic elements map to Core renderables and use kebab-case names such as <ascii-font> and<tab-select>. See the Components overview for availability and
registration requirements. React creates one Core renderable for each element and updates the renderable tree on each
commit.
API reference
`createRoot(renderer)`
createRoot(renderer) adopts an existing CliRenderer. It returns a React root with render(node) and unmount().
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return <text>Hello, React!</text>
}
const renderer = await createCliRenderer()
const root = createRoot(renderer)
root.render(<App />)A later root.render(node) updates the mounted tree in place and keeps component state, as in react-dom. It also
retries a tree that failed with an uncaught error.
For plugin slots, see Plugin slots and React plugin slots.
Lifecycle and cleanup
The React root does not own the renderer. root.unmount() removes the React tree while the renderer stays active.
Called outside React work, it runs React effect cleanup before it returns; called from an effect, it runs the cleanup
right after the current commit. renderer.destroy() releases the terminal and renderer resources. It also unmounts the
React root.
The code that creates the renderer owns renderer.destroy(). Call it on every application shutdown path. See
Lifecycle and cleanup for signal and failure handling.
Hooks
The event hooks subscribe after mount and remove their listeners during React effect cleanup.
`useRenderer()`
Access the OpenTUI renderer instance.
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
renderer.console.show()
console.log("Hello from console!")
}, [])
return <box />
}`useKeyboard(handler, options?)`
Handle keyboard events.
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to close</text>
}To handle release events:
useKeyboard(
(event) => {
if (event.eventType === "release") {
console.log("Key released:", event.name)
} else {
console.log("Key pressed:", event.name)
}
},
{ release: true },
)`useOnResize(callback)`
Handle terminal resize events.
import { useOnResize } from "@opentui/react"
function App() {
useOnResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize-aware component</text>
}`useTerminalDimensions()`
Get reactive terminal dimensions.
import { useTerminalDimensions } from "@opentui/react"
function App() {
const { width, height } = useTerminalDimensions()
return (
<text>
Terminal: {width}x{height}
</text>
)
}`usePaste(handler)`
Handle terminal paste events (bracketed paste).
import { decodePasteBytes } from "@opentui/core"
import { usePaste } from "@opentui/react"
function App() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted text:", text)
})
return <text>Paste something into the terminal</text>
}`useFocus(handler)`
Subscribe to terminal window focus events.
import { useFocus } from "@opentui/react"
function App() {
useFocus(() => {
console.log("Terminal gained focus")
})
return <text>Focus-aware component</text>
}`useBlur(handler)`
Subscribe to terminal window blur events.
import { useBlur } from "@opentui/react"
function App() {
useBlur(() => {
console.log("Terminal lost focus")
})
return <text>Blur-aware component</text>
}`useSelectionHandler(handler)`
Handle text selection events such as mouse drag selection.
import { useSelectionHandler } from "@opentui/react"
function App() {
useSelectionHandler((selection) => {
const text = selection.getSelectedText()
console.log("Selected:", text)
})
return <text selectable>Select this text with your mouse</text>
}`useTimeline(options?)`
useTimeline() creates one Timeline for the mounted hook. It registers the timeline after mount and starts it unlessautoplay is false. React pauses and unregisters the same instance during effect cleanup.
import { useTimeline } from "@opentui/react"
function App() {
const timeline = useTimeline({ autoplay: false })
return <text>{timeline.isPlaying ? "Playing" : "Paused"}</text>
}The hook reads its options when it creates the instance. Read Animation and Timeline
for scheduling, callbacks, engine ownership, and cleanup.
Styling
Style components with props or the style prop:
// Direct props
<box backgroundColor="blue" padding={2}>
<text>Hello</text>
</box>
// Style prop
<box style={{ backgroundColor: "blue", padding: 2 }}>
<text>Hello</text>
</box>Testing
@opentui/react/test-utils exports testRender(node, options). It is a React-aware wrapper aroundcreateTestRenderer(). It mounts the initial node with React act() and returns the CoreTestRendererSetup. Renderer destruction unmounts the React root with act().
import { expect, test } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
function App() {
return <text>Ready</text>
}
test("renders the application", async () => {
const setup = await testRender(<App />, { width: 20, height: 4 })
try {
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Ready")
} finally {
setup.renderer.destroy()
}
})The options argument is required and uses TestRendererOptions. The helper enables React's act-environment flag for
the mounted test root. Renderer destruction unmounts the root, calls options.onDestroy when supplied, and then sets
the flag to false. The helper does not preserve a previous global value. If onDestroy throws, it prevents the final
flag reset.
testRender() does not return the React root or a separate rerender function. Use the returned Core setup for frame
capture, waits, resize, keyboard and mouse input, native stats, and external output. Always destroy the renderer in test
teardown. Destruction unmounts the React tree and resets the global act-environment flag.
Example: login form
OpenTUI `` has no password-masking mode. This demonstration displays the password value as normal terminal text.
Do not use this pattern for real secrets.
/* Detailed source-code truncated for AI context efficiency. */Component extension
Register custom renderables as JSX elements:
import { BoxRenderable, createCliRenderer, type BoxOptions, type RenderContext } from "@opentui/core"
import { createRoot, extend } from "@opentui/react"
class ConsoleButtonRenderable extends BoxRenderable {
private _label: string = "Button"
constructor(ctx: RenderContext, options: BoxOptions & { label?: string }) {
super(ctx, options)
if (options.label) this._label = options.label
this.borderStyle = "single"
this.padding = 2
}
get label(): string {
return this._label
}
set label(value: string) {
this._label = value
this.requestRender()
}
}
// Add TypeScript support
declare module "@opentui/react" {
interface OpenTUIComponents {
consoleButton: typeof ConsoleButtonRenderable
}
}
// Register the component
extend({ consoleButton: ConsoleButtonRenderable })
// Use in JSX
function App() {
return <consoleButton label="Click me!" style={{ border: true, backgroundColor: "green" }} />
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)React calls the constructor with { id, ...props }. After construction, React applies each prop to the instance again.
For an ordinary prop, React assigns the value to the instance property of the same name. React assigns each entry of
the style prop as a separate property. On each update, React applies the changed props and sets removed props tonull. Read Custom renderables to draw custom content and to
compare React and Solid construction.
React DevTools
Use React DevTools to inspect the React tree:
- Install:
bun add --dev react-devtools-core@7- Start DevTools:
npx react-devtools@7- Run with the
DEVflag:
DEV=true bun run your-app.tsNext
- Components overview lists React component availability and registration requirements.
- Interaction, focus, and selection owns shared pointer and focus behavior.
- Animation and Timeline documents scheduling and engine ownership.
- React keymap integration connects application commands to React state.
- Package entry points lists the public React imports.
- Runtime and platform support gives the tested runtime matrix.
- Deploy an OpenTUI application helps you choose a distribution format.
- Standalone executables gives the Bun and Node.js executable procedures.
- Troubleshooting routes runtime, native loading, and terminal failures.
- Solid bindings describes the Solid state and ownership model.
Packages Web Src Content Docs Bindings Solid (`packages/web/src/content/docs/bindings/solid.mdx`)
title: Solid bindings skill: entry: true intents: [solid, jsx, signals, hooks, keyboard, animation, testing]
Solid bindings
Use Solid components and reactive primitives to build an OpenTUI application.
Requirements
@opentui/solid requires solid-js 1.9.12 exactly. See
Runtime and platform support for the current runtime, operating system,
architecture, and native renderer matrix.
Installation
bun install solid-js @opentui/solidSetup
1. Configure TypeScript
Add JSX config to tsconfig.json:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}2. Configure Bun
Add preload script to bunfig.toml:
preload = ["@opentui/solid/preload"]3. Configure Node compilation
The Node path requires compiled Solid TSX. Use Solid's universal transform semantics. Point the generated runtime
imports at the published solid-js JavaScript files:
import ts from "@babel/preset-typescript"
import moduleResolver from "babel-plugin-module-resolver"
import solid from "babel-preset-solid"
export default {
plugins: [
[
moduleResolver,
{
resolvePath(specifier) {
if (specifier === "solid-js") return "solid-js/dist/solid.js"
if (specifier === "solid-js/store") return "solid-js/store/dist/store.js"
return specifier
},
},
],
],
presets: [[solid, { moduleName: "@opentui/solid", generate: "universal" }], [ts]],
}Use Node.js 26.4.0 or later with --experimental-ffi. If you enable the Node permission model, also pass --permission,--allow-ffi, and the filesystem permissions that the application needs. The Solid Node source lane in CI compiles
TSX and runs on Linux x64.
4. Create the app
import { render } from "@opentui/solid"
const App = () => <text>Hello, World!</text>
await render(App)Run the app with bun index.tsx.
Runtime-loaded modules
Solid hosts that load TSX modules at runtime use this framework-specific import:
import "@opentui/solid/runtime-plugin-support"See Load plugins and modules at runtime for Bun setup, import order, module maps, and
trust boundaries.
Components
Solid JSX intrinsic elements map to Core renderables. Multi-word intrinsic names use snake_case, such as<ascii_font> and <tab_select>. See the Components overview for availability and
registration requirements.
API reference
`render(node, rendererOrConfig?)`
Render a Solid component tree into a CLI renderer. Pass an existing CliRenderer or a CliRendererConfig.
import { render } from "@opentui/solid"
const App = () => <text>Hello, Solid!</text>
// Simple usage
await render(() => <App />)
// With renderer config
await render(() => <App />, {
targetFps: 30,
exitOnCtrlC: false,
})Parameters:
node: Function that returns a JSX element.rendererOrConfig: OptionalCliRendererinstance orCliRendererConfig.
`testRender(node, options?)`
Create a test renderer for frame and interaction tests.
import { testRender } from "@opentui/solid"
const App = () => <text>Ready</text>
const testSetup = await testRender(() => <App />, { width: 40, height: 10 })`extend(components)`
Register custom renderables as JSX intrinsic elements.
import { BoxRenderable } from "@opentui/core"
import { extend } from "@opentui/solid"
declare module "@opentui/solid" {
interface OpenTUIComponents {
custom_box: typeof BoxRenderable
}
}
extend({ custom_box: BoxRenderable })The declare module block adds the element to the JSX types.
Solid calls the constructor with { id } only. After construction, Solid applies each JSX prop to the instance.
A class that reads a value only from its constructor options does not get that value from JSX.
Read Custom renderables to draw custom content and to adapt such classes.
`getComponentCatalogue()`
Returns the current component catalogue that powers JSX tag lookup.
For plugin slots, see Plugin slots and Solid plugin slots.
Lifecycle and cleanup
render() returns a promise that resolves after the initial mount. It does not return a disposer. When you pass a
renderer, Solid adopts it but does not take application ownership. The code that created that renderer must destroy it.
Renderer destruction disposes the Solid root. Disposal runs onCleanup callbacks and removes subscriptions created by
the Solid hooks. If render() creates the renderer from a config object, a component can get it with useRenderer()
and call renderer.destroy() during application shutdown.
See Lifecycle and cleanup for renderer shutdown and failure handling.
Scrollback writers
In split-footer mode with externalOutputMode: "capture-stdout", the Solid binding includes helpers that append JSX-rendered output above the footer. They wrap renderer.writeToScrollback so you can write scrollback content with signals and components.
`writeSolidToScrollback(renderer, node, options?)`
Render a JSX node once and append it as a scrollback commit.
import { writeSolidToScrollback } from "@opentui/solid"
writeSolidToScrollback(renderer, () => <text fg="#8BD5CA">api responded in 12ms</text>)`createScrollbackWriter(node, options?)`
If you need to pass the same JSX rendering to multiple writeToScrollback calls (or hold onto the writer inside your own code), use the lower-level factory:
import { createScrollbackWriter } from "@opentui/solid"
const writer = createScrollbackWriter(() => <text>logged at {new Date().toISOString()}</text>, { startOnNewLine: true })
renderer.writeToScrollback(writer)Options
| Option | Type | Default | Description |
|---|---|---|---|
width |
number |
renderer width | Override the snapshot width in columns |
height |
number |
measured auto | Override the snapshot height in rows (otherwise measured from layout) |
rowColumns |
number |
snapshot width | Explicit last-row column count for tail tracking |
startOnNewLine |
boolean |
true |
Insert a newline before this commit if the previous commit ended mid-row |
trailingNewline |
boolean |
true |
Append a newline after the final row |
The writer returns a ScrollbackSnapshot. Its teardown disposes the inner Solid subtree after the snapshot renders.
Use the Core ScrollbackSurface APIs for streaming commits that
render the same tree more than once.
Hooks
The event hooks subscribe after mount and remove their listeners during Solid owner cleanup.
`useRenderer()`
Access the OpenTUI renderer instance.
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
const App = () => {
const renderer = useRenderer()
onMount(() => {
renderer.console.show()
console.log("Hello from console!")
})
return <box />
}`useKeyboard(handler, options?)`
Subscribe to keyboard events.
import { useKeyboard, useRenderer } from "@opentui/solid"
const App = () => {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to close</text>
}With release events:
import { createSignal } from "solid-js"
const App = () => {
const [pressedKeys, setPressedKeys] = createSignal(new Set<string>())
useKeyboard(
(event) => {
setPressedKeys((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(pressedKeys()).join(", ") || "none"}</text>
}`onResize(callback)`
Handle terminal resize events.
import { onResize } from "@opentui/solid"
const App = () => {
onResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize-aware component</text>
}`onFocus(callback)`
Run side effects when the terminal window gains focus.
import { onFocus } from "@opentui/solid"
const App = () => {
onFocus(() => {
console.log("Terminal focused")
})
return <text>Switch away and back to trigger focus events</text>
}`onBlur(callback)`
Run side effects when the terminal window loses focus.
import { onBlur } from "@opentui/solid"
const App = () => {
onBlur(() => {
console.log("Terminal blurred")
})
return <text>Switch away and back to trigger blur events</text>
}These hooks listen for terminal focus-in/focus-out events when the terminal emulator supports them.
`useTerminalDimensions()`
Get reactive terminal dimensions (returns a Solid signal).
import { useTerminalDimensions } from "@opentui/solid"
const App = () => {
const dimensions = useTerminalDimensions()
return (
<text>
Terminal: {dimensions().width}x{dimensions().height}
</text>
)
}`usePaste(handler)`
Subscribe to paste events.
import { usePaste } from "@opentui/solid"
const textDecoder = new TextDecoder()
const App = () => {
usePaste((event) => {
console.log("Pasted:", textDecoder.decode(event.bytes))
})
return <text>Paste something!</text>
}`useSelectionHandler(callback)`
Handle text selection events.
import { useSelectionHandler } from "@opentui/solid"
const App = () => {
useSelectionHandler((selection) => {
console.log("Selected:", selection)
})
return <text selectable>Select me!</text>
}`useTimeline(options?)`
useTimeline() creates and returns one Timeline during component setup. It registers the timeline on mount and starts
it unless autoplay is false. Solid pauses and unregisters it during owner cleanup.
import { useTimeline } from "@opentui/solid"
const App = () => {
const timeline = useTimeline({ autoplay: false })
return <text>{timeline.isPlaying ? "Playing" : "Paused"}</text>
}The hook reads its options during component setup. Read
Animation and Timeline for scheduling, callbacks, engine ownership, and cleanup.
Special components
`Portal`
Render children into a different mount point. Use this for content such as modals and overlays.
import { Portal, useRenderer } from "@opentui/solid"
const App = () => {
const renderer = useRenderer()
return (
<box>
<text>Main content</text>
<Portal mount={renderer.root}>
<box border>Overlay</box>
</Portal>
</box>
)
}`Dynamic`
Render arbitrary intrinsic elements or components dynamically.
import { Dynamic } from "@opentui/solid"
import { createSignal } from "solid-js"
const App = () => {
const [isMultiline, setIsMultiline] = createSignal(false)
return <Dynamic component={isMultiline() ? "textarea" : "input"} />
}Testing
testRender(node, options?) mounts a Solid root on a Core test renderer. It returns the Core TestRendererSetup.
Destroying the test renderer disposes the Solid root and runs onCleanup callbacks.
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
const App = () => <text>Ready</text>
test("renders the application", async () => {
const setup = await testRender(() => <App />, { width: 20, height: 4 })
try {
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Ready")
} finally {
setup.renderer.destroy()
}
})Use the returned setup for frame capture, waits, resize, keyboard and mouse input, native stats, and external output.
Always destroy the renderer during test cleanup.
Example: counter
import { render, useKeyboard, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js"
const App = () => {
const [count, setCount] = createSignal(0)
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "up") setCount((c) => c + 1)
if (key.name === "down") setCount((c) => c - 1)
if (key.name === "escape") renderer.destroy()
})
return (
<box border padding={2}>
<text>Count: {count()}</text>
<text fg="#888">Up/Down to change, ESC to close</text>
</box>
)
}
await render(App)React and Solid differences
| Aspect | React | Solid |
|---|---|---|
| Render function | createRoot(renderer).render(<App />) |
render(() => <App />) |
| Component naming | kebab-case (ascii-font) |
snake_case (ascii_font) |
| State | useState |
createSignal |
| Effects | useEffect |
onMount, onCleanup |
| Resize hook | useOnResize(callback) |
onResize(callback) |
| Dimensions | Returns object: dimensions.width |
Returns signal: dimensions().width |
| Focus, blur, paste, and selection | useFocus, useBlur, usePaste, useSelectionHandler |
onFocus, onBlur, usePaste, useSelectionHandler |
| Special components | None | Portal, Dynamic |
Next
- React bindings describes the React state and ownership model.
- Components overview lists Solid component availability and registration requirements.
- Interaction, focus, and selection owns shared pointer and focus behavior.
- Animation and Timeline documents scheduling and engine ownership.
- Solid keymap integration connects application commands to Solid state.
- Package entry points lists the public Solid imports.
- Runtime and platform support gives the tested runtime matrix.
- Deploy an OpenTUI application helps you choose a distribution format.
- Standalone executables gives the Bun and Node.js executable procedures.
- Troubleshooting routes runtime, native loading, and terminal failures.
Packages Web Src Content Docs Components Ascii Font (`packages/web/src/content/docs/components/ascii-font.mdx`)
title: ASCIIFont
ASCIIFont
ASCIIFont draws short text with bundled ASCII art fonts. Use Text for normal labels and prose.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | ASCIIFontRenderable |
| React | <ascii-font> (automatic) |
| Solid | <ascii_font> (automatic) |
| Status | Built in |
Basic usage
Renderable API
import { ASCIIFontRenderable, RGBA, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "OPENTUI",
font: "tiny",
color: RGBA.fromInts(255, 255, 255, 255),
})
renderer.root.add(title)Available fonts
OpenTUI includes several ASCII art font styles:
// Small, compact font
{
font: "tiny"
}
// Block style font
{
font: "block"
}
// Shaded style font
{
font: "shade"
}
// Slick style font
{
font: "slick"
}
// Large font
{
font: "huge"
}
// Grid style font
{
font: "grid"
}
// Pallet style font
{
font: "pallet"
}The tiny font draws OPEN in two rows:
█▀█ █▀█ █▀▀ █▄ █
█▄█ █▀▀ ██▄ █ ▀█The block font draws the same text in six rows:
██████╗ ██████╗ ███████╗ ███╗ ██╗
██╔═══██╗ ██╔══██╗ ██╔════╝ ████╗ ██║
██║ ██║ ██████╔╝ █████╗ ██╔██╗ ██║
██║ ██║ ██╔═══╝ ██╔══╝ ██║╚██╗██║
╚██████╔╝ ██║ ███████╗ ██║ ╚████║
╚═════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═══╝Positioning
ASCIIFont inherits the standard layout positioning options. To position it at coordinates relative to its parent, use absolute positioning with left and top:
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "TITLE",
font: "block",
color: RGBA.fromHex("#FFFF00"),
position: "absolute",
left: 10,
top: 2,
})Properties
| Property | Type | Default | Description |
|---|---|---|---|
text |
string |
"" |
Text to display |
font |
ASCIIFontName |
"tiny" |
Font style |
color |
ColorInput | ColorInput[] |
"#FFFFFF" |
Text color or color bands |
backgroundColor |
ColorInput |
"transparent" |
Background color |
selectable |
boolean |
true |
Whether text is selectable |
selectionBg |
ColorInput |
- | Selection background color |
selectionFg |
ColorInput |
- | Selection foreground color |
position |
"relative" | "absolute" |
"relative" |
Effective positioning mode |
top |
number, "auto", or percentage string |
- | Top position offset |
right |
number, "auto", or percentage string |
- | Right position offset |
bottom |
number, "auto", or percentage string |
- | Bottom position offset |
left |
number, "auto", or percentage string |
- | Left position offset |
Example: welcome screen
import { ASCIIFontRenderable, BoxRenderable, TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const welcomeScreen = new BoxRenderable(renderer, {
width: "100%",
height: "100%",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
})
welcomeScreen.add(
new ASCIIFontRenderable(renderer, {
text: "OPENTUI",
font: "huge",
color: "#00FFFF",
}),
)
welcomeScreen.add(
new TextRenderable(renderer, {
content: "Terminal UI Framework",
fg: "#888888",
}),
)
welcomeScreen.add(
new TextRenderable(renderer, {
content: "Press any key to continue...",
fg: "#444444",
}),
)
renderer.root.add(welcomeScreen)Dynamic text
Update the text content dynamically:
const counter = new ASCIIFontRenderable(renderer, {
id: "counter",
text: "0",
font: "block",
color: RGBA.fromHex("#FF0000"),
})
let count = 0
setInterval(() => {
count++
counter.text = count.toString()
}, 1000)Color effects
Create a shadow by overlaying two ASCII fonts:
import { ASCIIFontRenderable, BoxRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const shadowTitle = new BoxRenderable(renderer, { position: "relative" })
shadowTitle.add(
new ASCIIFontRenderable(renderer, {
text: "HELLO",
font: "block",
color: "#880000",
position: "absolute",
left: 1,
top: 1,
zIndex: 0,
}),
)
shadowTitle.add(
new ASCIIFontRenderable(renderer, {
text: "HELLO",
font: "block",
color: "#FF0000",
zIndex: 1,
}),
)
renderer.root.add(shadowTitle)Related concepts
See Layout for positioning and Colors for accepted color values. Use FrameBuffer for graphics that need direct cell drawing.
Packages Web Src Content Docs Components Box (`packages/web/src/content/docs/components/box.mdx`)
title: Box
Box
Box lays out child renderables and can draw a background, border, and title. Use ScrollBox when the children must scroll.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | BoxRenderable |
| React | `` (automatic) |
| Solid | `` (automatic) |
| Status | Built in |
Basic usage
Renderable API
import { BoxRenderable, TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const panel = new BoxRenderable(renderer, {
id: "panel",
width: 30,
height: 10,
backgroundColor: "#333366",
borderStyle: "double",
borderColor: "#FFFFFF",
})
renderer.root.add(panel)Border styles
// No border
{
border: false
}
// Simple border (default style)
{
border: true
}
// Specific border styles
{
borderStyle: "single"
} // Single line: ┌─┐│└─┘
{
borderStyle: "double"
} // Double line: ╔═╗║╚═╝
{
borderStyle: "rounded"
} // Rounded corners: ╭─╮│╰─╯
{
borderStyle: "heavy"
} // Heavy lines: ┏━┓┃┗━┛┌─────single─────┐ ╔═════double═════╗
│ single line │ ║ double lines ║
└────────────────┘ ╚════════════════╝
╭────rounded─────╮ ┏━━━━━heavy━━━━━━┓
│ round corners │ ┃ heavy strokes ┃
╰────────────────╯ ┗━━━━━━━━━━━━━━━━┛Titles
Add a title and bottom title to the box border:
const panel = new BoxRenderable(renderer, {
id: "settings",
width: 40,
height: 15,
borderStyle: "rounded",
title: "Settings",
titleColor: "yellow",
titleAlignment: "center",
bottomTitle: "Footer",
bottomTitleAlignment: "center",
})Title alignment (both top and bottom titles)
{
titleAlignment: "left"
} // ┌─ Title ────────┐
{
titleAlignment: "center"
} // ┌──── Title ─────┐
{
titleAlignment: "right"
} // ┌────────── Title ┐
{
bottomTitleAlignment: "left"
} // └─ Title ────────┘
{
bottomTitleAlignment: "center"
} // └──── Title ─────┘
{
bottomTitleAlignment: "right"
} // └────────── Title ┘┌───────────settings───────────┐
│ Top and bottom titles │
└────────────────────────close─┘Layout container
Box works as a flex container for child elements:
const container = new BoxRenderable(renderer, {
flexDirection: "column",
justifyContent: "space-between",
alignItems: "stretch",
width: 50,
height: 20,
padding: 1,
gap: 1,
})
const content = new BoxRenderable(renderer, { flexGrow: 1, backgroundColor: "#222" })
content.add(new TextRenderable(renderer, { content: "Content area" }))
container.add(new TextRenderable(renderer, { content: "Header" }))
container.add(content)
container.add(new TextRenderable(renderer, { content: "Footer" }))Mouse events
Handle mouse interactions on the box:
const button = new BoxRenderable(renderer, {
id: "button",
width: 12,
height: 3,
border: true,
backgroundColor: "#444",
onMouseDown: () => {
console.log("Button clicked!")
},
onMouseOver: () => {
button.backgroundColor = "#666"
},
onMouseOut: () => {
button.backgroundColor = "#444"
},
})Properties
| Property | Type | Default | Description |
|---|---|---|---|
width |
number | string |
- | Width in terminal columns or percentage |
height |
number | string |
- | Height in rows or percentage |
backgroundColor |
string | RGBA |
transparent |
Background fill color |
border |
boolean |
false |
Show border |
borderStyle |
string |
"single" |
Border style |
borderColor |
string | RGBA |
#FFFFFF |
Border color |
title |
string |
- | Title text in border |
titleColor |
string | RGBA |
borderColor |
Color of the title text |
titleAlignment |
string |
"left" |
Title position |
bottomTitle |
string |
- | Bottom title text in border |
bottomTitleAlignment |
string |
"left" |
Bottom title position |
padding |
number |
0 |
Internal padding |
gap |
number | string |
- | Gap between children |
flexDirection |
string |
"column" |
Child layout direction |
justifyContent |
string |
"flex-start" |
Main axis alignment |
alignItems |
string |
"stretch" |
Cross axis alignment |
Example: card component
import { BoxRenderable, TextRenderable, t, bold, fg } from "@opentui/core"
function Card(props: { title: string; description: string }) {
const card = new BoxRenderable(renderer, {
width: 40,
borderStyle: "rounded",
borderColor: "#666",
padding: 1,
margin: 1,
})
card.add(
new TextRenderable(renderer, {
content: t`${bold(fg("#00FFFF")(props.title))}`,
}),
)
card.add(
new TextRenderable(renderer, {
content: props.description,
fg: "#AAAAAA",
}),
)
return card
}
const cards = new BoxRenderable(renderer, { flexDirection: "row", flexWrap: "wrap" })
cards.add(Card({ title: "Feature 1", description: "Description of feature 1" }))
cards.add(Card({ title: "Feature 2", description: "Description of feature 2" }))
cards.add(Card({ title: "Feature 3", description: "Description of feature 3" }))
renderer.root.add(cards)Related concepts
Read Layout for sizing and flex behavior. See Colors for color formats and Interaction, focus, and selection for mouse event propagation. Use Text for labels and other text content.
Packages Web Src Content Docs Components Code (`packages/web/src/content/docs/components/code.mdx`)
title: Code
Code
Code displays source text with Tree-sitter syntax highlighting. Use Markdown for documents or Diff for changed lines.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | CodeRenderable |
| React | <code> (automatic) |
| Solid | <code> (automatic) |
| Status | Built in |
Basic usage
Renderable API
import { CodeRenderable, createCliRenderer, SyntaxStyle, RGBA } from "@opentui/core"
const renderer = await createCliRenderer()
const syntaxStyle = SyntaxStyle.fromStyles(
{
default: { fg: RGBA.defaultForeground() },
keyword: { fg: RGBA.defaultForeground(), bold: true },
string: { fg: RGBA.fromIndex(247) },
comment: { fg: RGBA.fromIndex(244), italic: true },
},
renderer.nativeScene,
)
const code = new CodeRenderable(renderer, {
id: "code",
content: `function hello() {
// This is a comment
const message = "Hello, world!"
return message
}`,
filetype: "javascript",
syntaxStyle,
width: 50,
height: 10,
})
renderer.root.add(code)function hello() {
// This is a comment
const message = "Hello, world!"
return message
}Creating syntax styles
SyntaxStyle.fromStyles(definitions, owner) maps syntax token names to colors and attributes.
It creates a native style, so it needs an owner. Pass renderer.nativeScene.
Without a renderer, pass a ResourceContext.
fromStyles() and registerStyle() copy each definition, including its colors.getStyle(), getAllStyles(), and mergeStyles() return copies.
Changes to an input object or a returned value do not change the registered style.
To change a style, call registerStyle() again.
import { SyntaxStyle, RGBA, parseColor } from "@opentui/core"
const syntaxStyle = SyntaxStyle.fromStyles(
{
// Basic tokens
keyword: { fg: RGBA.fromHex("#FF7B72"), bold: true },
"keyword.import": { fg: RGBA.fromHex("#FF7B72"), bold: true },
"keyword.operator": { fg: RGBA.fromHex("#FF7B72") },
string: { fg: RGBA.fromHex("#A5D6FF") },
comment: { fg: RGBA.fromHex("#8B949E"), italic: true },
number: { fg: RGBA.fromHex("#79C0FF") },
boolean: { fg: RGBA.fromHex("#79C0FF") },
constant: { fg: RGBA.fromHex("#79C0FF") },
// Functions and types
function: { fg: RGBA.fromHex("#D2A8FF") },
"function.call": { fg: RGBA.fromHex("#D2A8FF") },
"function.method.call": { fg: RGBA.fromHex("#D2A8FF") },
type: { fg: RGBA.fromHex("#FFA657") },
constructor: { fg: RGBA.fromHex("#FFA657") },
// Variables and properties
variable: { fg: RGBA.fromHex("#E6EDF3") },
"variable.member": { fg: RGBA.fromHex("#79C0FF") },
property: { fg: RGBA.fromHex("#79C0FF") },
// Operators and punctuation
operator: { fg: RGBA.fromHex("#FF7B72") },
punctuation: { fg: RGBA.fromHex("#F0F6FC") },
"punctuation.bracket": { fg: RGBA.fromHex("#F0F6FC") },
"punctuation.delimiter": { fg: RGBA.fromHex("#C9D1D9") },
// Default fallback
default: { fg: RGBA.fromHex("#E6EDF3") },
},
renderer.nativeScene,
)A style belongs to one owner. To use the same definitions in another renderer, create a new style for that renderer.
Destroy the new style when that renderer no longer needs it:
const definitions = syntaxStyle.getAllStyles()
const boundStyle = SyntaxStyle.fromStyles(definitions, destination.nativeScene)
// Use boundStyle in the destination's Code or Markdown nodes.
// After you destroy those nodes:
boundStyle.destroy()CodeRenderable does not destroy the syntaxStyle you pass. You destroy it.
Text buffers and edit buffers accept only a style with the same owner.
A style from another owner throws an owner-mismatch error. Core does not copy it.
getAllStyles() returns only registered definitions. It does not return the styles that native code creates for text chunks.
Pass its Map directly to fromStyles() to keep the registration order, including numeric names.
An object lists numeric names first, which can change style IDs.
Style properties
Each style definition can include:
| Property | Type | Description |
|---|---|---|
fg |
RGBA |
Foreground (text) color |
bg |
RGBA |
Background color |
bold |
boolean |
Bold text |
italic |
boolean |
Italic text |
underline |
boolean |
Underlined text |
dim |
boolean |
Dimmed text |
Supported languages
OpenTUI bundles Tree-sitter parsers for these languages:
- JavaScript and JSX
- TypeScript and TSX
- Markdown and Markdown inline
- Zig
Other grammars require configuration. See the Tree-sitter reference.
Streaming mode
Enable streaming mode when content arrives incrementally, like LLM output:
const code = new CodeRenderable(renderer, {
id: "streaming-code",
content: "",
filetype: "typescript",
syntaxStyle,
streaming: true, // Enable streaming mode
})
// Later, append content
code.content += "const x = 1\n"
code.content += "const y = 2\n"With drawUnstyledText: false, streaming mode changes what Code shows while a highlight runs:
- Without streaming,
Codeshows nothing until each highlight completes. - With streaming,
Codeshows nothing until the first highlight completes. Later updates keep the previous highlighted text visible until the new highlight completes.
With drawUnstyledText: true, a content change shows the new text unstyled while its highlight runs, with or without streaming.
In streaming mode, after the first highlight, other changes that start a highlight, such as filetype or conceal, keep the previous highlighted text visible.
Each highlight processes the complete current content.
Text selection
Text selection is on by default. Set selectable: false to turn it off. Use selectionBg and selectionFg to set selection colors:
const code = new CodeRenderable(renderer, {
id: "code",
content: sourceCode,
filetype: "typescript",
syntaxStyle,
selectable: true,
selectionBg: "#264F78",
selectionFg: "#FFFFFF",
})Concealment
Set conceal to hide concealed syntax elements, such as Markdown formatting characters:
const code = new CodeRenderable(renderer, {
id: "markdown",
content: "# Heading\n**bold** text",
filetype: "markdown",
syntaxStyle,
conceal: true, // Hide formatting characters
})With line numbers
Use LineNumberRenderable to add line numbers:
import { CodeRenderable, LineNumberRenderable, RGBA, ScrollBoxRenderable, SyntaxStyle } from "@opentui/core"
const sourceCode = "const answer = 42\nconsole.log(answer)\n"
const syntaxStyle = SyntaxStyle.fromStyles(
{
default: { fg: RGBA.fromHex("#E6EDF3") },
},
renderer.nativeScene,
)
const code = new CodeRenderable(renderer, {
id: "code",
content: sourceCode,
filetype: "typescript",
syntaxStyle,
width: "100%",
})
const lineNumbers = new LineNumberRenderable(renderer, {
id: "code-with-lines",
target: code,
minWidth: 3,
paddingRight: 1,
fg: "#6b7280",
bg: "#161b22",
width: "100%",
})
// Wrap in ScrollBox for scrolling
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "scrollbox",
width: 60,
height: 20,
})
scrollbox.add(lineNumbers)
renderer.root.add(scrollbox)Properties
| Property | Type | Default | Description |
|---|---|---|---|
content |
string |
"" |
Source code to display |
filetype |
string |
- | Language for syntax highlighting |
syntaxStyle |
SyntaxStyle |
required | Syntax style for tokens |
streaming |
boolean |
false |
Change what shows while a highlight runs (see Streaming mode) |
conceal |
boolean |
true |
Hide concealed syntax elements |
drawUnstyledText |
boolean |
true |
Show unstyled text while a highlight runs (see Streaming mode) |
treeSitterClient |
TreeSitterClient |
- | Custom Tree-sitter client instance |
When the renderable has a filetype, a change to content, filetype, syntaxStyle, conceal, drawUnstyledText, streaming, or treeSitterClient starts a new highlight.Code starts each highlight in its update hook (onUpdate) during the next frame.Code does not start a highlight while it or an ancestor has visible: false.
Inherited from TextBufferRenderable
| Property | Type | Default | Description |
|---|---|---|---|
fg |
string | RGBA |
#FFFFFF |
Default foreground color |
bg |
string | RGBA |
transparent | Background color |
selectable |
boolean |
true |
Whether text selection is on |
selectionBg |
string | RGBA |
- | Selection background color |
selectionFg |
string | RGBA |
- | Selection foreground color |
wrapMode |
string |
"word" |
Text wrapping: "none", "char", "word" |
textAlign |
"left" | "center" | "right" |
"left" |
Per-line alignment within the text width |
tabIndicator |
string | number |
- | Tab display character or width |
Additional properties
| Property | Type | Description |
|---|---|---|
lineCount |
number |
Number of lines in the current text buffer |
scrollY |
number |
Current vertical scroll position (get/set) |
scrollX |
number |
Current horizontal scroll position (get/set) |
scrollWidth |
number |
Total scrollable width (read-only) |
scrollHeight |
number |
Total scrollable height (read-only) |
isHighlighting |
boolean |
Whether highlighting is in progress |
plainText |
string |
Plain text in the current text buffer. It can differ from content after concealment or onChunks transforms |
Markdown styles
For markdown highlighting, use markup-prefixed style names:
const markdownStyle = SyntaxStyle.fromStyles(
{
"markup.heading": { fg: RGBA.fromHex("#58A6FF"), bold: true },
"markup.heading.1": { fg: RGBA.fromHex("#00FF88"), bold: true, underline: true },
"markup.heading.2": { fg: RGBA.fromHex("#00D7FF"), bold: true },
"markup.bold": { fg: RGBA.fromHex("#F0F6FC"), bold: true },
"markup.strong": { fg: RGBA.fromHex("#F0F6FC"), bold: true },
"markup.italic": { fg: RGBA.fromHex("#F0F6FC"), italic: true },
"markup.list": { fg: RGBA.fromHex("#FF7B72") },
"markup.quote": { fg: RGBA.fromHex("#8B949E"), italic: true },
"markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
"markup.raw.block": { fg: RGBA.fromHex("#A5D6FF") },
"markup.link": { fg: RGBA.fromHex("#58A6FF"), underline: true },
"markup.link.url": { fg: RGBA.fromHex("#58A6FF"), underline: true },
default: { fg: RGBA.fromHex("#E6EDF3") },
},
renderer.nativeScene,
)Related components
Use Markdown for document structure and fenced code. Add a Line number gutter to a code renderable. Use Diff for unified or split patches. Use TextTable for tabular content without syntax parsing.
Read Text and terminal cells for wrapping, graphemes, and display-cell widths.
Packages Web Src Content Docs Components Diff (`packages/web/src/content/docs/components/diff.mdx`)
title: Diff
Diff
Diff renders one file patch in a unified or split view with syntax highlighting and optional line numbers. Use Code when you do not need patch parsing.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | DiffRenderable |
| React | <diff> (automatic) |
| Solid | <diff> is runtime built in. Its exact props declaration is missing. |
| Status | Built in with a Solid typing limitation |
Basic usage
Renderable API
import { DiffRenderable, SyntaxStyle, RGBA, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const syntaxStyle = SyntaxStyle.fromStyles(
{
default: { fg: RGBA.fromHex("#E6EDF3") },
string: { fg: RGBA.fromHex("#A5D6FF") },
keyword: { fg: RGBA.fromHex("#FF7B72"), bold: true },
},
renderer.nativeScene,
)
const patch = `diff --git a/app.ts b/app.ts
index 1111111..2222222 100644
--- a/app.ts
+++ b/app.ts
@@ -1,3 +1,3 @@
setup()
-const a = 1
+const a = 2
ready(a)
`
const diff = new DiffRenderable(renderer, {
id: "diff",
width: "100%",
height: 16,
diff: patch,
view: "split",
filetype: "typescript",
syntaxStyle,
showLineNumbers: true,
})
renderer.root.add(diff)Monochrome views of the same patch:
Unified view:
1 setup()
2 - const a = 1
2 + const a = 2
3 ready(a)Split view:
1 setup() 1 setup()
2 - const a = 1 2 + const a = 2
3 ready(a) 3 ready(a)For multi-file input, Diff shows only the first file patch (patches[0]). Create one DiffRenderable for each file patch that you need to show.
Split view scroll sync
In split view, set syncScroll: true to link the two panes. A mouse-wheel scroll in one pane then copies its scrollX and scrollY to the other pane:
const diff = new DiffRenderable(renderer, {
id: "diff",
view: "split",
syncScroll: true,
diff: patch,
syntaxStyle,
})
// Toggle at runtime
diff.syncScroll = falseScroll sync has no effect in the unified view.
If you omit syntaxStyle, Diff creates an empty fallback style and destroys it when you destroy the Diff.
Destroying a Diff also destroys its inactive panes.
Diff does not destroy a syntaxStyle you pass. Destroy it after no renderable uses it.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
diff |
string |
"" |
Unified diff string |
view |
"unified" or "split" |
"unified" |
Layout style |
syncScroll |
boolean |
false |
Link horizontal and vertical scroll in split view |
filetype |
string |
- | Syntax highlighting language |
fg |
string or RGBA |
- | Base foreground for inner code panes |
syntaxStyle |
SyntaxStyle |
- | Syntax style for code |
wrapMode |
"word", "char", or "none" |
- | Code wrapping mode |
conceal |
boolean |
false |
Conceal markup when highlighting |
selectionBg |
string or RGBA |
- | Selection background in code panes |
selectionFg |
string or RGBA |
- | Selection foreground in code panes |
treeSitterClient |
TreeSitterClient |
- | Custom Tree-sitter client |
showLineNumbers |
boolean |
true |
Show line numbers |
lineNumberFg |
string or RGBA |
#888888 |
Line number text color |
lineNumberBg |
string or RGBA |
transparent | Line number background |
addedLineNumberBg |
string or RGBA |
transparent | Line number background for added lines |
removedLineNumberBg |
string or RGBA |
transparent | Line number background for removed lines |
addedBg |
string or RGBA |
#1a4d1a |
Background for added lines |
removedBg |
string or RGBA |
#4d1a1a |
Background for removed lines |
contextBg |
string or RGBA |
transparent | Background for context lines |
addedContentBg |
string or RGBA |
- | Optional content background (added) |
removedContentBg |
string or RGBA |
- | Optional content background (removed) |
contextContentBg |
string or RGBA |
- | Optional content background (context) |
addedSignColor |
string or RGBA |
#22c55e |
Sign color for added lines |
removedSignColor |
string or RGBA |
#ef4444 |
Sign color for removed lines |
Other grammars require Tree-sitter configuration. See the Tree-sitter reference.
Related components
Use Code for source text without patch structure. Use Line number gutter with other line-aware renderables. Use Markdown for documents and TextTable for general tabular comparisons.
Packages Web Src Content Docs Components Embedded Terminal (`packages/web/src/content/docs/components/embedded-terminal.mdx`)
title: Embedded terminal skill: entry: true intents: [embedded-terminal, terminal-renderable, ghostty, vt, pty]
Embedded terminal
EmbeddedTerminalRenderable parses VT output and draws a terminal screen in the render tree. The parser is Ghostty's VT library, linked into the native artifact. You do not need an extra install.
The renderable is not a process and not a PTY. You write child output into it. You send encoded input back to the child.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | EmbeddedTerminalRenderable |
| React | Unavailable |
| Solid | Unavailable |
| Status | Built-in Core renderable |
The native artifact includes Ghostty VT on x86_64 and aarch64 for macOS, Linux glibc, Linux musl, and Windows GNU. On other targets, the constructor throws a NativeError with the message ot_embedded_terminal_create failed: UnsupportedResource.
I/O model
The renderable owns a VT parser, a screen, scrollback, and input encoders. It does not own the child.
╔══ child ═════════════════════════════╗
║ ║
║ stdout ║
║ ║
┌─ "input" ────▶║ stdin ║
├─ "response" ─▶║ ║
│ ╚══════════════════╤═══════════════════╝
│ │
│ │ write()
│ ▼
│ ╔══ EmbeddedTerminalRenderable ════════╗
│ ║ ║
│ ║ ┏━━ VT parser ━━━━━━━━━━━━━━━━━━━┓ ║
│ ║ ┗━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┛ ║
│ ║ ▼ ║
│ ║ ┏━━ screen ━━━━━━━━━━━━━━━━━━━━━━┓ ║
│ ║ ┃ cells, cursor, scrollback ┃ ║
│ ║ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ║
│ ║ ║
├───────────────╢ encode ║
└───────────────╢ drain ║
║ ║
╚══════════════════════════════════════╝Write both onData sources to the child's stdin. Use source only when you log or filter. A query such as DSR produces "response". A key or mouse event produces "input".
Draw VT output
import { EmbeddedTerminalRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const terminal = new EmbeddedTerminalRenderable(renderer, {
id: "session",
width: 80,
height: 24,
})
terminal.write("hello \x1b[1;32mworld\x1b[0m\r\n")
renderer.root.add(terminal)
terminal.focus()write() accepts a string or Uint8Array. The parser keeps incomplete escape sequences across calls. After write() or a resize, the renderable drains generated replies. Nonempty reply bytes go to onData with source "response".
Parsed child output can produce this screen:
$ bun test
✓ parser accepts UTF-8
✓ renderer draws wide cells
2 passed, 0 failedscreen() reads the private cell buffer from the last paint. Call it after a frame renders. It trims the end of each line and drops trailing empty rows. columns and rows are the renderable's current layout size. Cursor state also comes from the last compose, not from write() alone.
Attach a process
Give every child output chunk to write(). Give every onData payload to the child. Resize the child from onTerminalResize.
import { EmbeddedTerminalRenderable } from "@opentui/core"
function attachChild(
terminal: EmbeddedTerminalRenderable,
child: {
write(data: Uint8Array): void
resize(cols: number, rows: number): void
},
): void {
terminal.onData = (data) => {
child.write(data)
}
terminal.onTerminalResize = (cols, rows) => {
child.resize(cols, rows)
}
}If width or height is a percentage or flex size, the native grid starts at cols and rows. Those values default to 80 and 24 when width or height is not a number. Create the child at that starting grid.
A computed size change resizes the emulator only while the renderable is visible. That path also drains replies and calls onTerminalResize. A numeric width or height that already matches the first layout does not fire the callback.
Set TERM and COLORTERM on the child when you want a typical xterm-compatible session. The examples app includes an embedded shell demo that uses Bun.spawn with terminal.
Destroy the renderable and close the child together. destroy() releases the native emulator. A second call is safe.
Size and scrollback
| Option | Default | Meaning |
|---|---|---|
cols |
numeric width, otherwise 80 |
Initial emulator columns. Must be an integer from 1 through 65535. |
rows |
numeric height, otherwise 24 |
Initial emulator rows. Must be an integer from 1 through 65535. |
width |
cols |
Layout width. A later size change resizes the emulator. |
height |
rows |
Layout height. A later size change resizes the emulator. |
maxScrollback |
10000 |
Scrollback budget in bytes, not lines. 0 stores no history. The value must fit in a native u32. |
maxScrollback is constructor-only. A visible size change floors the computed width and height and caps the native grid at 65535. It then forces a full redraw on the next compose, drains replies, and calls onTerminalResize. The cap does not apply to layout size or screen() dimensions. The renderable ignores non-finite and non-positive sizes. Hidden size changes update layout only.
Focus and keyboard
The renderable is focusable. A left-button press inside it calls focus(). While the renderable has focus, the renderer routes keys and paste events to it.
handleKeyPress() encodes the key with the child's current keyboard mode. It sends nonempty bytes as "input" and returns true when it produces bytes. The renderable forwards Kitty releases only while it has focus. meta encodes as Alt, and only super encodes as Super.
handlePaste() encodes the paste with the current paste mode. Bracketed paste wraps the payload when the child enabled it. Encoding replaces certain control bytes with spaces. Without bracketed paste, newlines become carriage returns. encodePaste() accepts at most 1 MiB. A larger paste throws, and handlePaste() sends nothing to the child.
If the child enabled focus events, focus() and blur() send the matching sequences as "input". If onData throws during focus(), the renderable leaves focus. blur() still finishes cleanup if onData throws.
Renderer keyInput listeners run before the focused renderable. Use that path for a host shortcut such as Escape. Call stopPropagation() or preventDefault() when the host must keep the key.
Mouse and local scroll
When the child enables a cell-based mouse protocol, the renderable encodes pointer events, sends nonempty bytes as "input", and stops those events. Duplicate motion and mode 1016 pixel mouse encode to empty bytes and do not stop the event. The renderable has cell coordinates only.
When the child does not encode a wheel event, the renderable scrolls its own viewport by 3 rows and stops the event. Your onMouseDown and related handlers still run after this forwarding.
Selection
selectable defaults to true. The renderable joins the renderer selection and clamps drag endpoints to the visible grid. Selected cells invert on paint. getSelectedText() returns the emulator selection, and the renderer selection uses that same text.
The renderer starts host selection before it dispatches the mouse event. Encoded mouse bytes still go to the child. preventDefault() does not cancel that selection. The same gesture can update host selection and send mouse input.
Cursor and paint
The renderable is always buffered and composes dirty rows into its private buffer. Wide graphemes use their grid width. Bold, dim, italic, underline, blink, hidden, and strikethrough map to cell attributes. Inverse style and selection swap foreground and background.
The paint path does not compose child Kitty graphics or Sixel images. It draws the character grid only.
While the renderable has focus, the host cursor follows the emulator cursor. It takes the bar, block, or underline style. Hollow block becomes block. It takes blink and RGB cursor color when present. A wide-glyph tail shifts the host cursor one cell left.
blur() and destroy() hide the host cursor.
Compose runs in the renderable's renderSelf paint hook. In each frame that paints the renderable, Core runs renderBefore, then compose, then renderAfter. All three draw into the private buffer, where (0, 0) is the top-left cell of the renderable. Native code then draws the private buffer into the frame at the renderable's position.
If renderBefore or renderAfter exists, or a hook changed since the last compose, compose redraws the full grid. That redraw replaces renderBefore output. Use renderAfter to draw over the terminal cells.
onScreenChange runs after each successful compose, in every frame that paints the renderable. It is not a dirty-content signal. Do not use it to start work that must run only when the screen content changes.
invalidate() forces a full redraw on the next compose.
Limits and failures
The reply queue keeps at most 1 MiB and drops an overflowing reply. write() still delivers replies that were already queued and does not throw.
Construction validates cols, rows, and maxScrollback before it creates the native emulator. A failed constructor destroys the renderable and rethrows. destroy() and a failed constructor hide the host cursor. If the renderable has focus, destroy() can send a focus-lost sequence to onData before it frees the handle. After that, methods return without writing. Encoders return empty bytes.
If framebuffer allocation fails, the renderable does not compose into the parent. write() still updates native state.
Test
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
const setup = await createTestRenderer({ width: 40, height: 8 })
try {
const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 })
setup.renderer.root.add(terminal)
terminal.write("hello \x1b[1;32mworld\x1b[0m\r\n")
await setup.renderOnce()
console.log(setup.captureCharFrame())
console.log(terminal.screen().text)
} finally {
setup.renderer.destroy()
}Use encodeKey() and encodePaste() to assert mode-aware sequences without a child process. See Testing for createTestRenderer(), mockInput, and mockMouse.
Options and state
| Member | Type | Description |
|---|---|---|
cols |
number |
Constructor option. Initial emulator columns. Not an instance field. |
rows |
number |
Constructor option. Initial emulator rows. Not an instance field. |
maxScrollback |
number |
Constructor option. Scrollback budget in bytes. Default 10000. |
selectable |
boolean |
Whether the renderable joins renderer selection. Default true. |
transparentBackground |
boolean |
Use the host terminal's default background for the child's default background. Default false. |
onData |
(data: Uint8Array, source: EmbeddedTerminalDataSource) => void |
Called with encoded input or drained replies. |
onTerminalResize |
(cols: number, rows: number) => void |
Called after a visible layout size change resizes the emulator. |
onScreenChange |
() => void |
Called after each successful compose. |
write(data) |
(data: string | Uint8Array) => void |
Parse VT bytes and request a render. |
encodeKey(key) |
(key: KeyEvent) => Uint8Array |
Encode a key with the current keyboard mode. |
encodePaste(bytes) |
(bytes: Uint8Array) => Uint8Array |
Encode a paste with the current paste mode. |
handleKeyPress(key) |
(key: KeyEvent) => boolean |
Encode a key and send nonempty bytes as "input". |
handlePaste(event) |
(event: PasteEvent) => void |
Encode a paste and send nonempty bytes as "input". |
focus() / blur() |
() => void |
Move focus and send focus sequences when the child enabled them. |
screen() |
() => EmbeddedTerminalScreen |
Painted text and native cursor. columns and rows are the layout size. |
hasSelection() |
() => boolean |
Whether this renderable currently has an active selection. |
getSelectedText() |
() => string |
Selected emulator text, or "". |
invalidate() |
() => void |
Force a full redraw on the next compose. |
You can assign onData, onTerminalResize, onScreenChange, and transparentBackground after construction.
React and Solid
React and Solid do not register an element for this renderable. Use the Core API, or register your own catalogue entry with extend().
Solid constructs registered classes with { id } only. maxScrollback, cols, and rows then take their defaults. Use an adapter if those values must differ. See Custom renderables.
Related
Read Interaction, focus, and selection for focus and selection ownership. Read Keyboard input for listener order and paste events. Read Testing for frame capture.
Packages Web Src Content Docs Components Frame Buffer (`packages/web/src/content/docs/components/frame-buffer.mdx`)
title: FrameBuffer
FrameBuffer
FrameBuffer exposes a low-level, two-dimensional cell buffer for custom graphics. Use Image to display encoded images without direct cell drawing.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | FrameBufferRenderable |
| React | Unavailable |
| Solid | Unavailable |
| Status | Advanced |
Basic usage
Renderable API
import { FrameBufferRenderable, RGBA, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const canvas = new FrameBufferRenderable(renderer, {
id: "canvas",
width: 50,
height: 20,
})
// Draw on the frame buffer
canvas.frameBuffer.fillRect(5, 2, 20, 10, RGBA.fromHex("#FF0000"))
canvas.frameBuffer.drawText("Hello!", 8, 6, RGBA.fromHex("#FFFFFF"))
renderer.root.add(canvas)The renderable creates frameBuffer in the renderer's Context. Native code composes it into each frame at the
renderable's position. Call requestRender() after you draw so that the renderer paints the change.
When layout changes the renderable's size, the renderable resizes frameBuffer. A resize clears all cells, so draw
again after it.
Destroying the renderable also destroys its current frameBuffer. You can assign another buffer to frameBuffer.
Create it with owner: renderer.nativeScene. The renderable does not destroy the buffer that you replace.
Drawing methods
setCell
Set a single cell's content and colors:
canvas.frameBuffer.setCell(
x, // X position
y, // Y position
char, // One-cell scalar to display
fg, // Foreground color (RGBA)
bg, // Background color (RGBA)
attributes, // Text attributes (optional, default: 0)
)
// Example
canvas.frameBuffer.setCell(10, 5, "@", RGBA.fromHex("#FFFF00"), RGBA.fromHex("#000000"))setCell() uses only the first code point and does not reserve continuation cells. Use it for one-cell scalars. Use drawText() for wide or joined graphemes.
Draw text and block characters directly to build a compact chart:
network throughput
rx ▂▄▆█▇▅▃▂ 42 MB/s
tx ▃▅▇█▆▄▂▁ 18 MB/s
8 seconds nowsetCellWithAlphaBlending
Set a cell with alpha blending for transparency effects:
const semiTransparent = RGBA.fromValues(1.0, 0.0, 0.0, 0.5)
const transparent = RGBA.fromValues(0, 0, 0, 0)
canvas.frameBuffer.setCellWithAlphaBlending(10, 5, " ", transparent, semiTransparent)drawText
Draw a string of text at a position:
canvas.frameBuffer.drawText(
text, // String to draw
x, // Starting X position
y, // Y position
fg, // Text color (RGBA)
bg, // Background color (RGBA, optional)
attributes, // Text attributes (optional, default: 0)
)
// Example
canvas.frameBuffer.drawText("Score: 100", 2, 1, RGBA.fromHex("#00FF00"))fillRect
Fill a rectangular area with a color:
canvas.frameBuffer.fillRect(
x, // X position
y, // Y position
width, // Rectangle width
height, // Rectangle height
color, // Fill color (RGBA)
)
// Example: Draw a red rectangle
canvas.frameBuffer.fillRect(10, 5, 20, 8, RGBA.fromHex("#FF0000"))drawFrameBuffer
Composite another buffer onto this one. The source must be an owned buffer in the same Context:
canvas.frameBuffer.drawFrameBuffer(
destX, // Destination X
destY, // Destination Y
sourceBuffer, // Source FrameBuffer (OptimizedBuffer)
sourceX, // Source X offset (optional)
sourceY, // Source Y offset (optional)
sourceWidth, // Width to copy (optional)
sourceHeight, // Height to copy (optional)
)drawImage
Place a native image into destination cells:
const placed = canvas.frameBuffer.drawImage(
image,
x,
y,
width,
height,
pixelWidth,
pixelHeight,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
protocol,
)x, y, width, and height use terminal cells. pixelWidth and pixelHeight use terminal pixels and default to 0. Source coordinates default to the full image, and protocol defaults to "auto". Sixel requires nonzero pixel dimensions or falls back to blocks.
false means the buffer recorded no visible valid placement. A successful placement retains the native image until the buffer is cleared, resized, or destroyed. Ownership does not transfer. The caller must still dispose its NativeImage.
colorMatrix / colorMatrixUniform
Apply native 4x4 RGBA matrix transforms for post-processing effects. Use colorMatrixUniform for full-buffer
transforms, and colorMatrix when you want to target specific cells.
import { INVERT_MATRIX, TargetChannel } from "@opentui/core"
// Full-buffer transform
canvas.frameBuffer.colorMatrixUniform(INVERT_MATRIX, 1.0, TargetChannel.Both)
// Per-cell transform with explicit mask
const cellMask = new Float32Array([10, 5, 1.0, 11, 5, 0.5])
canvas.frameBuffer.colorMatrix(INVERT_MATRIX, cellMask, 1.0, TargetChannel.FG)See Color matrix reference for matrix layout, mask format, and behavior details.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
width |
number |
- | Initial buffer and layout width in cells (required) |
height |
number |
- | Initial buffer and layout height in rows (required) |
respectAlpha |
boolean |
false |
Always blend the buffer's cells into the frame |
position |
string |
"relative" |
Positioning mode |
left, top, right, bottom |
number, "auto", or percentage string |
- | Position offsets |
Example: game canvas
import { FrameBufferRenderable, RGBA, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const gameCanvas = new FrameBufferRenderable(renderer, {
id: "game",
width: 40,
height: 20,
position: "absolute",
left: 5,
top: 2,
})
// Game state
let playerX = 20
let playerY = 10
function render() {
const fb = gameCanvas.frameBuffer
const BG = RGBA.fromHex("#111111")
// Clear the canvas
fb.fillRect(0, 0, 40, 20, BG)
// Draw border
for (let x = 0; x < 40; x++) {
fb.setCell(x, 0, "-", RGBA.fromHex("#444444"), BG)
fb.setCell(x, 19, "-", RGBA.fromHex("#444444"), BG)
}
for (let y = 0; y < 20; y++) {
fb.setCell(0, y, "|", RGBA.fromHex("#444444"), BG)
fb.setCell(39, y, "|", RGBA.fromHex("#444444"), BG)
}
// Draw player
fb.setCell(playerX, playerY, "@", RGBA.fromHex("#00FF00"), BG)
// Draw score
fb.drawText("Score: 0", 2, 0, RGBA.fromHex("#FFFF00"))
}
// Handle input
renderer.keyInput.on("keypress", (key) => {
switch (key.name) {
case "up":
playerY = Math.max(1, playerY - 1)
break
case "down":
playerY = Math.min(18, playerY + 1)
break
case "left":
playerX = Math.max(1, playerX - 1)
break
case "right":
playerX = Math.min(38, playerX + 1)
break
}
render()
gameCanvas.requestRender()
})
render()
renderer.root.add(gameCanvas)Example: progress bar
const EMPTY_BG = RGBA.fromHex("#222222")
function drawProgressBar(fb, x, y, width, progress, color) {
const filled = Math.floor(width * progress)
// Draw filled portion
for (let i = 0; i < filled; i++) {
fb.setCell(x + i, y, "█", color, EMPTY_BG)
}
// Draw empty portion
for (let i = filled; i < width; i++) {
fb.setCell(x + i, y, "░", RGBA.fromHex("#333333"), EMPTY_BG)
}
}
// Usage
drawProgressBar(canvas.frameBuffer, 5, 10, 30, 0.75, RGBA.fromHex("#00FF00"))A 20-cell bar with 14 filled cells renders as:
Downloading package
██████████████░░░░░░ 70%
14 of 20 filesRelated APIs
Read the Buffer API for OptimizedBuffer ownership and drawing operations. Use NativeImage to decode and transform image data. The color matrix reference covers post-processing matrix and mask formats.
Packages Web Src Content Docs Components Image (`packages/web/src/content/docs/components/image.mdx`)
title: Image skill: entry: true intents: [image, image-renderable, image-display, kitty, sixel]
Image
ImageRenderable displays PNG, JPEG, WebP, or GIF data from an encoded source. It also accepts an existing NativeImage. The renderable uses Kitty graphics, Sixel, or a Unicode block fallback.
Use NativeImage when you need to decode, inspect, or transform image pixels before display.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | ImageRenderable |
| React | `` (automatic) |
| Solid | `` (automatic) |
| Status | Built in |
Renderable API
import { ImageRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const image = new ImageRenderable(renderer, {
id: "cover",
source: "./cover.webp",
width: 40,
height: 15,
fit: "cover",
protocol: "auto",
onError: console.error,
})
renderer.root.add(image)
await image.loadPromisesource accepts every ImageSource form: a path, file:/HTTP(S)/blob:/data: URL, URL, Blob, Response, Uint8Array, or ArrayBuffer. It also accepts NativeImage. Format detection uses encoded bytes when the source needs decoding. Encoded sources decode into the renderer's Context, so displaying them needs no copy between Contexts. ImageRenderable retains an existing NativeImage. You still own and must dispose the source reference.
React
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
const renderer = await createCliRenderer()
createRoot(renderer).render(
<image source="./cover.webp" fit="cover" protocol="auto" style={{ width: 40, height: 15 }} />,
)Solid
import { createCliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
const renderer = await createCliRenderer()
await render(
() => <image source="./cover.webp" fit="cover" protocol="auto" style={{ width: 40, height: 15 }} />,
renderer,
)You can update source, callbacks, fit, and protocol after construction. Replacing source keeps the current image visible until the replacement succeeds. It cancels obsolete loading and disposes stale native images. Setting source to undefined clears it. Clearing fit or protocol restores "fit" or "auto".
Sizing
fit |
Behavior |
|---|---|
fit |
Contain and center the full image. Preserve aspect (default) |
cover |
Fill the renderable, preserve aspect ratio, and center-crop |
fill |
Fill the renderable and allow stretching |
Sizing uses terminal pixel resolution when available and a 2:1 cell-height fallback otherwise. During startup and resize, Sixel images temporarily use blocks until current pixel geometry arrives.
Rendering protocol
protocol |
Behavior |
|---|---|
auto |
Global override, then Kitty, then Sixel, then Unicode blocks |
kitty |
Force Kitty graphics |
sixel |
Force Sixel. Falls back to blocks without terminal pixel resolution |
blocks |
Portable Unicode quadrant-block rendering |
The blocks protocol renders generated RGBA pixels as terminal cells:
████████████████████████
██████████████████▘██▜██
█████▛▀▝▀█████████▖██▟██
██▛▀██████▝▀█▛▀▘██▀▀████
▀███████████▗▄▙▄██████▀▀
████████▗▄▟███████▄▄████
▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄
████████████████████████With global and per-image protocols set to auto, tmux uses blocks. Explicit Kitty, or Sixel with pixel resolution, uses tmux passthrough.
Overlapping images must use the same effective protocol. OpenTUI does not support layering or alpha composition across different effective protocols. Leave overlapping images on auto or give them the same explicit protocol. Non-overlapping images can use different protocols.
Kitty preserves image alpha, Sixel treats alpha below 128 as transparent, and blocks blend sampled alpha. Placement opacity scales Kitty and block alpha. Sixel dims toward cell backgrounds. Direct, unbuffered fills, text, and box borders cover images at whole-cell granularity without blending.
Use OPENTUI_IMAGE_PROTOCOL=auto|kitty|sixel|blocks to set the global default. OPENTUI_GRAPHICS=false disables Kitty and Sixel detection. See Environment variables.
Split-footer scrollback snapshots use the same protocol resolution as live images. They use Kitty placement, Sixel with detected pixel geometry, or Unicode quadrant blocks. Snapshots with mixed effective protocols, overlapping images, or covered Sixel or Kitty cells use blocks. Native scrollback images can render before the footer is pinned. Each image must fit within the output width and the height available above the pinned footer. Its snapshot must start at the beginning of a line. Await loadPromise before rendering an image into a ScrollbackSurface. See Writing to scrollback.
resolveImageRenderProtocol(requested, capabilities, hasResolution) exposes the same protocol-resolution policy for code that needs it without constructing a renderable.
Kitty transport
Set kittyImageTransport when you create the renderer, or assign renderer.kittyImageTransport to change it at runtime. It controls image transmission, not protocol selection or placement. Kitty commands still use the renderer's output backend.
const renderer = await createCliRenderer({ kittyImageTransport: "zlib" })
renderer.kittyImageTransport = "file"
// After a transmission, inspect the actual choice rather than assuming the request succeeded.
const status = renderer.kittyImageTransportStatus"raw"is the default. It sends existing encoded PNG data unchanged. Otherwise, opaque pixels use RGB24, and other pixels use RGBA32."zlib"tries native level-1 compression. It uses raw if compression expands the stream, exceeds the 64 MiB preparation limit, or cannot allocate buffers. Encoded PNG data stays unchanged."file"writes a temporary file and sends its path for the local terminal to read. It still copies pixels, without shared memory or GPU sharing.
A transport change retransmits visible Kitty images without replacing their sources. File negotiation remains asynchronous. When you select an inline mode, outstanding files stay alive until their acknowledgments or deadlines, so queued output can still use them. When you switch back to file, the renderer reuses the session's probe result. It does not reset a failed or cancelled session.
Scrollback snapshots never use file transport because the renderer does not retain their sources for retries. They use self-contained raw or PNG output, or zlib when requested.
File transport
File transport requires a compatible local POSIX terminal. Windows, remote sessions, disabled graphics, and multiplexers use raw/PNG instead. Custom stdout defaults to remote. Set remote: false only if it connects to a local terminal.
File transport requires both a successful t=f query with a real file and an upload acknowledgment with an explicit image ID. Older WezTerm versions that answer queries but omit upload acknowledgments stay on raw/PNG. These checks are asynchronous, so initial frames can use raw/PNG while fileState is "probing".
Each file has a unique name, permissions 0600, and contents that do not change. The renderer uses the TMPDIR value forwarded at creation, or /tmp when absent. If you override forwardEnvKeys, include TMPDIR.
Each renderer allows at most eight pending files and 64 MiB of pending data. If an image ID is busy or the renderer reaches either limit, it uses raw/PNG without replacing pending files.
A matching Kitty reply lets the renderer remove the file. Frame events, stdout drain, and cursor reports do not confirm that the terminal read or displayed an image.
The renderer checks each file's five-second deadline during rendering and once per second while idle. A timeout does not confirm that the terminal read the file.
Timeouts, terminal or output errors, cancelKittyImageTransport(), suspend, and destroy disable file uploads and attempt to remove pending files. Active images retry with raw/PNG after a failed upload. Late replies cannot re-enable file transport. Cleanup failures or termination that bypasses cleanup, such as SIGKILL, can leave files behind.
Transport status
kittyImageTransportStatus reports requested, effective, fileState, fallback, pendingFiles, and pendingBytes. The effective value describes the last transmission: raw, zlib, png, or file. The compression fallback means compression did not save space or was unavailable. File transport preserves PNG payloads, but still reports effective: "file".
Options and state
| Member | Type | Description |
|---|---|---|
source |
ImageRenderableSource |
Encoded image source or NativeImage. Optional |
fit |
"fit" | "cover" | "fill" |
Destination sizing |
protocol |
"auto" | "kitty" | "sixel" | "blocks" |
Requested rendering protocol |
onLoad |
(image: NativeImage) => void |
Called after the current source loads |
onError |
(error: unknown) => void |
Called when the current source fails |
image |
NativeImage | null |
Currently displayed renderable-owned image |
loading |
boolean |
Whether the current source is loading |
loadError |
unknown |
Current load error, otherwise null |
loadPromise |
Promise<void> | null |
Settles after the current load attempt |
effectiveProtocol |
kitty | sixel | blocks |
Current resolved protocol. Can change with capabilities or size |
cellAspectRatio |
number |
Physical or fallback cell aspect ratio |
getFittedSize(...) |
(width, height, cellAspect?, sourceWidth?, sourceHeight?) |
Resolve destination cells. Omitted overrides use current values |
The renderable owns image and the NativeImage passed to onLoad. Do not dispose or transfer them. Images loaded from an encoded source, and images retained or derived from them, belong to the renderer's Context and stop working when the renderer is destroyed. To keep an image beyond the renderer, create it yourself (for example with NativeImage.load()) and pass it as source. Current-source failures set loadError, call onError, and resolve loadPromise. Superseded, cleared, or destroyed loads resolve without callbacks. Exceptions from either callback reject after state settles. Successful replacement, clearing, and destruction release owned images. A failed replacement keeps the current image.
Related components
Use FrameBuffer when you need direct cell drawing or OptimizedBuffer.drawImage(). Use QR code for text encoded as a scannable symbol.
Packages Web Src Content Docs Components Input (`packages/web/src/content/docs/components/input.mdx`)
title: Input skill: entry: true intents: [input, form, editing, focus]
Input
Input edits one line of text and supplies a cursor, placeholder, and focus styles. Use Textarea for multi-line editing.
Focus the input to receive keyboard input.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | InputRenderable |
| React | `` (automatic) |
| Solid | `` (automatic) |
| Status | Built in |
Basic usage
Renderable API
import { InputRenderable, InputRenderableEvents, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const input = new InputRenderable(renderer, {
id: "name-input",
width: 25,
placeholder: "Enter your name...",
})
input.on(InputRenderableEvents.CHANGE, (value) => {
console.log("Input value:", value)
})
input.focus()
renderer.root.add(input)An empty input displays its placeholder:
Name
Enter your nameFocus states
The input uses its focused colors while it has focus:
const input = new InputRenderable(renderer, {
id: "styled-input",
width: 30,
placeholder: "Type here...",
backgroundColor: "#1a1a1a",
focusedBackgroundColor: "#2a2a2a",
textColor: "#FFFFFF",
cursorColor: "#00FF00",
})After focusing the input and typing a name:
Name
Ada LovelaceEvents
Input event
Emitted after these operations:
- Typing, paste, or
insertText()inserts text. - A delete key, a delete method such as
deleteCharBackward(),undo(), orredo()runs. - You assign a different
value.
The listener receives the current value. Other buffer methods, such as setText(), insertChar(), deleteSelection(),
and deleteRange(), do not emit it:
import { InputRenderableEvents } from "@opentui/core"
input.on(InputRenderableEvents.INPUT, (value: string) => {
console.log("Current value:", value)
})Change event
Emitted on blur, or after a successful submit(), when the value differs from the last commit baseline:
input.on(InputRenderableEvents.CHANGE, (value: string) => {
console.log("Value committed:", value)
})The constructor and each focus() call set the baseline. Each emitted CHANGE event updates it. Thus, blur does not
emit another event if the value did not change after a successful commit.
Enter event
Emitted when submit() succeeds. Return, keypad Enter, and linefeed call submit(). If the current UTF-16 code-unit length
is less than minLength, submit() fails and the input emits no event:
input.on(InputRenderableEvents.ENTER, (value: string) => {
console.log("Submitted value:", value)
})Getting the current value
const currentValue = input.valueSetting the value
input.value = "New value"Properties
| Property | Type | Default | Description |
|---|---|---|---|
width |
number, "auto", or percentage string |
"auto" |
Input field width |
value |
string |
"" |
Initial text, with newlines removed |
placeholder |
string |
"" |
Placeholder text when empty |
minLength |
number |
0 |
Minimum UTF-16 code-unit length required for submit |
maxLength |
number |
1000 |
Maximum UTF-16 code-unit length |
backgroundColor |
string | RGBA |
"transparent" |
Background when unfocused |
focusedBackgroundColor |
string | RGBA |
backgroundColor, else "transparent" |
Background when focused |
textColor |
string | RGBA |
"#FFFFFF" |
Text color |
cursorColor |
string | RGBA |
"#FFFFFF" |
Cursor color |
position |
"relative" | "absolute" |
"relative" |
Positioning mode |
Example: login form
Input has no password-masking mode. This example shows the password value as normal terminal text. Do not use this
pattern for real secrets.
import { BoxRenderable, InputRenderable, TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const form = new BoxRenderable(renderer, {
width: 40,
borderStyle: "rounded",
title: "Login",
padding: 1,
gap: 1,
})
const usernameInput = new InputRenderable(renderer, {
id: "username-input",
placeholder: "Enter username",
width: 20,
backgroundColor: "#222",
focusedBackgroundColor: "#333",
})
const passwordInput = new InputRenderable(renderer, {
id: "password-input",
placeholder: "Enter password",
width: 20,
backgroundColor: "#222",
focusedBackgroundColor: "#333",
})
form.add(new TextRenderable(renderer, { content: "Username:", fg: "#888888" }))
form.add(usernameInput)
form.add(new TextRenderable(renderer, { content: "Password:", fg: "#888888" }))
form.add(passwordInput)
renderer.root.add(form)
usernameInput.focus()Tab navigation
Add tab navigation between inputs:
const inputs = [usernameInput, passwordInput]
let focusIndex = 0
renderer.keyInput.on("keypress", (key) => {
if (key.name === "tab") {
focusIndex = (focusIndex + 1) % inputs.length
inputs[focusIndex].focus()
}
})Related concepts
Read Interaction, focus, and selection for focus and event behavior. Read Text and terminal cells before enforcing limits that depend on code points, graphemes, or display cells.
Packages Web Src Content Docs Components Line Number (`packages/web/src/content/docs/components/line-number.mdx`)
title: Line number gutter
Line number gutter
LineNumberRenderable adds a gutter to a renderable that supplies line information. Use it with Code or an editor. Diff manages its own line-number gutters.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | LineNumberRenderable |
| React | <line-number> (automatic) |
| Solid | <line_number> is runtime built in. Its exact props declaration is missing. |
| Status | Built in with a Solid typing limitation |
Basic usage
Renderable API
import {
CodeRenderable,
LineNumberRenderable,
ScrollBoxRenderable,
SyntaxStyle,
RGBA,
createCliRenderer,
} from "@opentui/core"
const renderer = await createCliRenderer()
const syntaxStyle = SyntaxStyle.fromStyles(
{
default: { fg: RGBA.fromHex("#E6EDF3") },
},
renderer.nativeScene,
)
const code = new CodeRenderable(renderer, {
id: "code",
content: "const port = 3000\nserve(port)\nawait ready()",
filetype: "typescript",
syntaxStyle,
width: "100%",
})
const lineNumbers = new LineNumberRenderable(renderer, {
id: "code-lines",
target: code,
minWidth: 3,
paddingRight: 1,
fg: "#6b7280",
bg: "#161b22",
})
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "scrollbox",
width: 70,
height: 18,
})
lineNumbers.setLineSign(1, { before: ">" })
scrollbox.add(lineNumbers)
renderer.root.add(scrollbox)1 const port = 3000
> 2 serve(port)
3 await ready()LineNumberRenderable adds the gutter and the target as its own children. Add the LineNumberRenderable to the tree, not the target.
Line signs and colors
Line arguments and map keys are zero-based logical line indexes. With the default lineNumberOffset, line 0 shows the number 1.
Set one background color for a line, or set separate gutter and content colors with a LineColorConfig object:
// Shorthand: applies the color to the gutter and a 20%-darker color to the content
lineNumbers.setLineColor(3, "#2b6cb0")
// Split config: different gutter vs content background
lineNumbers.setLineColor(3, {
gutter: "#2b6cb0",
content: "#1f2937",
})
lineNumbers.setLineSign(3, { before: ">", beforeColor: "#2b6cb0" })
// Clear
lineNumbers.clearLineColor(3)
lineNumbers.clearLineSign(3)The shorthand color becomes the gutter background. The content background uses the same color, 20% darker. A LineColorConfig with only gutter uses the same rule for the content background.
Theming the gutter
You can change the gutter text and background colors after construction:
lineNumbers.fg = "#6b7280"
lineNumbers.bg = "#161b22"Assign undefined to restore the defaults: a #888888 foreground and a transparent background.
Initial visibility
The constructor ignores the showLineNumbers option. To hide the gutter at the start, set the property after construction:
const lineNumbers = new LineNumberRenderable(renderer, { target: code })
lineNumbers.showLineNumbers = falseProperties
| Property | Type | Default | Description |
|---|---|---|---|
target |
Renderable & LineInfoProvider |
- | Target renderable to number |
fg |
string or RGBA |
#888888 |
Gutter text color |
bg |
string or RGBA |
transparent | Gutter background color |
minWidth |
number |
3 |
Minimum gutter width |
paddingRight |
number |
1 |
Right padding for gutter |
lineColors |
Map<number, string | RGBA | LineColorConfig> |
- | Per-line background colors |
lineSigns |
Map<number, LineSign> |
- | Per-line signs (before/after) |
lineNumberOffset |
number |
0 |
Offset for line numbering |
hideLineNumbers |
Set<number> |
- | Lines to hide numbers for |
lineNumbers |
Map<number, number> |
- | Override line numbers per line |
showLineNumbers |
boolean |
true |
Toggle gutter visibility |
Methods
| Method | Description |
|---|---|
setLineColor() |
Set a background color for a line |
clearLineColor() |
Clear a line background color |
setLineSign() |
Set a sign before/after a line number |
clearLineSign() |
Clear a line sign |
setLineNumbers() |
Override multiple line numbers |
setHideLineNumbers() |
Hide line numbers for specific lines |
Related components
Use Code as a syntax-highlighted target. Use Diff when you need patch parsing and paired gutters. Markdown owns fenced code blocks. TextTable displays rows and columns without line semantics.
Packages Web Src Content Docs Components Markdown (`packages/web/src/content/docs/components/markdown.mdx`)
title: Markdown
Markdown
Markdown renders document structure and can use Tree-sitter highlighting in fenced code blocks. Use Code when the full content is source code.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | MarkdownRenderable |
| React | <markdown> (automatic) |
| Solid | <markdown> (automatic) |
| Status | Built in |
Basic usage
Renderable API
import { MarkdownRenderable, SyntaxStyle, RGBA, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const syntaxStyle = SyntaxStyle.fromStyles(
{
"markup.heading.1": { fg: RGBA.fromHex("#58A6FF"), bold: true },
"markup.list": { fg: RGBA.fromHex("#FF7B72") },
"markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
default: { fg: RGBA.fromHex("#E6EDF3") },
},
renderer.nativeScene,
)
const markdown = new MarkdownRenderable(renderer, {
id: "readme",
width: 60,
content: "# Hello\n\n- One\n- Two\n\n```ts\nconst x = 1\n```",
syntaxStyle,
})
renderer.root.add(markdown)Fenced language normalization
Markdown normalizes each code fence info string to a file type before Tree-sitter highlighting:
tsx->typescriptreact.jsx->javascriptreactTSX title=Button.tsx->typescriptreactDockerfile->dockerfile
Markdown uses infoStringToFiletype() for this step. To add or change mappings at runtime, edit the maps that it reads:
import { extensionToFiletype, basenameToFiletype } from "@opentui/core"
extensionToFiletype.set("templ", "html")
basenameToFiletype.set("mytoolrc", "yaml")Concealment
Set conceal: true to hide Markdown markers, such as backticks and emphasis markers:
const markdown = new MarkdownRenderable(renderer, {
content: "**bold** and `code`",
syntaxStyle,
conceal: true,
})concealCode controls concealment inside fenced code blocks separately. Its default is false.
Streaming updates
Use streaming mode when content arrives in chunks. Keep streaming: true while you append chunks.
When the content is complete, set markdown.streaming = false to finish parsing the trailing block.
Tables show trailing partial rows, with missing cells shown empty.
const markdown = new MarkdownRenderable(renderer, {
content: "",
syntaxStyle,
streaming: true,
})
markdown.content += "# Live log\n"
markdown.content += "- line 1\n"
markdown.streaming = falseStable block prefix
parseMarkdownIncremental reports how many parsed tokens at the head of the stream are unlikely to change after an append. The renderable exposes this value as a count of top-level render blocks. You can commit those blocks while the unstable tail changes.
To use it, set internalBlockMode: "top-level". The renderable then keeps each top-level Markdown block as a separate child renderable. These blocks include headings, paragraphs, lists, tables, and fenced code. markdown._stableBlockCount gives the current stable prefix length. In the default mode, it is always 0. This matches the shape that ScrollbackSurface expects for row-by-row commits.
import { MarkdownRenderable, RGBA, SyntaxStyle } from "@opentui/core"
const syntaxStyle = SyntaxStyle.fromStyles(
{
default: { fg: RGBA.fromHex("#E6EDF3") },
},
renderer.nativeScene,
)
const md = new MarkdownRenderable(renderer, {
content: "",
syntaxStyle,
streaming: true,
internalBlockMode: "top-level",
})
md.content = "# Title\n\nPara 1"
// md._stableBlockCount is 0. While streaming, the newest parsed tokens stay unstable.
md.content = "# Title\n\nPara 1\n\nPara 2"
// Still 0.
md.content = "# Title\n\nPara 1\n\nPara 2\n\nPara 3"
// md._stableBlockCount is 2: "# Title" and "Para 1" are stable.internalBlockMode is an internal, experimental flag for the built-in scrollback streaming demo. This status does not apply to Markdown or to its other options. For normal rendering, keep the default value, "coalesced". This value combines sibling blocks into larger render blocks and keeps the existing layout.
Markdown tables
Use tableOptions to set the style, sizing, wrapping, borders, and selection of Markdown tables.
const markdown = new MarkdownRenderable(renderer, {
content: "| Service | Status |\n| --- | --- |\n| api | ok |",
syntaxStyle,
tableOptions: {
style: "grid",
widthMode: "full",
columnFitter: "balanced",
wrapMode: "word",
cellPadding: 1,
cellPaddingX: 2,
cellPaddingY: 0,
borders: true,
outerBorder: true,
borderStyle: "rounded",
borderColor: "#6b7280",
selectable: true,
},
})`tableOptions`
| Option | Type | Default | Description |
|---|---|---|---|
style |
"grid" | "columns" |
depends on block mode | Visual preset (see Table styles) |
widthMode |
"content" | "full" |
depends on style | "full" expands columns to fill available width |
columnFitter |
"proportional" | "balanced" |
"proportional" |
How columns shrink when space is constrained |
wrapMode |
"none" | "char" | "word" |
"word" |
Wrapping mode inside each table cell |
cellPadding |
number |
0 |
Padding on all sides of each cell |
cellPaddingX |
number |
cellPadding |
Horizontal padding on each side |
cellPaddingY |
number |
cellPadding |
Vertical padding on each side |
borders |
boolean |
depends on style | Enable inner and outer borders |
outerBorder |
boolean |
borders |
Override outer border visibility |
borderStyle |
BorderStyle |
"single" |
Table border character set |
borderColor |
ColorInput |
conceal fg or #888888 |
Border color for markdown tables |
selectable |
boolean |
true |
Enable table cell text selection |
Table styles
tableOptions.style picks a preset. The preset sets the defaults for borders, outerBorder, and widthMode:
"grid": a boxed table with visible borders. Defaults toborders: trueandwidthMode: "full". This is the normal table rendering."columns": borderless columns with a 2-column gap. Defaults toborders: falseandwidthMode: "content". Use it for append-only output where a full-width grid is too heavy.
This Name/Status sample shows both presets:
Grid:
┌────────┬────────┐
│ Name │ Status │
├────────┼────────┤
│ api │ ready │
├────────┼────────┤
│ worker │ paused │
└────────┴────────┘Columns:
Name Status
api ready
worker pausedIf you do not pass style, it is "columns" when internalBlockMode is "top-level", and "grid" otherwise. Individual fields, such as borders: true, override the preset defaults.
Custom node rendering
Use renderNode to replace the renderable for a block. Return undefined or null to use the default renderable. Call context.defaultRender() to create the default renderable yourself:
const markdown = new MarkdownRenderable(renderer, {
content: "# Title\n\nHello",
syntaxStyle,
renderNode: (token, context) => {
if (token.type === "heading") {
return context.defaultRender()
}
return undefined
},
})Custom fenced-code languages
Use createMarkdownCodeBlockRenderer to replace only specific fenced-code languages. It matches each key against the normalized info string. A tsx fence matches the key typescriptreact. A custom name such as taskflow matches itself.
import {
BoxRenderable,
MarkdownRenderable,
SyntaxStyle,
TextRenderable,
createMarkdownCodeBlockRenderer,
type CliRenderer,
type MarkdownCodeBlockRenderer,
} from "@opentui/core"
const syntaxStyle = SyntaxStyle.fromStyles({ default: {} }, renderer.nativeScene)
const renderTaskFlow =
(renderer: CliRenderer): MarkdownCodeBlockRenderer =>
(token) => {
const steps = token.text
.split("\n")
.filter((line) => line.startsWith("step "))
.map((line) => line.slice("step ".length))
const card = new BoxRenderable(renderer, {
border: true,
borderStyle: "rounded",
borderColor: "#38BDF8",
paddingX: 1,
flexDirection: "column",
width: "100%",
})
for (const step of steps) {
card.add(new TextRenderable(renderer, { content: `- ${step}`, width: "100%" }))
}
return card
}
const markdown = new MarkdownRenderable(renderer, {
content: "```taskflow\nstep Parse markdown done\nstep Render widget active\n```",
syntaxStyle,
renderNode: createMarkdownCodeBlockRenderer({
taskflow: renderTaskFlow(renderer),
}),
})Properties
| Property | Type | Default | Description |
|---|---|---|---|
content |
string |
"" |
Markdown source |
syntaxStyle |
SyntaxStyle |
required | Style definitions for tokens |
fg |
ColorInput |
- | Base foreground color, also used by inner code blocks |
bg |
ColorInput |
- | Base background color, also used by inner code blocks |
conceal |
boolean |
true |
Hide markdown markers in markdown text |
concealCode |
boolean |
false |
Hide markers inside fenced code blocks |
streaming |
boolean |
false |
Incremental mode. Set false to finalize |
tableOptions |
MarkdownTableOptions |
- | Options for markdown table rendering |
internalBlockMode |
"coalesced" | "top-level" |
"coalesced" |
Experimental: expose top-level blocks as separate renderables |
treeSitterClient |
TreeSitterClient |
- | Custom Tree-sitter client for code blocks |
renderNode |
(token: Token, context: RenderNodeContext) => Renderable | null | undefined |
- | Custom renderer for each Markdown block |
Markdown rebuilds its blocks at once when you set content.
It applies syntaxStyle, fg, bg, conceal, and concealCode changes later, in its lifecycle pass (onLifecyclePass).
Core runs lifecycle passes after the frame callbacks and before layout, so the new styles appear in that frame.
These five setters do not request a frame. If nothing else requests one, call markdown.requestRender().
If you assign onLifecyclePass on a Markdown renderable, even to null, Markdown no longer applies these changes by itself.
Call refreshStyles() after each change. refreshStyles() rebuilds the blocks at once and requests a frame.
OpenTUI bundles a limited parser set. See the Tree-sitter reference before you highlight other fenced-code languages.
Related components
Use Code for source-only content. Add a Line number gutter to a compatible code or editor renderable. Use Diff for patches. Use TextTable for table data that does not start as Markdown.
Packages Web Src Content Docs Components Qr Code (`packages/web/src/content/docs/components/qr-code.mdx`)
title: QR code
QR code
QRCodeRenderable displays text or URLs as a QR Code Model 2 symbol. It uses half-block cells to keep the symbol square in terminal cell geometry.
For module matrices, terminal strings, SVG, and raw bytes, use the standalone QR encoder.
The same package exports this encoder. It also supports explicit segments, ECI, GS1/FNC1, and structured append.
Availability
| Field | Availability |
|---|---|
| Package | Core @opentui/qrcode, React @opentui/qrcode/react, Solid @opentui/qrcode/solid |
| Core renderable | QRCodeRenderable |
| React | Call registerQRCode() once, then use <qr-code> |
| Solid | Call registerQRCode() once, then use <qr_code> |
| Status | Separately registered |
Install the QR code package separately:
bun add @opentui/qrcodeBasic usage
Renderable API
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",
quietZone: 4,
scale: 2,
})
renderer.root.add(qr)React JSX
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
import { registerQRCode } from "@opentui/qrcode/react"
registerQRCode()
const renderer = await createCliRenderer()
createRoot(renderer).render(
<qr-code
content="https://opentui.com/docs"
quietZone={4}
scale={2}
foregroundColor="#111827"
backgroundColor="#ffffff"
/>,
)Solid JSX
import { render } from "@opentui/solid"
import { registerQRCode } from "@opentui/qrcode/solid"
registerQRCode()
render(() => (
<qr_code
content="https://opentui.com/docs"
quietZone={4}
scale={2}
foregroundColor="#111827"
backgroundColor="#ffffff"
/>
))Sizing
QRCodeRenderable measures itself from the encoded QR version, the quiet zone, and the requested scale. The default fit: "contain" lets the QR code shrink to fit constrained parents while preserving a valid square module grid.
const qr = new QRCodeRenderable(renderer, {
content: "opentui.com",
quietZone: 4,
scale: 2,
fit: "contain",
})Use fit: "none" when you want the configured scale to be the only rendered size.
quietZone must be at least 4 modules for a standard QR code. OpenTUI validates the matrix and quiet-zone geometry, but scanner reliability still depends on terminal font, cell aspect ratio, display contrast, and camera conditions.
The content OPENTUI and a four-module quiet zone fit a version-one symbol:
█▀▀▀▀▀█ ▀█▄▀█ █▀▀▀▀▀█
█ ███ █ ▀ ▀▀█ █ ███ █
█ ▀▀▀ █ ▀ ▀█ █ ▀▀▀ █
▀▀▀▀▀▀▀ █ ▀▄█ ▀▀▀▀▀▀▀
▄▄ ▄ ▀▀▄ █ ▄▀▄▀▄█▄█▄█
▄█▀▀█▄▀ █ ▀▀▀▄▄█ ▀█▄
▀ ▀ ▀▀█ ██▄ ▀▀ ▀ ▀
█▀▀▀▀▀█ ▄▄▀ ▀▀▄ ▀█▄█▀
█ ███ █ ▀███▀█ ▀▄▀
█ ▀▀▀ █ █ ▀▀▀▀▀█▄▀▀█
▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀▀Fallback content
When a container is too small to render even a scale-1 QR code, show fallback text instead of an empty area:
const qr = new QRCodeRenderable(renderer, {
content: "https://opentui.com/docs",
fallbackContent: "Resize for QR",
fallbackColor: "#94a3b8",
})Fallback painting currently indexes UTF-16 code units and writes one cell for each unit. Use one-cell Basic Multilingual
Plane characters. Emoji, joined or combining graphemes, and wide characters can split, disappear, or misalign.
Error correction
Set errorCorrectionLevel with the QR package error correction enum:
import { ErrorCorrectionLevel, QRCodeRenderable } from "@opentui/qrcode"
const qr = new QRCodeRenderable(renderer, {
content: "opentui.com",
errorCorrectionLevel: ErrorCorrectionLevel.H,
})Higher error correction improves resilience but can increase the QR version and rendered size for longer content.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
content |
string |
"" |
Text content to encode |
errorCorrectionLevel |
ErrorCorrectionLevel |
M |
QR error correction level |
quietZone |
number |
4 |
Blank module border around the QR code |
scale |
number |
1 |
Terminal columns per QR module before fitting |
fit |
"contain" | "none" |
"contain" |
Whether the QR code may shrink to fit its parent |
foregroundColor |
ColorInput |
"#000000" |
Dark module color |
backgroundColor |
ColorInput |
"#ffffff" |
Light module and quiet-zone color |
fallbackContent |
string |
"" |
Text rendered when the QR code cannot fit |
fallbackColor |
ColorInput |
"#ffffff" |
Fallback text color |
Related APIs
Use the QR encoder when you need a matrix or serialized output without a renderable. See Image for decoded image display.
Packages Web Src Content Docs Components Scrollbar (`packages/web/src/content/docs/components/scrollbar.mdx`)
title: ScrollBar
ScrollBar
ScrollBar controls a scroll position with optional arrows, keyboard input, and a draggable thumb. Use ScrollBox when the component must also own and clip scrollable children.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | ScrollBarRenderable |
| React | Unavailable |
| Solid | Unavailable |
| Status | Built-in Core renderable |
Basic usage
Renderable API
import { ScrollBarRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const scrollbar = new ScrollBarRenderable(renderer, {
id: "scrollbar",
orientation: "vertical",
height: 10,
showArrows: true,
trackOptions: {
backgroundColor: "#222222",
foregroundColor: "#888888",
},
onChange: (position) => {
console.log("Scroll position:", position)
},
})
// Connect the scrollbar to your content
scrollbar.scrollSize = 200
scrollbar.viewportSize = 20
scrollbar.scrollPosition = 0
renderer.root.add(scrollbar)
scrollbar.focus()position: 0 / 180 ▲
▀
viewport: 20 / 200 ▼Keyboard controls
When focused, the scrollbar responds to:
Up/Downork/jfor vertical barsLeft/Rightorh/lfor horizontal barsPageUp/PageDownto move by half the viewportHome/Endto jump to start/end
Properties
| Property | Type | Default | Description |
|---|---|---|---|
orientation |
"vertical" or "horizontal" |
- | Scrollbar direction |
showArrows |
boolean |
false |
Show arrow buttons |
arrowOptions |
ArrowOptions |
- | Styling for arrow buttons |
trackOptions |
Partial<SliderOptions> |
- | Styling for the track and thumb |
scrollSize |
number |
0 |
Total scrollable size |
viewportSize |
number |
0 |
Visible size of the viewport |
scrollPosition |
number |
0 |
Current scroll position |
scrollStep |
number |
- | Step size when scrollBy(..., "step") |
onChange |
(position: number) => void |
- | Fired when scroll position changes |
Related concepts
Read Layout for viewport dimensions. Read Interaction, focus, and selection for keyboard and pointer behavior. Slider exposes the underlying continuous-value control without scroll semantics.
Packages Web Src Content Docs Components Scrollbox (`packages/web/src/content/docs/components/scrollbox.mdx`)
title: ScrollBox
ScrollBox
ScrollBox scrolls arbitrary child renderables and manages its own ScrollBar instances. Use a standalone ScrollBar only when another model owns the scroll position.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | ScrollBoxRenderable |
| React | <scrollbox> (automatic) |
| Solid | <scrollbox> (automatic) |
| Status | Built in |
Basic usage
Renderable API
import { ScrollBoxRenderable, TextRenderable, BoxRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "scrollbox",
width: 40,
height: 20,
})
// Add content to the scrollbox
for (let i = 0; i < 100; i++) {
scrollbox.add(
new BoxRenderable(renderer, {
id: `item-${i}`,
width: "100%",
height: 2,
backgroundColor: i % 2 === 0 ? "#292e42" : "#2f3449",
}),
)
}
renderer.root.add(scrollbox)A bordered list of source files initially shows its first five rows:
offset: 0 / 7
┌──────────────────────────────┐
│01 src/index.ts ▀│
│02 src/app.ts │
│03 src/layout.ts │
│04 src/theme.ts │
│05 src/events.ts │
└──────────────────────────────┘Sticky scroll
Enable sticky scroll to keep content pinned to an edge as new content arrives. Set both stickyScroll and stickyStart because stickyStart has no default.
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "logs",
width: 60,
height: 20,
stickyScroll: true,
stickyStart: "bottom", // New content will keep the view scrolled to bottom
})Sticky positions
"bottom": Stay scrolled to the bottom"top": Stay scrolled to the top"left": Stay scrolled to the left"right": Stay scrolled to the right
When you scroll away from the sticky position, sticky behavior pauses until you scroll back to the sticky edge.
Bidirectional scrolling
Enable scrolling in both directions:
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "canvas",
width: 60,
height: 30,
scrollX: true,
scrollY: true,
})By default, scrollY is true and scrollX is false.
Viewport culling
Viewport culling skips children outside the viewport in large content. It is on by default:
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "large-list",
width: 40,
height: 20,
viewportCulling: true, // Only render visible items
})Culling applies when the content holds 16 or more direct children, including hidden ones, or when the viewport has zero width or height. Native code then skips each direct child that does not overlap the viewport, with its whole subtree. Otherwise, native code paints all children and clips them to the viewport.
Skipped children and their descendants run no paint hooks (renderBefore, renderSelf, and renderAfter) and no update hook (onUpdate). Yoga still computes their layout. A skipped direct child still runs its resize hook (onResize), but its descendants do not. Do not make layout or state depend on paint or update hooks. Disable viewportCulling if every child must run these hooks.
Customizing scrollbars
Style the scrollbars using nested options:
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "styled-scroll",
width: 40,
height: 20,
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
// Or customize vertical and horizontal separately
verticalScrollbarOptions: {
trackOptions: { backgroundColor: "#333" },
},
horizontalScrollbarOptions: {
trackOptions: { backgroundColor: "#333" },
},
})Customizing sub-components
ScrollBox contains several internal components that you can style individually:
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "custom-scroll",
width: 40,
height: 20,
rootOptions: {
backgroundColor: "#24283b",
},
wrapperOptions: {
backgroundColor: "#1f2335",
},
viewportOptions: {
backgroundColor: "#1a1b26",
},
contentOptions: {
backgroundColor: "#16161e",
},
})Scroll methods
scrollBy
Scroll by a relative amount:
// Scroll down 5 lines
scrollbox.scrollBy(5)
// Scroll with both x and y
scrollbox.scrollBy({ x: 10, y: 5 })
// Scroll by viewport (page)
scrollbox.scrollBy(1, "viewport")scrollTo
Scroll to an absolute position:
// Scroll to top
scrollbox.scrollTo(0)
// Scroll to specific position
scrollbox.scrollTo({ x: 0, y: 100 })The same list after scrollTo(5):
offset: 5 / 7
┌──────────────────────────────┐
│06 src/input.ts │
│07 src/scroll.ts │
│08 src/render.ts │
│09 src/state.ts ▀│
│10 src/config.ts │
└──────────────────────────────┘scrollChildIntoView
Scroll the minimum distance needed to show a nested child in the viewport. The method uses DOM-style "nearest"
behavior. If the child already fits, the call is a no-op. An oversized child also stays in place when it extends beyond
both edges of an axis. Otherwise, the method scrolls to reveal the nearest edge.
scrollbox.scrollChildIntoView("table-row-42")Use it when you focus an offscreen element, such as a search result or a new form field.
Keyboard navigation
When focused, ScrollBox responds to keyboard input:
- Arrow keys: Move by one fifth of the active viewport.
- Page Up/Down: Move by one half of the vertical viewport.
- Home/End: Move to the start or end.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
scrollX |
boolean |
false |
Enable horizontal scrolling |
scrollY |
boolean |
true |
Enable vertical scrolling |
stickyScroll |
boolean |
false |
Keep scroll position pinned to an edge |
stickyStart |
"top" | "bottom" | "left" | "right" |
- | Which edge to stick to |
viewportCulling |
boolean |
true |
Skip children outside the viewport |
scrollAcceleration |
ScrollAcceleration |
- | Custom scroll acceleration algorithm |
rootOptions |
BoxOptions |
- | Style options for root container |
wrapperOptions |
BoxOptions |
- | Style options for wrapper |
viewportOptions |
BoxOptions |
- | Style options for viewport |
contentOptions |
BoxOptions |
- | Style options for content container |
scrollbarOptions |
ScrollBarOptions |
- | Options for both scrollbars |
verticalScrollbarOptions |
ScrollBarOptions |
- | Options for vertical scrollbar only |
horizontalScrollbarOptions |
ScrollBarOptions |
- | Options for horizontal scrollbar only |
Additional properties
| Property | Type | Description |
|---|---|---|
scrollTop |
number |
Current vertical scroll position (get/set) |
scrollLeft |
number |
Current horizontal scroll position (get/set) |
scrollWidth |
number |
Total scrollable width (read-only) |
scrollHeight |
number |
Total scrollable height (read-only) |
Internal components
ScrollBox exposes its internal components for advanced use:
scrollbox.wrapper // BoxRenderable - outer wrapper
scrollbox.viewport // BoxRenderable - visible area
scrollbox.content // ContentRenderable - holds children
scrollbox.horizontalScrollBar // ScrollBarRenderable
scrollbox.verticalScrollBar // ScrollBarRenderableRelated concepts
Read Layout for viewport sizing. Read Interaction, focus, and selection for focus, wheel input, drag selection, and event propagation.
Packages Web Src Content Docs Components Select (`packages/web/src/content/docs/components/select.mdx`)
title: Select
Select
Select shows discrete choices in a vertical list. Use TabSelect for a horizontal set of peer views or Slider for a continuous value.
Focus the select to enable keyboard input.
Availability
| Field | Availability |
|---|---|
| Package | @opentui/core |
| Core renderable | SelectRenderable |
| React | <select> (automatic) |
| Solid | <select> (automatic) |
| Status | Built in |
Basic usage
Renderable API
import { SelectRenderable, SelectRenderableEvents, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const menu = new SelectRenderable(renderer, {
id: "menu",
width: 32,
height: 6,
options: [
{ name: "New file", description: "Create a document" },
{ name: "Open file", description: "Browse existing files" },
{ name: "Save", description: "Write current changes" },
],
})
menu.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Selected:", option.name)
})
menu.focus()
renderer.root.add(menu)After pressing Down, the next option is selected:
New file
Create a document
▶ Open file
Browse existing files
Save
Write current changesKeyboard navigation
When focused, the select responds to these keys:
| Key | Action |
|---|---|
Up / k |
Move selection up |
Down / j |
Move selection down |
Shift+Up / Shift+Down |
Fast scroll (5 items) |
Enter |
Select current item |
Events
Item selected
Fires when the user presses Enter on an option:
import { SelectRenderableEvents, type SelectOption } from "@opentui/core"
menu.on