opentui Documentation

OpenTUI is a library to build terminal user interfaces (TUI)

13,595 stars TypeScript TypeScript 45 files · ~126,446 tokens #opencode#opentui#tui
RAW Doc

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:

bash
npx skills add https://opentui.com --skill opentui

Add -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 with
npx 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.

bash
bun install

Run 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:

bash
bun run test
bun run fmt:check
bun run lint

To 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 in
packages/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

text
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 | TextSlotRenderable

The 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

text
<module>: [<modifier> ...]<kind> <name><signature>
  • <module> is the npm specifier of an entry point, such as @opentui/core or @opentui/core/testing. Each subpath
    of a package's exports map with a types target is an entry point. A feature exported by two entry points has a
    line for each. The module of the C ABI is opentui.h.
  • <modifier> is deprecated (JSDoc @deprecated), static, protected, abstract, or readonly, in that order.
  • <kind> is class, interface, type, function, const, let, enum, enum-member, namespace,
    constructor, method, property, call, construct, index, or reexport. The C ABI also uses struct and
    field.
  • <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 is default.
  • <signature> is the declaration syntax after the name, from the published .d.ts files. Comments are removed,
    whitespace is collapsed, and import("…").X is written as X.

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", or
reexport * from "react". A renamed export from another package ends with as <original name>.

The C ABI

text
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 = -1

The 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 is field <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 from
packages/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> writes api/<version>.txt: the difference between the latest
    file's API and the API of the source tree. scripts/release.ts runs 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 diff prints the file that a release of the source tree would add.
  • bun packages/web/scripts/api.ts current prints every feature of the source tree.
  • bun packages/web/scripts/api.ts check validates 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.

typescript
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:

bash
bun add @opentui/core

Then 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:

bash
npx skills add https://opentui.com --skill opentui

Add -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 with
npx 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 KeymapHost interface 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-within layers that follow the active target's parent chain.
  • Layered bindings with priority ordering, newest-first ties, fallthrough, and preventDefault control.
  • Branch-aware multi-key sequences over flat compiled bindings, with a public pending-sequence API, synchronous pendingSequence events, active continuation queries, and automatic invalidation on focus changes.
  • Programmable exact-vs-prefix disambiguation (e.g. g vs gg) with runExact, continueSequence, clear, and deferred AbortSignal + sleep decisions. 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 via require(...) and activeWhen(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 input stop() 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-aware dispatchCommand.
  • 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, resolving mod+... from host metadata while preserving display strings.

Addons

@opentui/keymap/addons ships ready-made building blocks:

  • registerDefaultKeys — ctrl+shift+s style parser and event matching.
  • registerLeader, registerTimedLeader — leader tokens with optional timeout.
  • registerEmacsBindings — ctrl+x ctrl+s chords.
  • registerExCommands — :write-style commands with aliases and nargs.
  • 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

tsx
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.

ts
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

bash
bun install @opentui/keymap

Development

bash
bun run build
bun run test
bun src/keymap-benchmark.ts

The 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.zig is the public Zig module. It exports the
    checked Context API and raw Zig primitives, including CliRenderer,
    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.h defines the checked ot_* 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.ts supplies TypeScript wrappers over that
    checked ABI. Its checked signatures, callbacks, constants, and record layouts come
    from native-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:

sh
bun run generate:abi
bun run check:abi
bun run test:abi

scripts/native-abi.ts uses Zig Translate-C and
scripts/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 portable
buffer/ptr policy live in
scripts/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. Use
bun 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 under
lib/<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.

sh
zig build test-abi --summary all

This 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.

ts
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:

tsx
import { registerQRCode } from "@opentui/qrcode/react"

registerQRCode()

Solid JSX support is registered explicitly:

tsx
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:

bash
bun create tui --template react

Manual installation:

bash
bun install @opentui/react @opentui/core react

Quick Start

tsx
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:

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:

tsx
// 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.

tsx
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: A CliRenderer instance (typically created with createCliRenderer())

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/react does not export render(). Use createRoot(renderer).render(node) instead.

Hooks

`useRenderer()`

Access the OpenTUI renderer instance.

tsx
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.

tsx
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 a KeyEvent object
  • options?: 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:

tsx
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).

tsx
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 a PasteEvent object with bytes: Uint8Array (decode with decodePasteBytes from @opentui/core)

`useFocus(handler)`

Subscribe to terminal window focus events. The handler runs when the terminal window gains focus.

tsx
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.

tsx
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.

tsx
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 a Selection object with methods like getSelectedText()

`useOnResize(callback)`

Handle terminal resize events.

tsx
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.

tsx
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.

tsx
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?: Optional TimelineOptions object 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 completes
    • onPause?: Callback when the timeline pauses

Returns: A Timeline instance with methods:

  • add(target, properties, startTime): Add animation to timeline
  • play(): Start the timeline
  • pause(): Pause the timeline
  • restart(): Restart the timeline from beginning

Components

Layout & Display Components

Text Component

Display text with rich formatting.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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

tsx
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.

tsx
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.

tsx
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.

text
/* 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

text
/* Detailed source-code truncated for AI context efficiency. */

Counter with Timer

tsx
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

text
/* Detailed source-code truncated for AI context efficiency. */

Styled Text Showcase

tsx
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:

text
/* 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:

  1. Install the optional peer dependency:
bash
bun add --dev react-devtools-core@7
  1. Start the standalone React DevTools:
bash
npx react-devtools@7
  1. Run your app with the DEV environment variable:
bash
DEV=true bun run your-app.ts

After 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

bash
bun install solid-js @opentui/solid

Usage

  1. Add jsx config to tsconfig.json:
json
{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "@opentui/solid"
  }
}
  1. Add preload script to bunfig.toml:
toml
preload = ["@opentui/solid/preload"]
  1. Add render function to index.tsx:
tsx
import { render } from "@opentui/solid"

render(() => <text>Hello, World!</text>)
  1. Run with bun index.tsx.

  2. To build use Bun.build (source):

ts
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.

tsx
import { render } from "@opentui/solid"

render(() => <App />)

Parameters:

  • node: Function returning a JSX element.
  • rendererOrConfig?: CliRenderer instance or CliRendererConfig.

`testRender(node, options?)`

Create a test renderer for snapshots and interaction tests.

tsx
import { testRender } from "@opentui/solid"

const testSetup = await testRender(() => <App />, { width: 40, height: 10 })

`extend(components)`

Register custom renderables as JSX intrinsic elements.

tsx
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.

tsx
import { Portal } from "@opentui/solid"
;<Portal mount={renderer.root}>
  <box border>Overlay</box>
</Portal>

`Dynamic`

Render arbitrary intrinsic elements or components dynamically.

tsx
import { Dynamic } from "@opentui/solid"
;<Dynamic component={isMultiline() ? "textarea" : "input"} />

Components

Layout & Display

  • text: styled text container
  • box: layout container with borders, padding, and flex settings
  • scrollbox: scrollable container
  • ascii_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 input
  • textarea: multi-line text input
  • select: list selection
  • tab_select: tab-based selection

Code & Diff

  • code: syntax-highlighted code blocks
  • line_number: line-numbered code display with diff/diagnostic helpers
  • diff: unified or split diff viewer

Text Modifiers

Use these only inside a text component:

  • span: inline styled text
  • strong/b: bold text
  • em/i: italic text
  • u: underline text
  • br: line break
  • a: link text with href

Overview (`packages/ssh/README.md`)

@opentui/ssh

Serve OpenTUI apps over SSH.

@opentui/ssh gives each incoming SSH shell its own OpenTUI
CliRenderer. 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.

ts
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)
text
ssh -p 2222 localhost

Install

sh
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().

ts
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 change

listen(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.

ts
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 on
session.renderer. Runnable versions of all three are in
examples/.

Imperative (`@opentui/core`)

ts
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. See
examples/react.tsx.

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.

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/react and @opentui/solid are not runtime dependencies of this
package. The framework examples use workspace dev dependencies only to show
the hand-off. Run the Solid example with
bun 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.

ts
// 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/ssh verifies public-key signatures itself, because ssh2 does not.
So publicKey: "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 to
session.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.

  1. Registration order is execution order. The first-registered middleware
    is the outermost link.
  2. 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 the finally block in try { return await next() } finally { ... } runs
    as teardown.
  3. Contributions are inferred. next({ tier: "free" }) widens
    session.context by { 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.
ts
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 and
context types come from the builder. To name and reuse one, type it as
Middleware, 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's
Session 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, so
onError stays the single error sink. A throwing handler is logged as a normal
disconnect and still goes to onError.

ts
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

ts
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:

ts
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 logging middleware (connect/disconnect/duration)
  • report an error → onError, the single error sink
ts
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.onClose are 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 throwing
    onResize or onClose, a throwing auth predicate, Session output failures,
    and connection-level and server-level ssh2 errors. It defaults to
    console.error. It reports errors. To react to a session, use middleware or
    onClose. To observe the lifecycle, use the logging middleware. A bind
    failure rejects listen() instead of going here. The logger ignores its own
    sink failures, so logging cannot affect a session.
  • idleTimeout closes 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 between 1ms and 24h.
  • maxTimeout closes a session after that absolute lifetime, even when the
    client keeps sending input. Durations must be between 1ms and 24h.
  • listen() binds, prints the startup banner (URL, host-key fingerprints,
    auth methods, allowlist fingerprints) to stdout unless startupBanner: 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.
text
@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

ts
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

ts
import { createTerminalCapabilities, setRendererCapabilities } from "@opentui/core/testing"

const capabilities = createTerminalCapabilities({ kitty_keyboard: true })
setRendererCapabilities(renderer, { notifications: true })

Mock Keyboard Input

ts
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:

ts
{ ctrl?: boolean; shift?: boolean; meta?: boolean }

Mock Mouse Input

ts
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.

ts
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.

ts
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 frames
  • stop() - Stop recording frames
  • clear() - Clear all recorded frames
  • isRecording - Check if currently recording
  • recordedFrames - Get array of recorded frames (returns a copy)

RecordedFrame

Each frame contains:

  • frame: string - The captured frame content
  • timestamp: number - Time in milliseconds since recording started
  • frameNumber: number - Sequential frame number (0-indexed)

Example

ts
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:

bash
bun run bench:native

# Include memory statistics
bun run bench:native --mem

# Or from packages/native:
bun run bench
bun run bench --mem

Adding New Benchmarks

To add a new benchmark:

  1. Create a new *_bench.zig file in the bench/ directory
  2. Import shared types from bench-utils.zig:
    zig
    const bench_utils = @import("../bench-utils.zig");
    const BenchResult = bench_utils.BenchResult;
    const MemStats = bench_utils.MemStats;
  3. Implement a pub fn run(allocator: std.mem.Allocator, show_mem: bool) ![]BenchResult function:
    • 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_mem flag indicates whether to include memory statistics
  4. Import it in bench.zig:
    zig
    const my_new_bench = @import("bench/my_new_bench.zig");
  5. Call it and print results in main():
    zig
    const 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:

bash
bun run test:native

It 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:

bash
bun run test:native:lifetime

That 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

  1. Create a new *_test.zig file in this directory
  2. Import it in ../test.zig:
    zig
    const new_tests = @import("tests/new_test.zig");
  3. Reference it in the test block at the end of ../test.zig, or Zig skips its tests:
    zig
    _ = new_tests;
  4. 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 the ink overrides.
  • 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, and fadeLeft set an edge fade from 0 to 1.
  • title and caption supply 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:

mdx
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:

bash
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.ts

The tool is also available as bun run recordings:record.

If the application uses another working directory, store the absolute tool path first:

bash
record="$PWD/scripts/recordings/bin/record"

Automatic Applications

Use --headless for an application that needs no input. Use timeout to set the duration.

bash
cd ~/src/tuiexperiments/some-headless-app
"$record" --headless --window-size 210x42 /tmp/my-recording.cast \
  -- timeout 16 bun src/app.ts

Use 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:

bash
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 rec

Make 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:

bash
cd ~/src/tuiexperiments/some-app
"$record" /tmp/my-recording.cast -- bun src/app.ts

Use 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:

bash
cp /tmp/my-recording.cast scripts/recordings/my-recording.cast
bun run recordings:build scripts/recordings/my-recording.cast

The 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:

bash
bun run recordings:build scripts/recordings/my-recording.story.config.json

Commit the config and the generated story. The repository ignores the cast.

Check The Story

Check these items before you ship the story:

  • Set atMs after 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 durationMs to one cycle for a deterministic animation. The player repeats the story.
  • Make sure that setup keys changed the recorded mode. Examine rows when 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:

text
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:

astro
<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 compiles
private-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 in
public/fonts/. /lab/font compares those files with the next compile in
public/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.

text
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::OpenTUIMono

Copy the next compile into packages/web/public/fonts/candidate/ as
OpenTUIMono-{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):

bash
# Run from this directory
bun update.ts

# Or from the project root
bun packages/core/src/lib/tree-sitter/assets/update.ts

For Application Developers (using custom paths):

bash
# 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 --help

Programmatic Usage:

typescript
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

  1. Downloads Language Parsers: Downloads .wasm files from GitHub releases
  2. Downloads Query Files: Downloads .scm highlight query files from repositories
  3. Combines Queries: For languages like TypeScript, combines multiple query files (JS base + TS extensions)
  4. 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:

json
{
  "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:

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

typescript
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:

  1. Update Configuration: Add the new parser to ../parsers-config.ts
  2. Run Update Script: Execute bun update.ts to download assets and regenerate imports
  3. 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

If you use Core, read Renderables after the quickstart.

Find a topic

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:

bash
curl -fsSL https://raw.githubusercontent.com/anomalyco/opentui/main/packages/examples/install.sh | sh

On 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:

bash
npx skills add https://opentui.com --skill opentui

Add -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 accepts
href 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 the NativeImage API 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:

bash
mkdir my-tui && cd my-tui
bun init -y
bun add @opentui/core

Create the app

Create index.ts:

typescript
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's
content property directly.

The renderer emits parsed keyboard events through keyInput. The handler changes counter.content, which schedules a
new frame.

Run it

bash
bun index.ts

Press 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


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

bash
bun add @opentui/keymap

The 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.

ts
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.

text terminal=keymap-active-keys
ctrl+s  file.save
q       app.quit

Register, 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:

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.

Next


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:

text frame=hello
┌──────────┐
│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:

text
┌─ 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:

text
┌─ 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:

text frame=ready
┌──────────┐
│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
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(), and clearHitGridScissorRects(): removed from the renderer and RenderContext. Native code adds a hit area for each visible node and clips it to overflow ancestors. Use renderer.hitTest(x, y) to query the hit grid.
  • dumpBuffers() and dumpOutputBuffer(): removed. Use test frame capture, renderer.currentRenderBuffer.withBuffers(), or a custom stdout. dumpHitGrid() remains.
  • renderer.useThread and the useThread option of createCliRenderer() and createTestRenderer(): removed. Core writes output on the JavaScript thread. Remove the option.
  • renderer.rendererPtr: removed, with no replacement.
  • OTUI_NO_NATIVE_RENDER environment variable: removed. Use createTestRenderer().
  • suspend() and resume(): now return a Promise<void>. Await them. They throw when the terminal is not set up (also after destroy()) or a transition is pending. resume() also throws when the renderer is not suspended.
  • useMouse, enableKittyKeyboard(), and disableKittyKeyboard(): now throw during terminal setup, suspend, or resume, and while the renderer is suspended. screenMode now throws during setup, suspend, or resume. Change them after the transition completes.
  • enableKittyKeyboard(flags): now throws RangeError when flags is not an integer from 0 to 31.
  • Output stream error event: now closes the renderer and rejects renderer.closed. Handle the rejection of closed. 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(), and console.show() (also through console.toggle()) throw. getPalette() rejects. requestRender(), resize(), triggerNotification(), and getLinkAt() do nothing. Check renderer.isDestroyed first.
  • getPalette(): now rejects before the terminal is set up, for example on a createTestRenderer() renderer. It also rejects while a setup, suspend, or resume is pending. Its size must 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. Await renderer.closed before 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 rowColumns value that is not an integer from 0 to the snapshot width throws RangeError. In 0.5.14, Core truncated and clamped rowColumns.

Renderables and layout

  • RootRenderable.calculateLayout(): removed. Layout runs in the native frame step. Read getLayout() after renderOnce() or await renderer.idle().
  • Renderable.getLayoutNode() and the protected yogaNode: removed. Use layout properties, setMeasureProvider(), invalidateIntrinsicSize(), and getLayout(). Replace a style read such as getLayoutNode().getMaxWidth() with the renderable method of the same name, getMaxWidth(). Replace markDirty() with invalidateIntrinsicSize(), and the computed position and size getters with getLayout(). Computed padding, margin, and border have no replacement. Renderables cannot use Display.Contents. The standalone Yoga API remains.
  • render(), updateLayout(), updateFromLayout(), canReuseRenderCommandList(), and the RenderCommand type: removed. Override renderSelf(buffer, deltaTime), or use the renderBefore and renderAfter paint hooks. In a subclass of TextBufferRenderable or EditBufferRenderable, the override has one parameter: renderSelf(buffer).
  • Protected _x, _y, _widthValue, _heightValue, _childrenInZIndexOrder, _getVisibleChildren(), _hasVisibleChildFilter(), and getScissorRect(): removed. Use x, y, width, height, screenX, and screenY. For child culling, use the viewportCulling option of ScrollBox.
  • Protected renderCursor() of edit buffer renderables: removed. Use the cursor options.
  • Protected updateViewportOffset(): TextRenderable no longer calls it when it scrolls or resizes. Native code applies the viewport.
  • Protected color fields of ASCIIFontRenderable: now hold RGBA values.
  • Custom RenderContext: must now implement nativeScene, requestAnimationFrame(), and cancelAnimationFrame().
  • Paint hooks (renderBefore, renderSelf, and renderAfter): now receive a recording buffer. buffers, withBuffers(), getRealCharBytes(), and getSpanLines() throw in a hook. Draw into a buffer that you own and compose it, or use a post-process function. A buffered: true custom renderable is different: its hooks receive its own frame buffer.
  • Changes made in a paint hook: a layout, position, visibility, child, or zIndex change takes effect in the next frame. Color and text changes paint in the same frame.
  • on("resized") and on("layout-changed"): now throw on a renderable that is not the root. These listeners never ran in 0.5.14. Use onSizeChange.
  • Geometry after destroy(): x, y, screenX, screenY, width, and height of a renderable that is not selectable return 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 requires options.owner. Pass renderer.nativeScene or a ResourceContext.
  • buffer.buffers: works only in a post-process function. Use buffer.withBuffers(callback) elsewhere.
  • Drawing into renderer.nextRenderBuffer: works only in a post-process function. renderer.currentRenderBuffer does not accept drawing. In 0.5.14, frame callbacks could draw into nextRenderBuffer, for example with ThreeCliRenderer.drawScene(). Draw into a FrameBufferRenderable instead.
  • encodeUnicode(text): now returns { data } and never null. Its char values are opaque tokens for drawChar() on a buffer with the same owner. A tab encodes as two one-cell spaces.
  • buffer.ptr and getNativeId(): removed. Keep the buffer object. buffer.id is only a label.
  • Drawing methods: now throw RangeError for fractional, NaN, or infinite coordinates and sizes. Round them first.
  • drawText() and box titles: now throw RangeError for 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(), and drawText(): now reject attribute bits 8 to 31. Pass only text attributes. Use the styled-text link() helper for hyperlinks.

Text and editing

  • SyntaxStyle.create(), fromStyles(styles), and fromTheme(theme): now create(owner), fromStyles(styles, owner), and fromTheme(theme, owner). Pass renderer.nativeScene, or a ResourceContext for standalone use.
  • TextBuffer.create(widthMethod) and EditBuffer.create(widthMethod): now create(widthMethod, owner).
  • .ptr on TextBuffer, TextBufferView, EditBuffer, EditorView, and SyntaxStyle: removed. Their constructors take Context handles. Use the create() factories.
  • EditBuffer.setText(), insertText(), and replaceText(), the same Textarea and Input methods, Textarea initialValue, Input.value, and the placeholder of both: now show control characters other than tab, carriage return, and line feed as notation, such as ^L or <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 priority above 255 or hlRef above 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 SyntaxStyle style getters: now return copies. Change a color through its setter. ASCIIFont.color returns an RGBA.

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.
  • Clipboard class: removed. Use renderer.copyToClipboardOSC52() and clearClipboardOSC52(), or createClipboard() with createHostClipboard() and createRendererClipboardAdapter(renderer).
  • Native resources: each resource now belongs to one owner Context. Resources from different owners cannot be used together. Drawing a NativeImage into a renderer copies it into the renderer's Context.
  • Images that ImageRenderable loads: belong to the renderer's Context and throw after renderer.destroy(). To keep an image, load it yourself, for example with NativeImage.load(), and pass it as source.
  • getAllocatorStats(): does not count Context memory. getArenaAllocatedBytes() always returns 0. Use process.memoryUsage().rss for total memory.
  • Handle types RendererHandle, OptimizedBufferHandle, TextBufferHandle, TextBufferViewHandle, EditBufferHandle, EditorViewHandle, SyntaxStyleHandle, EventSinkHandle, NativeRenderableHandle, EmbeddedTerminalHandle, NativeMeasureTargetHandle, NativeMeasureTargetKind, NativeRenderOperationResult, and NativeRendererCreateOptions: removed. Use the Context*Handle types, SessionHandle, and SceneNodeHandle. ImageHandle is now an alias of ContextImageHandle.
  • Pointer-based RenderLib methods, such as createRenderer, createOptimizedBuffer, bufferDrawText, setUseThread, and nativeRenderableSetMeasureTarget: removed. Use the public classes. Standalone Yoga nodes 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, throw YogaError. Measure and dirtied functions must return synchronously and must not change Yoga nodes. See Yoga API.

SSH

  • @opentui/ssh session.write(): can now throw OutputPressureError or RangeError. cols and rows hold the requested size until the renderer attaches. onClose runs at logical close, before the renderer finishes closing.

Native libraries

  • Legacy native exports, such as createRenderer and bufferDrawText: removed. Use the ot_* C ABI in opentui.h. See C.
  • Zig GraphemePool.alloc(), GraphemePool.allocUnowned(), and LinkPool.alloc(): replaced by acquire(). It copies the bytes and returns an ID that holds one reference. Call decref() to release it. alloc() returned an ID with no references, and allocUnowned() did not copy.
  • Zig GraphemePool.freeUnreferenced() and the global pools (initGlobalPool(), initGlobalPoolWithOptions(), deinitGlobalPool(), initGlobalLinkPool(), and deinitGlobalLinkPool()): removed. Use explicit pools or opentui.Context. See Zig.
  • Zig UnifiedTextBuffer.setStyledText() and StyledChunk: removed. Use replaceOwnedStyledText() with OwnedStyledChunk. clear() and reset() can now return errors.
  • Zig CliRenderer.dumpBuffers() and dumpOutputBuffer(): 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

tsx
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:

tsx
// 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

  1. Declare types with module augmentation: TypeScript then completes and checks the props of each custom element.

  2. Call requestRender(): Call requestRender() in each property setter that changes what the component draws.

  3. Extend from appropriate base classes: Use BoxRenderable for containers, TextRenderable for 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:

sh
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.json

Default 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.

typescript
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:

typescript
import {
  Timeline,
  createTimeline,
  getTimelineEngine,
  engine,
  type AnimationOptions,
  type EasingFunctions,
  type JSAnimation,
  type TimelineOptions,
} from "@opentui/core"

Framework hooks use their framework package roots:

tsx
import { useTimeline } from "@opentui/react"
tsx
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:

  • linear
  • inQuad
  • outQuad
  • inOutQuad
  • inExpo
  • outExpo
  • inOutSine
  • outBounce
  • outElastic
  • inBounce
  • inCirc
  • outCirc
  • inOutCirc
  • inBack
  • outBack
  • inOutBack

The API does not accept a custom easing function through ease.

Schedule items

Use numeric start times in milliseconds.

typescript
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:

  1. Call timeline.pause().
  2. Call engine.unregister(timeline).
  3. Call engine.detach() only when this owner explicitly controls the whole engine.
  4. 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.

typescript
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 {}. Pass
startOptions: { 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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.
  • channels is 1 or 2. sampleRate is an integer from 8000 through 192000 Hz. 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 ReadableStream producer 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:

typescript
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().

typescript
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, or 429
  • HTTP status values from 500 through 599
  • 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:

typescript
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:

typescript
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:

  • fetch
  • response
  • source
  • demuxer
  • create
  • write
  • end
  • restart
  • stats
  • decoder
  • destroy
  • setVolume
  • setPan
  • setGroup

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.

typescript
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:

typescript
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.

typescript
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


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:

bash
bun create tui --template react

Or add the packages to an existing project:

bash
bun install @opentui/react @opentui/core react

Quick start

tsx
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:

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:

ts
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().

tsx
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.

tsx
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.

tsx
import { useKeyboard, useRenderer } from "@opentui/react"

function App() {
  const renderer = useRenderer()

  useKeyboard((key) => {
    if (key.name === "escape") {
      renderer.destroy()
    }
  })

  return <text>Press ESC to close</text>
}

To handle release events:

tsx
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.

tsx
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.

tsx
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).

tsx
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.

tsx
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.

tsx
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.

tsx
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 unless
autoplay is false. React pauses and unregisters the same instance during effect cleanup.

tsx
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:

tsx
// 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 around
createTestRenderer(). It mounts the initial node with React act() and returns the Core
TestRendererSetup. Renderer destruction unmounts the React root with act().

tsx
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.

text
/* Detailed source-code truncated for AI context efficiency. */

Component extension

Register custom renderables as JSX elements:

tsx
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 to
null. Read Custom renderables to draw custom content and to
compare React and Solid construction.

React DevTools

Use React DevTools to inspect the React tree:

  1. Install:
bash
bun add --dev react-devtools-core@7
  1. Start DevTools:
bash
npx react-devtools@7
  1. Run with the DEV flag:
bash
DEV=true bun run your-app.ts

Next


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

bash
bun install solid-js @opentui/solid

Setup

1. Configure TypeScript

Add JSX config to tsconfig.json:

json
{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "@opentui/solid"
  }
}

2. Configure Bun

Add preload script to bunfig.toml:

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:

js
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

tsx
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:

ts
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.

tsx
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: Optional CliRenderer instance or CliRendererConfig.

`testRender(node, options?)`

Create a test renderer for frame and interaction tests.

tsx
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.

tsx
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.

tsx
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:

tsx
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.

tsx
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.

tsx
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:

tsx
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.

tsx
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.

tsx
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.

tsx
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).

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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

tsx
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


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

typescript
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:

typescript
// 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:

text terminal=ascii-font-tiny
█▀█ █▀█ █▀▀ █▄ █
█▄█ █▀▀ ██▄ █ ▀█

The block font draws the same text in six rows:

text terminal=ascii-font-block
██████╗  ██████╗  ███████╗ ███╗   ██╗
██╔═══██╗ ██╔══██╗ ██╔════╝ ████╗  ██║
██║   ██║ ██████╔╝ █████╗   ██╔██╗ ██║
██║   ██║ ██╔═══╝  ██╔══╝   ██║╚██╗██║
╚██████╔╝ ██║      ███████╗ ██║ ╚████║
 ╚═════╝  ╚═╝      ╚══════╝ ╚═╝  ╚═══╝

Positioning

ASCIIFont inherits the standard layout positioning options. To position it at coordinates relative to its parent, use absolute positioning with left and top:

typescript
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

typescript
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:

typescript
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:

typescript
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)

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

typescript
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

typescript
// 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: ┏━┓┃┗━┛
text terminal=box-borders
┌─────single─────┐  ╔═════double═════╗
│ single line    │  ║ double lines   ║
└────────────────┘  ╚════════════════╝

╭────rounded─────╮  ┏━━━━━heavy━━━━━━┓
│ round corners  │  ┃ heavy strokes  ┃
╰────────────────╯  ┗━━━━━━━━━━━━━━━━┛

Titles

Add a title and bottom title to the box border:

typescript
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)

typescript
{
  titleAlignment: "left"
} // ┌─ Title ────────┐
{
  titleAlignment: "center"
} // ┌──── Title ─────┐
{
  titleAlignment: "right"
} // ┌────────── Title ┐

{
  bottomTitleAlignment: "left"
} // └─ Title ────────┘
{
  bottomTitleAlignment: "center"
} // └──── Title ─────┘
{
  bottomTitleAlignment: "right"
} // └────────── Title ┘
text terminal=box-title-alignment
┌───────────settings───────────┐
│ Top and bottom titles        │
└────────────────────────close─┘

Layout container

Box works as a flex container for child elements:

typescript
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:

typescript
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

typescript
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)

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

typescript
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)
text terminal=code-highlighted
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.

typescript
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:

typescript
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:

typescript
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, Code shows nothing until each highlight completes.
  • With streaming, Code shows 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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,
)

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

typescript
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:

text terminal=diff-unified
1   setup()
 2 - const a = 1
 2 + const a = 2
 3   ready(a)

Split view:

text terminal=diff-split
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:

typescript
const diff = new DiffRenderable(renderer, {
  id: "diff",
  view: "split",
  syncScroll: true,
  diff: patch,
  syntaxStyle,
})

// Toggle at runtime
diff.syncScroll = false

Scroll 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.

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.

text
╔══ 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

typescript
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:

text terminal=embedded-terminal-vt
$ bun test
✓ parser accepts UTF-8
✓ renderer draws wide cells
2 passed, 0 failed

screen() 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.

typescript
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

typescript
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.

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

typescript
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:

typescript
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:

text terminal=frame-buffer-draw
network throughput
rx  ▂▄▆█▇▅▃▂  42 MB/s
tx  ▃▅▇█▆▄▂▁  18 MB/s
    8 seconds     now

setCellWithAlphaBlending

Set a cell with alpha blending for transparency effects:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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.

typescript
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

typescript
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

typescript
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:

text terminal=frame-buffer-progress
Downloading package
██████████████░░░░░░  70%
14 of 20 files

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

typescript
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.loadPromise

source 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

tsx
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

tsx
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:

text terminal=image-blocks
████████████████████████
██████████████████▘██▜██
█████▛▀▝▀█████████▖██▟██
██▛▀██████▝▀█▛▀▘██▀▀████
▀███████████▗▄▙▄██████▀▀
████████▗▄▟███████▄▄████
▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄
████████████████████████

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.

typescript
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.

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

typescript
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:

text terminal=input-placeholder surface
Name
Enter your name

Focus states

The input uses its focused colors while it has focus:

typescript
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:

text terminal=input-focused surface
Name
Ada Lovelace

Events

Input event

Emitted after these operations:

  • Typing, paste, or insertText() inserts text.
  • A delete key, a delete method such as deleteCharBackward(), undo(), or redo() 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:

typescript
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:

typescript
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:

typescript
input.on(InputRenderableEvents.ENTER, (value: string) => {
  console.log("Submitted value:", value)
})

Getting the current value

typescript
const currentValue = input.value

Setting the value

typescript
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.

typescript
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:

typescript
const inputs = [usernameInput, passwordInput]
let focusIndex = 0

renderer.keyInput.on("keypress", (key) => {
  if (key.name === "tab") {
    focusIndex = (focusIndex + 1) % inputs.length
    inputs[focusIndex].focus()
  }
})

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

typescript
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)
text terminal=line-number-editor surface
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:

typescript
// 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:

typescript
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:

typescript
const lineNumbers = new LineNumberRenderable(renderer, { target: code })
lineNumbers.showLineNumbers = false

Properties

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

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

typescript
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 -> javascriptreact
  • TSX title=Button.tsx -> typescriptreact
  • Dockerfile -> dockerfile

Markdown uses infoStringToFiletype() for this step. To add or change mappings at runtime, edit the maps that it reads:

typescript
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:

typescript
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.

typescript
const markdown = new MarkdownRenderable(renderer, {
  content: "",
  syntaxStyle,
  streaming: true,
})

markdown.content += "# Live log\n"
markdown.content += "- line 1\n"
markdown.streaming = false

Stable 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.

typescript
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.

typescript
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 to borders: true and widthMode: "full". This is the normal table rendering.
  • "columns": borderless columns with a 2-column gap. Defaults to borders: false and widthMode: "content". Use it for append-only output where a full-width grid is too heavy.

This Name/Status sample shows both presets:

Grid:

text terminal=markdown-table-grid
┌────────┬────────┐
│ Name   │ Status │
├────────┼────────┤
│ api    │ ready  │
├────────┼────────┤
│ worker │ paused │
└────────┴────────┘

Columns:

text terminal=markdown-table-columns
Name    Status
api     ready
worker  paused

If 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:

typescript
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.

typescript
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.

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:

bash
bun add @opentui/qrcode

Basic usage

Renderable API

typescript
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

tsx
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

tsx
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.

typescript
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:

text terminal=qr-code-version-one
█▀▀▀▀▀█ ▀█▄▀█ █▀▀▀▀▀█
    █ ███ █ ▀ ▀▀█ █ ███ █
    █ ▀▀▀ █ ▀ ▀█  █ ▀▀▀ █
    ▀▀▀▀▀▀▀ █ ▀▄█ ▀▀▀▀▀▀▀
    ▄▄ ▄ ▀▀▄ █ ▄▀▄▀▄█▄█▄█
    ▄█▀▀█▄▀ █ ▀▀▀▄▄█ ▀█▄
     ▀  ▀ ▀▀█  ██▄ ▀▀ ▀ ▀
    █▀▀▀▀▀█ ▄▄▀ ▀▀▄ ▀█▄█▀
    █ ███ █  ▀███▀█ ▀▄▀
    █ ▀▀▀ █  █ ▀▀▀▀▀█▄▀▀█
    ▀▀▀▀▀▀▀  ▀ ▀   ▀ ▀▀

Fallback content

When a container is too small to render even a scale-1 QR code, show fallback text instead of an empty area:

typescript
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:

typescript
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

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

typescript
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()
text terminal=scrollbar-arrows
position: 0 / 180  ▲
                   ▀







viewport: 20 / 200 ▼

Keyboard controls

When focused, the scrollbar responds to:

  • Up/Down or k/j for vertical bars
  • Left/Right or h/l for horizontal bars
  • PageUp/PageDown to move by half the viewport
  • Home/End to 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

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

typescript
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:

text terminal=scrollbox-top
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.

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
// Scroll to top
scrollbox.scrollTo(0)

// Scroll to specific position
scrollbox.scrollTo({ x: 0, y: 100 })

The same list after scrollTo(5):

text terminal=scrollbox-scrolled
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.

typescript
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:

typescript
scrollbox.wrapper // BoxRenderable - outer wrapper
scrollbox.viewport // BoxRenderable - visible area
scrollbox.content // ContentRenderable - holds children
scrollbox.horizontalScrollBar // ScrollBarRenderable
scrollbox.verticalScrollBar // ScrollBarRenderable

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

typescript
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:

text terminal=select-options surface
New file
   Create a document
 ▶ Open file
   Browse existing files
   Save
   Write current changes

Keyboard 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:

typescript
import { SelectRenderableEvents, type SelectOption } from "@opentui/core"

menu.on