docs: update OpenTUI skill (#1290)

This commit is contained in:
Sebastian
2026-07-27 23:55:19 +02:00
committed by GitHub
parent 34e78b2fbf
commit 6064dcdc29
40 changed files with 2364 additions and 364 deletions
+6 -2
View File
@@ -22,7 +22,11 @@ This monorepo contains the following packages:
- [`@opentui/three`](packages/three) - Three.js WebGPU renderer for OpenTUI.
- [`@opentui/solid`](packages/solid) - The SolidJS reconciler for OpenTUI.
- [`@opentui/react`](packages/react) - The React reconciler for OpenTUI.
- [`@opentui/keymap`](packages/keymap) - Shared command, keybinding, and sequence engine.
- [`@opentui/qrcode`](packages/qrcode) - QR encoder and terminal renderable integrations.
- [`@opentui/ssh`](packages/ssh) - Serve imperative, React, and Solid OpenTUI applications over SSH.
- [`@opentui/examples`](packages/examples) - Example browser and standalone examples executable build.
- [`@opentui/web`](packages/web) - Private documentation website and installable AI agent skill source.
## Install
@@ -84,8 +88,8 @@ See the [Development Guide](packages/core/docs/development.md) for building, tes
- [Website docs](https://opentui.com/docs/getting-started) - Guides and API references
- [Development Guide](packages/core/docs/development.md) - Building, testing, and local dev linking
- [Getting Started](packages/core/docs/getting-started.md) - API and usage guide
- [Environment Variables](packages/core/docs/env-vars.md) - Configuration options
- [Getting Started](https://opentui.com/docs/getting-started) - API and usage guide
- [Environment Variables](https://opentui.com/docs/reference/env-vars) - Configuration options
## Showcase
+4 -4
View File
@@ -4,11 +4,11 @@ OpenTUI is a native terminal UI core written in Zig with TypeScript bindings. Th
## Documentation
- [Getting Started](docs/getting-started.md) - API and usage guide
- [Getting Started](https://opentui.com/docs/getting-started) - API and usage guide
- [Development Guide](docs/development.md) - Building, testing, and contributing
- [Tree-Sitter](docs/tree-sitter.md) - Syntax highlighting integration
- [Renderables vs Constructs](docs/renderables-vs-constructs.md) - Understanding the component model
- [Environment Variables](docs/env-vars.md) - Configuration options
- [Tree-Sitter](https://opentui.com/docs/reference/tree-sitter) - Syntax highlighting integration
- [Renderables vs Constructs](https://opentui.com/docs/core-concepts/renderables-vs-constructs) - Understanding the component model
- [Environment Variables](https://opentui.com/docs/reference/env-vars) - Configuration options
## Install
+1 -13
View File
@@ -6,26 +6,14 @@
* Usage:
* bun dev/print-env-vars.ts # Colored output (default)
* bun dev/print-env-vars.ts --markdown # Markdown output
* bun dev/print-env-vars.ts --update # Update docs/env-vars.md
*/
import { generateEnvColored, generateEnvMarkdown } from "../src/index.js"
import { join } from "path"
const args = process.argv.slice(2)
const useMarkdown = args.includes("--markdown")
const updateDocs = args.includes("--update")
const generateMarkdownContent = () => {
return `# Environment Variables\n\n${generateEnvMarkdown()}---\n\n_generated via packages/core/dev/print-env-vars.ts_\n`
}
if (updateDocs) {
const docsPath = join(import.meta.dir, "../docs/env-vars.md")
const content = generateMarkdownContent()
await Bun.write(docsPath, content)
console.log(`✓ Updated ${docsPath}`)
} else if (useMarkdown) {
if (useMarkdown) {
console.log(`${generateEnvMarkdown()}\n---\n_generated via packages/core/dev/print-env-vars.ts_`)
} else {
console.log(generateEnvColored())
+1 -1
View File
@@ -90,7 +90,7 @@ The script automatically links:
## Debugging
OpenTUI captures `console.log` output. Toggle the built-in console with backtick or use [Environment Variables](./env-vars.md) for debugging.
OpenTUI captures `console.log` output. Toggle the built-in console with backtick or use [Environment Variables](https://opentui.com/docs/reference/env-vars) for debugging.
## Terminal Compatibility
+11
View File
@@ -119,6 +119,17 @@ describe("env registry", () => {
expect(env.TEST_DEFAULT).toBe("default_value")
})
test("should return undefined for optional env vars when not set", () => {
registerEnvVar({
name: "TEST_OPTIONAL",
description: "An optional test variable",
type: "string",
required: false,
})
expect(env.TEST_OPTIONAL).toBeUndefined()
})
test("should throw error for required env var not set", () => {
registerEnvVar({
name: "TEST_REQUIRED",
+13 -3
View File
@@ -35,6 +35,7 @@ export interface EnvVarConfig {
name: string
description: string
default?: string | boolean | number
required?: boolean
type?: "string" | "boolean" | "number"
}
@@ -46,7 +47,8 @@ export function registerEnvVar(config: EnvVarConfig): void {
if (
existing.description !== config.description ||
existing.type !== config.type ||
existing.default !== config.default
existing.default !== config.default ||
existing.required !== config.required
) {
throw new Error(
`Environment variable "${config.name}" is already registered with different configuration. ` +
@@ -63,13 +65,17 @@ function normalizeBoolean(value: string): boolean {
return ["true", "1", "on", "yes"].includes(lowerValue)
}
function parseEnvValue(config: EnvVarConfig): string | boolean | number {
function parseEnvValue(config: EnvVarConfig): string | boolean | number | undefined {
const envValue = process.env[config.name]
if (envValue === undefined && config.default !== undefined) {
return config.default
}
if (envValue === undefined && config.required === false) {
return undefined
}
if (envValue === undefined) {
throw new Error(`Required environment variable ${config.name} is not set. ${config.description}`)
}
@@ -90,7 +96,7 @@ function parseEnvValue(config: EnvVarConfig): string | boolean | number {
}
class EnvStore {
private parsedValues: Map<string, string | boolean | number> = new Map()
private parsedValues: Map<string, string | boolean | number | undefined> = new Map()
get(key: string): any {
if (this.parsedValues.has(key)) {
@@ -143,6 +149,8 @@ export function generateEnvMarkdown(): string {
if (config.default !== undefined) {
const defaultValue = typeof config.default === "string" ? `"${config.default}"` : String(config.default)
markdown += `**Default:** \`${defaultValue}\`\n`
} else if (config.required === false) {
markdown += "**Default:** *unset*\n"
} else {
markdown += "**Default:** *Required*\n"
}
@@ -170,6 +178,8 @@ export function generateEnvColored(): string {
if (config.default !== undefined) {
const defaultValue = typeof config.default === "string" ? `"${config.default}"` : String(config.default)
output += `\x1b[32mDefault:\x1b[0m \x1b[35m${defaultValue}\x1b[0m\n`
} else if (config.required === false) {
output += "\x1b[32mDefault:\x1b[0m \x1b[35munset\x1b[0m\n"
} else {
output += `\x1b[32mDefault:\x1b[0m \x1b[31mRequired\x1b[0m\n`
}
@@ -108,7 +108,7 @@ for (const parser of getParsers()) {
}
```
For more information about using Tree-Sitter in your application, see the [Tree-Sitter guide](../../../docs/tree-sitter.md).
For more information about using Tree-Sitter in your application, see the [Tree-Sitter guide](https://opentui.com/docs/reference/tree-sitter).
### For OpenTUI Core Developers
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test"
import { envRegistry, generateEnvMarkdown } from "./lib/env.js"
import "./zig.js"
test("native terminal environment registrations match native string semantics", () => {
for (const name of ["OPENTUI_FORCE_WCWIDTH", "OPENTUI_FORCE_UNICODE", "OPENTUI_GRAPHICS", "OPENTUI_FORCE_NOZWJ"]) {
expect(envRegistry[name]?.type).toBe("string")
expect(envRegistry[name]?.default).toBeUndefined()
expect(envRegistry[name]?.required).toBe(false)
}
const generated = generateEnvMarkdown()
expect(generated).toContain("## OPENTUI_FORCE_WCWIDTH")
expect(generated).toContain("**Default:** *unset*")
})
+12 -12
View File
@@ -138,27 +138,27 @@ registerEnvVar({
// Env vars used in terminal.zig
registerEnvVar({
name: "OPENTUI_FORCE_WCWIDTH",
description: "Use wcwidth for character width calculations",
type: "boolean",
default: false,
description: "Use wcwidth for character width calculations when the variable is present",
type: "string",
required: false,
})
registerEnvVar({
name: "OPENTUI_FORCE_UNICODE",
description: "Force Mode 2026 Unicode support in terminal capabilities",
type: "boolean",
default: false,
description: "Force Mode 2026 Unicode support when the variable is present",
type: "string",
required: false,
})
registerEnvVar({
name: "OPENTUI_GRAPHICS",
description: "Enable Kitty graphics protocol detection",
type: "boolean",
default: true,
description: "Override Kitty graphics detection with the exact value true, 1, false, or 0",
type: "string",
required: false,
})
registerEnvVar({
name: "OPENTUI_FORCE_NOZWJ",
description: "Use no_zwj width method (Unicode without ZWJ joining)",
type: "boolean",
default: false,
description: "Use no_zwj width mode when the variable is present",
type: "string",
required: false,
})
// Cursor & mouse pointer style mappings (avoid recreation on each call)
+2 -2
View File
@@ -179,9 +179,9 @@ function slugifyHeading(text: string): string {
.replace(/`([^`]*)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/<([^>]+)>/g, "$1")
.replace(/[*_~]/g, "")
.replace(/[*~]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/[^a-z0-9_\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
+18 -4
View File
@@ -34,6 +34,7 @@ async function main() {
addDuplicateKeyViolations(index.pages, (page) => page.url, "url", violations)
addDuplicateKeyViolations(index.pages, (page) => page.sourcePath, "sourcePath", violations)
addOrderCollisionViolations(index.pages, violations)
addOrderSequenceViolations(index.pages, violations)
if (violations.length > 0) {
console.error("Metadata validation failed:\n")
@@ -76,10 +77,6 @@ function addOrderCollisionViolations(pages: DocPage[], violations: string[]) {
const grouped = new Map<string, DocPage[]>()
for (const page of pages) {
if (page.order === undefined) {
continue
}
const key = `${page.section}:${page.order}`
grouped.set(key, [...(grouped.get(key) ?? []), page])
}
@@ -96,6 +93,23 @@ function addOrderCollisionViolations(pages: DocPage[], violations: string[]) {
}
}
function addOrderSequenceViolations(pages: DocPage[], violations: string[]) {
for (const section of Object.keys(DOC_SECTION_CONFIG)) {
const orders = pages
.filter((page) => page.section === section)
.map((page) => page.order)
.toSorted((left, right) => left - right)
for (const [index, order] of orders.entries()) {
const expected = index + 1
if (order !== expected) {
violations.push(`${section}: expected contiguous orders starting at 1; expected ${expected}, found ${order}`)
break
}
}
}
}
function findDuplicates(values: string[]): string[] {
const seen = new Set<string>()
const duplicates = new Set<string>()
+159 -1
View File
@@ -3,7 +3,7 @@
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { buildDocsIndex } from "../src/lib/docs-index"
import { buildDocsIndex, type DocsIndex } from "../src/lib/docs-index"
interface CodeLine {
lineNumber: number
@@ -24,6 +24,7 @@ interface Violation {
}
const REPO_ROOT = join(import.meta.dir, "../../..")
const SKILL_SOURCE_PATH = "packages/web/src/content/SKILL.md"
const SHELL_LANGUAGES = new Set(["bash", "console", "shell", "sh", "zsh"])
async function main() {
@@ -36,6 +37,9 @@ async function main() {
violations.push(...validateSkillDoc(page.sourcePath, content))
}
const skillContent = await readFile(join(REPO_ROOT, SKILL_SOURCE_PATH), "utf8")
violations.push(...validateSkillIndex(index, skillContent))
if (violations.length > 0) {
console.error("Skill doc validation failed:\n")
for (const violation of violations.sort(compareViolations)) {
@@ -51,6 +55,160 @@ async function main() {
}
}
function validateSkillIndex(index: DocsIndex, content: string): Violation[] {
const violations: Violation[] = []
const lines = content.replace(/\r\n/g, "\n").split("\n")
const readingOrder = getSectionLines(lines, "## Reading order by area")
const routing = getSectionLines(lines, "## Quick routing by intent")
const entries = getSectionLines(lines, "## Current skill entry pages")
if (!readingOrder || !routing || !entries) {
return [
{
rule: "skill-index-sections",
sourcePath: SKILL_SOURCE_PATH,
lineNumber: 1,
message: "expected Reading order by area, Quick routing by intent, and Current skill entry pages sections",
},
]
}
const readingCounts = new Map<string, number>()
for (const { lineNumber, text } of readingOrder) {
for (const match of text.matchAll(/`\/docs\/(.+?)`/g)) {
const slug = match[1]
readingCounts.set(slug, (readingCounts.get(slug) ?? 0) + 1)
const page = index.pagesBySlug[slug]
if (!page) {
violations.push({
rule: "skill-reading-target",
sourcePath: SKILL_SOURCE_PATH,
lineNumber,
message: `reading-order target /docs/${slug} does not exist`,
})
} else if (!page.skill.entry) {
violations.push({
rule: "skill-reading-entry",
sourcePath: SKILL_SOURCE_PATH,
lineNumber,
message: `reading-order target /docs/${slug} is not marked skill.entry`,
})
}
}
}
const routeCounts = new Map<string, number>()
for (const { lineNumber, text } of routing) {
const match = text.match(/^\|\s*(.*?)\s*\|\s*`docs\/(.+)\.mdx`\s*\|$/)
if (!match || match[1].includes("---")) continue
const intents = [...match[1].matchAll(/`([^`]+)`/g)].map((intent) => intent[1].trim().toLowerCase())
const slug = match[2]
const page = index.pagesBySlug[slug]
if (!page) {
violations.push({
rule: "skill-routing-target",
sourcePath: SKILL_SOURCE_PATH,
lineNumber,
message: `routing target docs/${slug}.mdx does not exist`,
})
continue
}
if (!page.skill.entry) {
violations.push({
rule: "skill-routing-entry",
sourcePath: SKILL_SOURCE_PATH,
lineNumber,
message: `routing target docs/${slug}.mdx is not marked skill.entry`,
})
}
routeCounts.set(slug, (routeCounts.get(slug) ?? 0) + 1)
if (!sameStringSet(intents, page.skill.intents)) {
violations.push({
rule: "skill-routing-intents",
sourcePath: SKILL_SOURCE_PATH,
lineNumber,
message: `routing intents for docs/${slug}.mdx must match metadata: ${page.skill.intents.join(", ")}`,
})
}
}
const entryCounts = new Map<string, number>()
for (const { lineNumber, text } of entries) {
const match = text.match(/^- `docs\/(.+)\.mdx`$/)
if (!match) continue
const slug = match[1]
entryCounts.set(slug, (entryCounts.get(slug) ?? 0) + 1)
const page = index.pagesBySlug[slug]
if (!page) {
violations.push({
rule: "skill-entry-target",
sourcePath: SKILL_SOURCE_PATH,
lineNumber,
message: `entry target docs/${slug}.mdx does not exist`,
})
} else if (!page.skill.entry) {
violations.push({
rule: "skill-entry-metadata",
sourcePath: SKILL_SOURCE_PATH,
lineNumber,
message: `docs/${slug}.mdx is listed as an entry but is not marked skill.entry`,
})
}
}
for (const page of index.skillEntryPages) {
const readingCount = readingCounts.get(page.slug) ?? 0
const routeCount = routeCounts.get(page.slug) ?? 0
const entryCount = entryCounts.get(page.slug) ?? 0
if (readingCount !== 1) {
violations.push({
rule: "skill-reading-coverage",
sourcePath: SKILL_SOURCE_PATH,
lineNumber: readingOrder[0]?.lineNumber ?? 1,
message: `${page.sourcePath} must appear exactly once in reading order; found ${readingCount}`,
})
}
if (routeCount !== 1) {
violations.push({
rule: "skill-routing-coverage",
sourcePath: SKILL_SOURCE_PATH,
lineNumber: routing[0]?.lineNumber ?? 1,
message: `${page.sourcePath} must appear exactly once in quick routing; found ${routeCount}`,
})
}
if (entryCount !== 1) {
violations.push({
rule: "skill-entry-coverage",
sourcePath: SKILL_SOURCE_PATH,
lineNumber: entries[0]?.lineNumber ?? 1,
message: `${page.sourcePath} must appear exactly once in the entry list; found ${entryCount}`,
})
}
}
return violations
}
function getSectionLines(lines: string[], heading: string): Array<{ lineNumber: number; text: string }> | undefined {
const start = lines.findIndex((line) => line.trim() === heading)
if (start === -1) return undefined
const section: Array<{ lineNumber: number; text: string }> = []
for (let index = start + 1; index < lines.length; index++) {
if (lines[index].startsWith("## ")) break
section.push({ lineNumber: index + 1, text: lines[index].trim() })
}
return section
}
function sameStringSet(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false
const values = new Set(left)
return values.size === left.length && right.every((value) => values.has(value))
}
function validateSkillDoc(sourcePath: string, content: string): Violation[] {
const violations: Violation[] = []
const pendingDisables: string[] = []
+105 -107
View File
@@ -7,34 +7,27 @@
*
* This script:
* 1. Extracts TypeScript/JavaScript code blocks from MDX files
* 2. Type-checks them against @opentui/core
* 3. Reports any type errors found
* 2. Type-checks complete examples against current workspace packages with the matching TypeScript/React/Solid profile
* 3. Reports diagnostics mapped to the examples
*/
import { cp, readFile, writeFile, mkdir, rm } from "node:fs/promises"
import { readFile, writeFile, mkdir, rm, symlink } from "node:fs/promises"
import { join } from "node:path"
import { existsSync } from "node:fs"
import { buildDocsIndex } from "../src/lib/docs-index"
const REPO_ROOT = join(import.meta.dir, "../../..")
const DOCS_DIR = join(import.meta.dir, "../src/content/docs")
const CORE_PACKAGE = join(import.meta.dir, "../../core")
const TEST_DIR = "/tmp/opentui-doc-verify"
const VENDORED_CORE_PACKAGE = join(TEST_DIR, "vendor-core")
interface PackageJson {
name?: string
version?: string
type?: string
main?: string
module?: string
types?: string
exports?: unknown
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
const WORKSPACE_PACKAGE_DIRS = ["core", "keymap", "qrcode", "react", "solid", "ssh", "three"] as const
const TYPESCRIPT_CLI = join(import.meta.dir, "../node_modules/typescript/bin/tsc")
const KNOWN_WORKSPACE_DIAGNOSTICS = [
/packages\/react\/src\/reconciler\/(?:host-config|reconciler)\.ts.*error TS2307: Cannot find module 'react-reconciler\/constants'/,
/packages\/solid\/(?:index\.ts|src\/scrollback\.ts).*error TS2345: Argument of type 'ContextProviderComponent/,
/packages\/three\/src\/physics\/RapierPhysicsAdapter\.ts.*error TS2614: Module .* has no exported member '(?:RigidBody|World)'/,
]
let checkedBlocks = 0
let skippedBlocks = 0
interface CodeBlock {
code: string
@@ -122,11 +115,6 @@ function wrapCodeForTypeCheck(code: string, blockIndex: number): string {
return ""
}
// Skip JSX - would need separate handling with tsx
if (hasJSX(code)) {
return ""
}
const { importStatements, bodyLines } = splitImportsAndBody(code)
const importedModules = importStatements.map(getImportModule).filter((value): value is string => Boolean(value))
@@ -135,7 +123,14 @@ function wrapCodeForTypeCheck(code: string, blockIndex: number): string {
return ""
}
if (importedModules.some((module) => !module.startsWith("@opentui/core"))) {
if (importedModules.some((module) => module.startsWith("."))) {
return ""
}
if (
hasJSX(code) &&
!importedModules.some((module) => module.startsWith("@opentui/react") || module.startsWith("@opentui/solid"))
) {
return ""
}
@@ -319,88 +314,47 @@ function collectDeclaredBindings(body: string): string[] {
bindings.add(match[1])
}
const parameterListPattern = /\(([^()]*)\)\s*(?:=>|\{)/g
while ((match = parameterListPattern.exec(body)) !== null) {
for (const parameter of match[1].split(",")) {
const name = parameter.trim().match(/^(?:\.\.\.)?([A-Za-z_$][\w$]*)/)
if (name) bindings.add(name[1])
}
}
const singleArrowParameterPattern = /\b([A-Za-z_$][\w$]*)\s*=>/g
while ((match = singleArrowParameterPattern.exec(body)) !== null) {
bindings.add(match[1])
}
return [...bindings]
}
// Setup the test environment
async function setupTestEnv(): Promise<boolean> {
if (existsSync(TEST_DIR)) {
await rm(TEST_DIR, { recursive: true })
}
await rm(TEST_DIR, { recursive: true, force: true })
await mkdir(TEST_DIR, { recursive: true })
if (!existsSync(CORE_PACKAGE)) {
console.error(`ERROR: ${CORE_PACKAGE} not found.`)
return false
}
const corePackageJsonPath = join(CORE_PACKAGE, "package.json")
const corePackageJson = JSON.parse(await readFile(corePackageJsonPath, "utf8")) as PackageJson
await mkdir(VENDORED_CORE_PACKAGE, { recursive: true })
await cp(join(CORE_PACKAGE, "src"), join(VENDORED_CORE_PACKAGE, "src"), { recursive: true })
await writeFile(
join(VENDORED_CORE_PACKAGE, "package.json"),
JSON.stringify(
{
name: corePackageJson.name ?? "@opentui/core",
version: corePackageJson.version ?? "0.0.0",
type: corePackageJson.type ?? "module",
main: corePackageJson.main ?? "src/index.ts",
module: corePackageJson.module ?? "src/index.ts",
types: corePackageJson.types ?? "src/index.ts",
exports: corePackageJson.exports,
dependencies: corePackageJson.dependencies,
optionalDependencies: corePackageJson.optionalDependencies,
peerDependencies: corePackageJson.peerDependencies,
},
null,
2,
),
)
// Create package.json for the verifier sandbox.
await writeFile(
join(TEST_DIR, "package.json"),
JSON.stringify({
name: "doc-verify",
type: "module",
dependencies: {
"@opentui/core": `file:${VENDORED_CORE_PACKAGE}`,
},
}),
)
// Create tsconfig.json
await writeFile(
join(TEST_DIR, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
target: "ESNext",
module: "ESNext",
moduleResolution: "bundler",
strict: true,
skipLibCheck: true,
esModuleInterop: true,
noEmit: true,
jsx: "preserve",
types: ["bun-types"],
},
include: ["*.ts", "*.tsx"],
}),
)
// Install dependencies
const install = Bun.spawnSync(["bun", "install", "--production"], {
cwd: TEST_DIR,
stdout: "pipe",
stderr: "pipe",
})
if (install.exitCode !== 0) {
console.error("Failed to install dependencies:", install.stderr.toString())
return false
const packageScope = join(TEST_DIR, "node_modules/@opentui")
await mkdir(packageScope, { recursive: true })
for (const packageDir of WORKSPACE_PACKAGE_DIRS) {
await symlink(join(REPO_ROOT, `packages/${packageDir}`), join(packageScope, packageDir), "dir")
}
await symlink(join(REPO_ROOT, "packages/react/node_modules/react"), join(TEST_DIR, "node_modules/react"), "dir")
await symlink(join(REPO_ROOT, "packages/solid/node_modules/solid-js"), join(TEST_DIR, "node_modules/solid-js"), "dir")
const typeScope = join(TEST_DIR, "node_modules/@types")
await mkdir(typeScope, { recursive: true })
await symlink(join(REPO_ROOT, "packages/core/node_modules/@types/bun"), join(typeScope, "bun"), "dir")
await symlink(join(REPO_ROOT, "packages/core/node_modules/@types/node"), join(typeScope, "node"), "dir")
await symlink(join(REPO_ROOT, "packages/react/node_modules/@types/react"), join(typeScope, "react"), "dir")
return true
}
@@ -411,14 +365,46 @@ async function typeCheckBlock(block: CodeBlock, blockIndex: number): Promise<Iss
const wrappedCode = wrapCodeForTypeCheck(block.code, blockIndex)
if (!wrappedCode) {
skippedBlocks++
return issues // Skip fragments that can't be checked
}
checkedBlocks++
const testFile = join(TEST_DIR, `example-${blockIndex}.ts`)
const jsx = hasJSX(block.code)
const extension = jsx ? "tsx" : block.language === "javascript" || block.language === "js" ? "js" : "ts"
const testFileName = `example-${blockIndex}.${extension}`
const testFile = join(TEST_DIR, testFileName)
await writeFile(testFile, wrappedCode)
// Run tsc on this specific file
const result = Bun.spawnSync(["bunx", "tsc", "--noEmit", "--skipLibCheck", testFile], {
const jsxImportSource = block.code.includes("@opentui/react")
? "@opentui/react"
: block.code.includes("@opentui/solid")
? "@opentui/solid"
: undefined
await writeFile(
join(TEST_DIR, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
target: "ESNext",
module: "NodeNext",
moduleResolution: "NodeNext",
moduleDetection: "force",
strict: true,
skipLibCheck: true,
esModuleInterop: true,
noEmit: true,
allowJs: extension === "js",
checkJs: extension === "js",
noImplicitAny: extension !== "js",
jsx: jsxImportSource === "@opentui/react" ? "react-jsx" : "preserve",
...(jsxImportSource ? { jsxImportSource } : {}),
types: ["bun", "node"],
},
files: [testFile],
}),
)
const result = Bun.spawnSync(["bun", TYPESCRIPT_CLI, "-p", join(TEST_DIR, "tsconfig.json")], {
cwd: TEST_DIR,
stdout: "pipe",
stderr: "pipe",
@@ -427,23 +413,29 @@ async function typeCheckBlock(block: CodeBlock, blockIndex: number): Promise<Iss
if (result.exitCode !== 0) {
const output = result.stdout.toString() + result.stderr.toString()
// Parse errors, filter out noise
const lines = output.split("\n")
for (const line of lines) {
// Match TypeScript errors like: example-0.ts(5,3): error TS2304: Cannot find name 'foo'.
const match = line.match(/example-\d+\.ts\(\d+,\d+\): error TS\d+: (.+)/)
const match = line.match(/example-\d+\.(?:ts|tsx|js)\(\d+,\d+\): error TS\d+: (.+)/)
if (match) {
const msg = match[1]
// Skip some noise errors
if (msg.includes("Cannot find module './assets/")) continue
if (msg.includes("@ts-expect-error")) continue
issues.push({
type: "error",
message: msg,
})
}
}
const diagnosticHeaders = lines.filter((line) => /\(\d+,\d+\): error TS\d+:/.test(line))
const onlyKnownWorkspaceDiagnostics =
diagnosticHeaders.length > 0 &&
diagnosticHeaders.every((line) => KNOWN_WORKSPACE_DIAGNOSTICS.some((pattern) => pattern.test(line)))
if (issues.length === 0 && !onlyKnownWorkspaceDiagnostics) {
issues.push({
type: "error",
message: `TypeScript failed without a mapped example diagnostic: ${output.trim()}`,
})
}
}
return issues
@@ -483,14 +475,17 @@ async function main() {
if (pages.length === 0) {
console.error("No matching docs found.")
process.exit(1)
process.exitCode = 1
return
}
// Setup test environment
console.log("Setting up test environment...")
const setupOk = await setupTestEnv()
if (!setupOk) {
process.exit(1)
await rm(TEST_DIR, { recursive: true, force: true })
process.exitCode = 1
return
}
console.log("Test environment ready.\n")
@@ -545,13 +540,15 @@ async function main() {
console.log(`\n${"=".repeat(60)}`)
console.log(`Summary:`)
console.log(` Files checked: ${pages.length}`)
console.log(` Code blocks checked: ${checkedBlocks}`)
console.log(` Code blocks skipped as fragments: ${skippedBlocks}`)
console.log(` Files with issues: ${filesWithIssues}`)
console.log(` Total errors: ${totalErrors}`)
// Cleanup
await rm(TEST_DIR, { recursive: true })
await rm(TEST_DIR, { recursive: true, force: true })
process.exit(totalErrors > 0 ? 1 : 0)
process.exitCode = totalErrors > 0 ? 1 : 0
}
function collectMatchedSourcePaths(pattern: string): Set<string> {
@@ -572,7 +569,8 @@ function normalizePath(path: string): string {
return path.replace(/\\/g, "/")
}
main().catch((err) => {
main().catch(async (err) => {
await rm(TEST_DIR, { recursive: true, force: true })
console.error("Error:", err)
process.exit(1)
process.exitCode = 1
})
+2 -2
View File
@@ -3,11 +3,11 @@ import { glob } from "astro/loaders"
import { z } from "astro/zod"
const docs = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/docs" }),
loader: glob({ pattern: "**/*.mdx", base: "./src/content/docs" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
order: z.number().int().nonnegative().optional(),
order: z.number().int().positive(),
navTitle: z.string().optional(),
skill: z
.object({
+32 -17
View File
@@ -1,6 +1,6 @@
---
name: opentui
description: Build terminal UIs with OpenTUI. Covers the core API, native audio, keymaps, React and Solid bindings, components, layout, keyboard input, plugins, and testing.
description: Build terminal UIs with OpenTUI. Covers core, components, audio, keymaps, React, Solid, plugins, testing, standalone executables, QR encoding, SSH, and Three.js WebGPU.
---
# OpenTUI Skill
@@ -27,25 +27,34 @@ Inside the OpenTUI repo, this skill root lives at `packages/web/src/content/`, s
- Layout: `/docs/core-concepts/layout`
- Keyboard: `/docs/core-concepts/keyboard`
- Plugins: `/docs/plugins/slots`
- Reference: `/docs/reference/env-vars`
- Runtime and packaging: `/docs/reference/env-vars`, `/docs/reference/standalone-executables`
- Package entrypoints: `/docs/reference/package-entrypoints`
- QR encoding: `/docs/reference/qr-encoder`
- SSH: `/docs/reference/ssh`
- Three.js WebGPU: `/docs/reference/three`
## Quick routing by intent
| Intent(s) | Start here |
| ---------------------------------------------------------- | --------------------------------- |
| `getting-started`, `installation`, `quickstart`, `intro` | `docs/getting-started.mdx` |
| `core`, `renderer`, `terminal`, `scrollback`, `lifecycle` | `docs/core-concepts/renderer.mdx` |
| `audio`, `native-audio`, `sound`, `playback`, `pcm`, `fft` | `docs/core-concepts/audio.mdx` |
| `keymap`, `keybindings`, `shortcuts`, `commands`, `leader` | `docs/keymap/overview.mdx` |
| `layout`, `flexbox`, `yoga`, `positioning` | `docs/core-concepts/layout.mdx` |
| `keyboard`, `input`, `keybindings`, `paste`, `focus` | `docs/core-concepts/keyboard.mdx` |
| `testing`, `test-renderer`, `snapshots`, `frames` | `docs/core-concepts/testing.mdx` |
| `react`, `jsx`, `hooks`, `animation`, `testing` | `docs/bindings/react.mdx` |
| `solid`, `signals`, `jsx`, `hooks`, `animation`, `testing` | `docs/bindings/solid.mdx` |
| `plugins`, `plugin`, `slots`, `registry`, `extensions` | `docs/plugins/slots.mdx` |
| `text`, `styling`, `content`, `selection` | `docs/components/text.mdx` |
| `input`, `form`, `editing`, `focus` | `docs/components/input.mdx` |
| `env`, `environment`, `configuration`, `flags` | `docs/reference/env-vars.mdx` |
| Intent(s) | Start here |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `getting-started`, `installation`, `quickstart`, `intro` | `docs/getting-started.mdx` |
| `core`, `renderer`, `terminal`, `scrollback`, `lifecycle` | `docs/core-concepts/renderer.mdx` |
| `audio`, `native-audio`, `sound`, `playback`, `streaming`, `radio`, `mp3`, `flac`, `pcm`, `fft` | `docs/core-concepts/audio.mdx` |
| `keymap`, `keybindings`, `shortcuts`, `commands`, `leader`, `ex-commands` | `docs/keymap/overview.mdx` |
| `layout`, `flexbox`, `yoga`, `positioning` | `docs/core-concepts/layout.mdx` |
| `keyboard`, `input`, `keybindings`, `paste`, `focus` | `docs/core-concepts/keyboard.mdx` |
| `testing`, `test-renderer`, `snapshots`, `frames` | `docs/core-concepts/testing.mdx` |
| `react`, `jsx`, `hooks`, `keyboard`, `paste`, `focus`, `blur`, `selection`, `animation`, `testing` | `docs/bindings/react.mdx` |
| `solid`, `jsx`, `signals`, `hooks`, `keyboard`, `animation`, `testing` | `docs/bindings/solid.mdx` |
| `plugins`, `plugin`, `slots`, `registry`, `extensions` | `docs/plugins/slots.mdx` |
| `text`, `styling`, `content`, `selection` | `docs/components/text.mdx` |
| `input`, `form`, `editing`, `focus` | `docs/components/input.mdx` |
| `env`, `environment`, `configuration`, `flags` | `docs/reference/env-vars.mdx` |
| `standalone`, `executable`, `bun-compile`, `node-sea`, `node-assets` | `docs/reference/standalone-executables.mdx` |
| `package-exports`, `entrypoints`, `subpath-exports`, `imports` | `docs/reference/package-entrypoints.mdx` |
| `qr`, `qrcode`, `qr-encoder`, `svg-qr`, `gs1`, `eci`, `structured-append` | `docs/reference/qr-encoder.mdx` |
| `ssh`, `remote-tui`, `ssh-server`, `authentication`, `middleware` | `docs/reference/ssh.mdx` |
| `three`, `threejs`, `webgpu`, `3d`, `sprites`, `physics` | `docs/reference/three.mdx` |
For concrete component requests, jump straight to `docs/components/<name>.mdx` after the relevant entry page. For plugin implementation details, narrow from `docs/plugins/slots.mdx` into `docs/plugins/core.mdx`, `docs/plugins/react.mdx`, or `docs/plugins/solid.mdx`.
@@ -54,6 +63,7 @@ For concrete component requests, jump straight to `docs/components/<name>.mdx` a
- `docs/getting-started.mdx`
- `docs/core-concepts/renderer.mdx`
- `docs/core-concepts/audio.mdx`
- `docs/core-concepts/testing.mdx`
- `docs/keymap/overview.mdx`
- `docs/core-concepts/layout.mdx`
- `docs/core-concepts/keyboard.mdx`
@@ -63,6 +73,11 @@ For concrete component requests, jump straight to `docs/components/<name>.mdx` a
- `docs/components/text.mdx`
- `docs/components/input.mdx`
- `docs/reference/env-vars.mdx`
- `docs/reference/standalone-executables.mdx`
- `docs/reference/package-entrypoints.mdx`
- `docs/reference/qr-encoder.mdx`
- `docs/reference/ssh.mdx`
- `docs/reference/three.mdx`
## Working rules
@@ -78,6 +78,7 @@ OpenTUI React provides JSX intrinsic elements that map to core renderables:
- `<box>` - Container with borders and layout
- `<scrollbox>` - Scrollable container
- `<ascii-font>` - ASCII art text
- [`<TimeToFirstDraw />`](/docs/components/time-to-first-draw) - Exported first-draw performance timestamp component
QR code support is available from `@opentui/qrcode/react` and must be registered explicitly with `registerQRCode()`.
@@ -116,6 +117,10 @@ Creates a React root for rendering into the terminal.
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return <text>Hello, React!</text>
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
```
@@ -338,6 +343,34 @@ Style components with props or the `style` prop:
</box>
```
## Testing
`@opentui/react/test-utils` exports `testRender(node, options)`, a React-aware wrapper around [`createTestRenderer()`](/docs/core-concepts/testing). It mounts the initial node with React `act()`, returns the core `TestRendererSetup`, and unmounts the React root with `act()` when the renderer is destroyed.
```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 has the same type/default behavior as `TestRendererOptions`. The helper also enables React's act-environment flag for the mounted test root. On renderer destruction it unmounts the root, invokes any `options.onDestroy` callback, and then sets that flag to `false`. It does not preserve a previous global value, and a throwing `onDestroy` callback prevents the final reset.
`testRender()` does not return the React root or a separate rerender function. Use the returned core setup for frame capture, waits, resize, keyboard/mouse input, native stats, and external output. Always destroy the renderer in test teardown so the React tree unmounts and the global act-environment flag is reset.
## Example: Login form
```tsx
@@ -117,6 +117,7 @@ OpenTUI Solid provides JSX intrinsic elements that map to core renderables.
- `<scrollbox>` - Scrollable container
- `<ascii_font>` - ASCII art text
- `<markdown>` - Render Markdown content
- [`<TimeToFirstDraw />`](/docs/components/time-to-first-draw) - Exported first-draw performance timestamp component
QR code support is available from `@opentui/qrcode/solid` and must be registered explicitly with `registerQRCode()`.
@@ -153,6 +154,8 @@ Render a Solid component tree into a CLI renderer.
```tsx
import { render } from "@opentui/solid"
const App = () => <text>Hello, Solid!</text>
// Simple usage
render(() => <App />)
@@ -175,6 +178,7 @@ Create a test renderer for snapshots and interaction tests.
```tsx
import { testRender } from "@opentui/solid"
const App = () => <text>Ready</text>
const testSetup = await testRender(() => <App />, { width: 40, height: 10 })
```
@@ -183,9 +187,10 @@ const testSetup = await testRender(() => <App />, { width: 40, height: 10 })
Register custom renderables as JSX intrinsic elements.
```tsx
import { BoxRenderable } from "@opentui/core"
import { extend } from "@opentui/solid"
extend({ custom_box: CustomBoxRenderable })
extend({ custom_box: BoxRenderable })
```
### `getComponentCatalogue()`
@@ -228,7 +233,7 @@ renderer.writeToScrollback(writer)
| `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` | - | Append a newline after the final row |
| `trailingNewline` | `boolean` | `true` | Append a newline after the final row |
The writer returns a `ScrollbackSnapshot` whose teardown disposes the inner Solid subtree after the snapshot renders. Use the core [`ScrollbackSurface`](/docs/core-concepts/renderer#writing-to-scrollback) APIs directly if you need streaming commits that re-render the same tree over time.
@@ -534,13 +539,13 @@ render(App)
## Differences from React bindings
| Aspect | Solid | React |
| ------------------ | ------------------------------------------------------ | -------------------------------------- |
| Render function | `render(() => <App />)` | `createRoot(renderer).render(<App />)` |
| Component naming | snake_case (`ascii_font`) | kebab-case (`ascii-font`) |
| State | `createSignal` | `useState` |
| Effects | `onMount`, `onCleanup` | `useEffect` |
| Resize hook | `onResize(callback)` | `useOnResize(callback)` |
| Dimensions | Returns signal: `dimensions().width` | Returns object: `dimensions.width` |
| Extra hooks | `onFocus`, `onBlur`, `usePaste`, `useSelectionHandler` | - |
| Special components | `Portal`, `Dynamic` | - |
| Aspect | Solid | React |
| --------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- |
| Render function | `render(() => <App />)` | `createRoot(renderer).render(<App />)` |
| Component naming | snake_case (`ascii_font`) | kebab-case (`ascii-font`) |
| State | `createSignal` | `useState` |
| Effects | `onMount`, `onCleanup` | `useEffect` |
| Resize hook | `onResize(callback)` | `useOnResize(callback)` |
| Dimensions | Returns signal: `dimensions().width` | Returns object: `dimensions.width` |
| Focus, blur, paste, and selection | `onFocus`, `onBlur`, `usePaste`, `useSelectionHandler` | `useFocus`, `useBlur`, `usePaste`, `useSelectionHandler` |
| Special components | `Portal`, `Dynamic` | - |
@@ -86,7 +86,7 @@ OpenTUI includes several ASCII art font styles:
## Positioning
Position the ASCII text anywhere on screen:
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, {
@@ -94,24 +94,28 @@ const title = new ASCIIFontRenderable(renderer, {
text: "TITLE",
font: "block",
color: RGBA.fromHex("#FFFF00"),
x: 10,
y: 2,
position: "absolute",
left: 10,
top: 2,
})
```
## Properties
| Property | Type | Default | Description |
| ----------------- | ---------------------------- | --------------- | -------------------------- |
| `text` | `string` | `""` | Text to display |
| `font` | `ASCIIFontName` | `"tiny"` | Font style to use |
| `color` | `ColorInput \| ColorInput[]` | `"#FFFFFF"` | Text color(s) |
| `backgroundColor` | `ColorInput` | `"transparent"` | Background color |
| `selectable` | `boolean` | `true` | Whether text is selectable |
| `selectionBg` | `ColorInput` | - | Selection background color |
| `selectionFg` | `ColorInput` | - | Selection foreground color |
| `x` | `number` | - | X position offset |
| `y` | `number` | - | Y position offset |
| 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
@@ -167,25 +171,27 @@ setInterval(() => {
## Color Effects
Create gradient-like effects by positioning multiple ASCII fonts:
Create a shadow by overlaying two ASCII fonts:
```typescript
import { Box, ASCIIFont } from "@opentui/core"
const gradientTitle = Box(
{},
ASCIIFont({
text: "HELLO",
font: "block",
color: "#FF0000",
}),
// Overlay with offset for shadow effect
const shadowTitle = Box(
{ position: "relative" },
ASCIIFont({
text: "HELLO",
font: "block",
color: "#880000",
position: "absolute",
left: 1,
top: 1,
zIndex: 0,
}),
ASCIIFont({
text: "HELLO",
font: "block",
color: "#FF0000",
zIndex: 1,
}),
)
```
@@ -149,11 +149,11 @@ code.content += "const x = 1\n"
code.content += "const y = 2\n"
```
Streaming mode optimizes highlighting for incremental updates.
Streaming mode changes intermediate rendering behavior during repeated updates. In particular, when `drawUnstyledText` is `false`, later updates keep the previous rendered buffer visible while a new one-shot highlight completes. Each highlight still processes the complete current `content`.
## Text selection
Enable text selection for copy operations:
Text selection is enabled by default. Set `selectable: false` to disable it. Use `selectionBg` and `selectionFg` to customize selection colors:
```typescript
const code = new CodeRenderable(renderer, {
@@ -223,15 +223,15 @@ 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 highlighting theme |
| `streaming` | `boolean` | `false` | Optimize for incremental content updates |
| `conceal` | `boolean` | `true` | Hide concealed syntax elements |
| `drawUnstyledText` | `boolean` | `true` | Show text before highlighting completes |
| `treeSitterClient` | `TreeSitterClient` | - | Custom Tree-sitter client instance |
| Property | Type | Default | Description |
| ------------------ | ------------------ | -------- | ------------------------------------------------------------------------------------- |
| `content` | `string` | `""` | Source code to display |
| `filetype` | `string` | - | Language for syntax highlighting |
| `syntaxStyle` | `SyntaxStyle` | required | Syntax highlighting theme |
| `streaming` | `boolean` | `false` | Preserve suitable intermediate rendering during repeated updates |
| `conceal` | `boolean` | `true` | Hide concealed syntax elements |
| `drawUnstyledText` | `boolean` | `true` | Show unstyled text while highlighting; streaming limits this to the initial highlight |
| `treeSitterClient` | `TreeSitterClient` | - | Custom Tree-sitter client instance |
### Inherited from TextBufferRenderable
@@ -239,7 +239,7 @@ renderer.root.add(scrollbox)
| -------------- | ------------------ | -------- | ------------------------------------------- |
| `fg` | `string \| RGBA` | - | Default foreground color |
| `bg` | `string \| RGBA` | - | Background color |
| `selectable` | `boolean` | `false` | Enable text selection |
| `selectable` | `boolean` | `true` | Whether text selection is enabled |
| `selectionBg` | `string \| RGBA` | - | Selection background color |
| `selectionFg` | `string \| RGBA` | - | Selection foreground color |
| `wrapMode` | `string` | `"word"` | Text wrapping: `"none"`, `"char"`, `"word"` |
@@ -247,15 +247,15 @@ renderer.root.add(scrollbox)
## Additional properties
| Property | Type | Description |
| ---------------- | --------- | -------------------------------------------- |
| `lineCount` | `number` | Number of lines in content |
| `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` | Raw text content |
| 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 may differ from `content` after concealment or `onChunks` transforms |
## Markdown styles
@@ -70,7 +70,7 @@ const input = new InputRenderable(renderer, {
### Input event
Fires on every keystroke as the value changes:
Emitted after text insertion and deletion operations, and when assigning a different `value`. The listener receives the current value:
```typescript
import { InputRenderableEvents } from "@opentui/core"
@@ -92,7 +92,7 @@ input.on(InputRenderableEvents.CHANGE, (value: string) => {
### Enter event
Fires when the user presses Enter/Return:
Emitted when an Enter/Return submit succeeds. No event is emitted when the current UTF-16 code-unit length is less than `minLength`:
```typescript
input.on(InputRenderableEvents.ENTER, (value: string) => {
@@ -114,17 +114,18 @@ input.value = "New value"
## Properties
| Property | Type | Default | Description |
| ------------------------ | -------------------------------------- | ------------ | ---------------------------- |
| `width` | `number` | - | Input field width |
| `value` | `string` | `""` | Initial text value |
| `placeholder` | `string` | `""` | Placeholder text when empty |
| `maxLength` | `number` | `1000` | Maximum number of characters |
| `backgroundColor` | `string \| RGBA` | - | Background when unfocused |
| `focusedBackgroundColor` | `string \| RGBA` | - | Background when focused |
| `textColor` | `string \| RGBA` | - | Text color |
| `cursorColor` | `string \| RGBA` | - | Cursor color |
| `position` | `"static" \| "relative" \| "absolute"` | `"relative"` | Positioning mode |
| Property | Type | Default | Description |
| ------------------------ | -------------------------------------- | --------------------------------------- | --------------------------------------------------- |
| `width` | number, `"auto"`, or percentage string | `"auto"` | Input field width |
| `value` | `string` | `""` | Initial text value |
| `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"` | Effective positioning mode |
## Example: Login form
@@ -180,30 +180,42 @@ const markdown = new MarkdownRenderable(renderer, {
Use `createMarkdownCodeBlockRenderer` when you only want to replace specific fenced-code languages. The language key is matched against the normalized fence info string, so a `tsx` fence maps to `typescriptreact`, and custom DSL names like `taskflow` can be matched directly.
````typescript
import { BoxRenderable, MarkdownRenderable, TextRenderable, createMarkdownCodeBlockRenderer } from "@opentui/core"
import {
BoxRenderable,
MarkdownRenderable,
SyntaxStyle,
TextRenderable,
createMarkdownCodeBlockRenderer,
type CliRenderer,
type MarkdownCodeBlockRenderer,
} from "@opentui/core"
const renderTaskFlow = (renderer) => (token) => {
const steps = token.text
.split("\n")
.filter((line) => line.startsWith("step "))
.map((line) => line.slice("step ".length))
const syntaxStyle = SyntaxStyle.fromStyles({ default: {} })
const card = new BoxRenderable(renderer, {
border: true,
borderStyle: "rounded",
borderColor: "#38BDF8",
paddingX: 1,
flexDirection: "column",
width: "100%",
})
const renderTaskFlow =
(renderer: CliRenderer): MarkdownCodeBlockRenderer =>
(token) => {
const steps = token.text
.split("\n")
.filter((line) => line.startsWith("step "))
.map((line) => line.slice("step ".length))
for (const step of steps) {
card.add(new TextRenderable(renderer, { content: `- ${step}`, width: "100%" }))
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
}
return card
}
const markdown = new MarkdownRenderable(renderer, {
content: "```taskflow\nstep Parse markdown done\nstep Render widget active\n```",
syntaxStyle,
@@ -8,6 +8,8 @@ order: 16
Display text or URLs as scannable standard QR Codes in the terminal. `QRCodeRenderable` renders QR Code Model 2 modules with half-block cells so the symbol stays square in terminal character geometry.
For module matrices, terminal strings, SVG, raw bytes, explicit segments, ECI, GS1/FNC1, and structured append, use the standalone [QR encoder](/docs/reference/qr-encoder) exported by the same package.
Install the QR code package separately:
```bash
@@ -52,13 +52,13 @@ const slider = new SliderRenderable(renderer, {
## Properties
| Property | Type | Default | Description |
| ----------------- | ------------------------------ | ------------ | ------------------------------ |
| `orientation` | `"vertical"` or `"horizontal"` | - | Slider direction |
| `value` | `number` | `min` | Current value |
| `min` | `number` | `0` | Minimum value |
| `max` | `number` | `100` | Maximum value |
| `viewPortSize` | `number` | range \* 0.1 | Thumb size relative to content |
| `backgroundColor` | `string` or `RGBA` | - | Track color |
| `foregroundColor` | `string` or `RGBA` | - | Thumb color |
| `onChange` | `(value: number) => void` | - | Fired when value changes |
| Property | Type | Default | Description |
| ----------------- | ---------------------------- | -------------------------------- | ---------------------------------------------- |
| `orientation` | `"vertical" \| "horizontal"` | required | Slider direction |
| `value` | `number` | `min` | Current value |
| `min` | `number` | `0` | Minimum value |
| `max` | `number` | `100` | Maximum value |
| `viewPortSize` | `number` | `Math.max(1, (max - min) * 0.1)` | Viewport size used to calculate the thumb size |
| `backgroundColor` | `ColorInput` | `"#252527"` | Track color |
| `foregroundColor` | `ColorInput` | `"#9a9ea3"` | Thumb color |
| `onChange` | `(value: number) => void` | - | Called when the stored value changes |
@@ -0,0 +1,153 @@
---
title: TextTable
description: Render styled, wrapping, and selectable tabular text
order: 17
skill:
intents: [text-table, table, tabular-data, styled-cells, table-selection]
---
# TextTable
`TextTableRenderable` lays out a two-dimensional array of styled text cells. It supports intrinsic or full-width columns, constrained wrapping, independent inner and outer borders, cell padding, and text selection.
TextTable currently has an imperative renderable API. It is not registered as a built-in React or Solid component.
## Basic usage
```typescript
import { TextTableRenderable, bold, fg, type TextChunk, type TextTableContent } from "@opentui/core"
const cell = (text: string): TextChunk[] => [{ __isChunk: true, text }]
const content: TextTableContent = [
[[bold("Service")], [bold("Status")], [bold("Notes")]],
[cell("api"), [fg("#00d4aa")("OK")], cell("latency 28ms")],
[cell("worker"), [fg("#b8a0ff")("DEGRADED")], cell("queue depth: 124")],
]
const table = new TextTableRenderable(renderer, {
width: "100%",
wrapMode: "word",
columnWidthMode: "content",
borderStyle: "rounded",
content,
})
renderer.root.add(table)
```
## Content
The public content types are:
```typescript
type TextTableCellContent = TextChunk[] | null | undefined
type TextTableContent = TextTableCellContent[][]
```
Use styled-text helpers such as `bold()`, `fg()`, and `bg()` to build each cell's `TextChunk[]`. `null`, `undefined`, and missing cells render as empty text. Rows may have different lengths; the table uses the longest row's column count and fills missing cells with empty content.
The first row has no special behavior. Styling it as a header is a convention; TextTable has no header option or separate header model.
Replace the table data through the `content` setter:
```typescript
table.content = [
[[bold("Name")], [bold("State")]],
[cell("worker-1"), [fg("#22c55e")("ready")]],
]
```
## Column sizing and wrapping
`columnWidthMode` controls what happens when the available width exceeds the content's intrinsic width:
- `"full"` expands columns evenly to fill the width constraint. This is the default.
- `"content"` keeps the table at its intrinsic width when additional space is available.
When content is wider than a finite width constraint, `wrapMode: "word"` or `"char"` allows the table to shrink columns and grow rows. `wrapMode: "none"` preserves intrinsic column widths instead of shrinking over-wide content.
`columnFitter` chooses how constrained width is distributed:
- `"proportional"` preserves more width for intrinsically wider columns. This is the default.
- `"balanced"` keeps constrained columns closer to an even visual width while respecting their intrinsic sizes.
```typescript
const table = new TextTableRenderable(renderer, {
width: 50,
wrapMode: "word",
columnWidthMode: "full",
columnFitter: "balanced",
content,
})
```
## Borders and spacing
`border` controls separators between cells. `outerBorder` controls the boundary around the table. When `outerBorder` is omitted, it follows the initial `border` value and later `border` assignments. Assigning `outerBorder` to a different value makes it independent; assigning its current value is a no-op and leaves that relationship unchanged.
```typescript
// Outer border without inner cell separators
const table = new TextTableRenderable(renderer, {
border: false,
outerBorder: true,
content,
})
```
`showBorders: false` suppresses border glyph painting without removing the space reserved by enabled inner or outer borders. `columnGap` adds space between columns only when inner vertical borders are disabled.
`cellPadding` sets both axes. `cellPaddingX` and `cellPaddingY` override that value per axis. Padding and gap values are floored and clamped to zero; non-finite values use the default `0`.
## Selection
Selection starts only within cell content, not on border glyphs. Selection within one cell can be partial. A vertical drag that remains in the anchor column selects that column; moving into another column changes to grid selection.
`getSelectedText()` joins selected cells in a row with tabs and joins selected rows with newlines, excluding table borders:
```typescript
renderer.on("selection", () => {
console.log(table.getSelectedText())
})
```
Selection-related methods are:
| Method | Result |
| ------------------------------- | ----------------------------------------------------------------- |
| `shouldStartSelection(x, y)` | Whether the global position is selectable cell content |
| `onSelectionChanged(selection)` | Applies a renderer selection and reports whether text is selected |
| `hasSelection()` | Whether any cell has a selection |
| `getSelection()` | The first selected cell's `{ start, end }` range, or `null` |
| `getSelectedText()` | Tab/newline-delimited selected cell text |
## Options
TextTable also accepts the standard [renderable layout options](/docs/core-concepts/renderables#layout-properties).
| Option | Type | Default | Description |
| ----------------------- | ---------------------------------------------- | ---------------- | ------------------------------------------------------- |
| `content` | `TextTableContent` | `[]` | Rows of styled cell chunks |
| `wrapMode` | `"none" \| "char" \| "word"` | `"word"` | Cell text wrapping behavior |
| `columnWidthMode` | `"content" \| "full"` | `"full"` | Preserve intrinsic width or fill the width constraint |
| `columnFitter` | `"proportional" \| "balanced"` | `"proportional"` | Width allocation when columns must shrink |
| `cellPadding` | `number` | `0` | Horizontal and vertical padding on each side of a cell |
| `cellPaddingX` | `number` | `cellPadding` | Horizontal padding on each side |
| `cellPaddingY` | `number` | `cellPadding` | Vertical padding on each side |
| `columnGap` | `number` | `0` | Gap between columns when inner vertical borders are off |
| `showBorders` | `boolean` | `true` | Paint enabled border glyphs |
| `border` | `boolean` | `true` | Enable inner row and column separators |
| `outerBorder` | `boolean` | `border` | Enable the table boundary |
| `borderStyle` | `"single" \| "double" \| "rounded" \| "heavy"` | `"single"` | Border glyph set |
| `borderColor` | `ColorInput` | `"#FFFFFF"` | Border foreground color |
| `borderBackgroundColor` | `ColorInput` | `"transparent"` | Border background color |
| `backgroundColor` | `ColorInput` | `"transparent"` | Buffered table surface background |
| `fg` | `ColorInput` | `"#FFFFFF"` | Default cell text foreground |
| `bg` | `ColorInput` | `"transparent"` | Default cell text background |
| `attributes` | `number` | `0` | Default text attribute bitmask |
| `selectable` | `boolean` | `true` | Allow cell text selection |
| `selectionBg` | `ColorInput` | - | Selection background override |
| `selectionFg` | `ColorInput` | - | Selection foreground override |
| `flexShrink` | `number` | `0` | Inherited layout shrink factor |
The renderable always uses a buffered surface. The mutable table-specific properties are `content`, `wrapMode`, `columnWidthMode`, `columnFitter`, `cellPadding`, `cellPaddingX`, `cellPaddingY`, `columnGap`, `showBorders`, `border`, `outerBorder`, `borderStyle`, and `borderColor`.
@@ -0,0 +1,88 @@
---
title: TimeToFirstDraw
description: Display the performance timestamp captured on the first draw
order: 18
skill:
intents: [time-to-first-draw, first-draw, startup-timing, performance]
---
# TimeToFirstDraw
`TimeToFirstDrawRenderable` captures and displays a `performance.now()` reading the first time it draws. OpenTUI exports the renderable from core and wrapper components from both React and Solid.
Despite its name and default label, the displayed value is the first-draw timestamp from the runtime's performance time origin. The implementation does not subtract renderer creation time or application start time, so it is **not an elapsed startup duration**.
## Core API
```typescript
import { TimeToFirstDrawRenderable } from "@opentui/core"
const firstDraw = new TimeToFirstDrawRenderable(renderer, {
label: "First draw timestamp",
precision: 1,
fg: "#94a3b8",
})
renderer.root.add(firstDraw)
```
On its first `renderSelf()` call, the renderable stores `performance.now()` in `runtimeMs`. Later draws continue to display that same value. `reset()` clears it and requests another render; the next draw captures a new timestamp.
```typescript
console.log(firstDraw.runtimeMs) // null before the first draw
firstDraw.reset()
```
## React
```tsx
import { TimeToFirstDraw } from "@opentui/react"
function App() {
return <TimeToFirstDraw label="First draw timestamp" precision={1} fg="#94a3b8" />
}
```
The React binding exports `TimeToFirstDraw` and `TimeToFirstDrawProps`. It also registers the `time-to-first-draw` intrinsic element internally; the exported component is the direct public wrapper.
## Solid
```tsx
import { TimeToFirstDraw } from "@opentui/solid"
const App = () => <TimeToFirstDraw label="First draw timestamp" precision={1} fg="#94a3b8" />
```
The Solid binding exports `TimeToFirstDraw` and `TimeToFirstDrawProps`. It also registers the `time_to_first_draw` intrinsic element internally; the exported component is the direct public wrapper.
## Options
The core renderable and both framework wrappers accept these options in addition to standard [renderable layout options](/docs/core-concepts/renderables#layout-properties):
| Option | Type | Default | Description |
| ------------ | ---------------- | ---------------------- | ----------------------------------------------------------------------- |
| `fg` | `ColorInput` | `"#AAAAAA"` | Text color |
| `label` | `string` | `"Time to first draw"` | Text before the timestamp |
| `precision` | `number` | `2` | Decimal places passed to `toFixed()`; use an integer from 0 through 100 |
| `width` | layout dimension | `"100%"` | Renderable width |
| `height` | layout dimension | `1` | Renderable height |
| `flexShrink` | `number` | `0` | Layout shrink factor |
| `alignSelf` | layout alignment | `"center"` | Cross-axis alignment |
User-supplied layout values override the width, height, shrink, and alignment defaults. `precision` is floored and clamped to zero; a non-finite value becomes `2`. The implementation does not clamp the upper bound, so values above JavaScript's `toFixed()` limit of 100 throw when the renderable draws.
The rendered line is `${label}: ${runtimeMs.toFixed(precision)}ms`. It is centered within the renderable width and truncated when it exceeds that width.
## Runtime properties
| Member | Description |
| ------------------- | ------------------------------------------------------------------ |
| `runtimeMs` | Read-only `number \| null`; first-draw `performance.now()` reading |
| `fg = value` | Change the text color and request a render |
| `color = value` | Alias for the `fg` setter |
| `textLabel = value` | Change the displayed label |
| `decimals = value` | Change the normalized display precision |
| `reset()` | Clear `runtimeMs`; capture another timestamp on the next draw |
The constructor/JSX option names are `label` and `precision`; the corresponding post-construction setter names are `textLabel` and `decimals`.
@@ -260,7 +260,28 @@ Compile the app normally with `bun build --compile ./app.ts --outfile app`.
## Node.js standalone executables
Coming soon.
After following the [Node.js SEA asset setup](/docs/reference/standalone-executables#nodejs-sea), add application audio files to the SEA `assets` map alongside the OpenTUI runtime manifest:
```js
assets: {
...Object.fromEntries(openTuiAssets.map(({ key, source }) => [key, source])),
"app/click.wav": resolve("click.wav"),
}
```
Load the embedded bytes through Node's SEA API and pass them directly to `loadSound()`, which accepts `ArrayBuffer` and `Uint8Array`:
```typescript
import { getAsset } from "node:sea"
import { Audio } from "@opentui/core"
const audio = Audio.create({ autoStart: false })
const click = audio.loadSound(getAsset("app/click.wav"))
if (click != null && audio.start()) {
audio.play(click)
}
```
## Devices
@@ -30,23 +30,25 @@ renderer.root.add(greeting)
OpenTUI provides these built-in renderables:
| Class | Description |
| ----------------------- | --------------------------------------------- |
| `BoxRenderable` | Container with border, background, and layout |
| `TextRenderable` | Read-only styled text display |
| `InputRenderable` | Single-line text input |
| `TextareaRenderable` | Multi-line editable text |
| `SelectRenderable` | Dropdown/list selection |
| `TabSelectRenderable` | Horizontal tab selection |
| `ScrollBoxRenderable` | Scrollable container |
| `ScrollBarRenderable` | Standalone scroll bar control |
| `CodeRenderable` | Syntax-highlighted code display |
| `LineNumberRenderable` | Line number gutter for code/text views |
| `DiffRenderable` | Unified or split diff viewer |
| `ASCIIFontRenderable` | ASCII art font display |
| `FrameBufferRenderable` | Raw framebuffer for custom graphics |
| `MarkdownRenderable` | Markdown renderer |
| `SliderRenderable` | Numeric slider control |
| Class | Description |
| ------------------------------------------------------------------ | --------------------------------------------- |
| `BoxRenderable` | Container with border, background, and layout |
| `TextRenderable` | Read-only styled text display |
| [`TextTableRenderable`](/docs/components/text-table) | Styled, wrapping, selectable text table |
| `InputRenderable` | Single-line text input |
| `TextareaRenderable` | Multi-line editable text |
| `SelectRenderable` | Dropdown/list selection |
| `TabSelectRenderable` | Horizontal tab selection |
| `ScrollBoxRenderable` | Scrollable container |
| `ScrollBarRenderable` | Standalone scroll bar control |
| `CodeRenderable` | Syntax-highlighted code display |
| `LineNumberRenderable` | Line number gutter for code/text views |
| `DiffRenderable` | Unified or split diff viewer |
| `ASCIIFontRenderable` | ASCII art font display |
| `FrameBufferRenderable` | Raw framebuffer for custom graphics |
| `MarkdownRenderable` | Markdown renderer |
| `SliderRenderable` | Numeric slider control |
| [`TimeToFirstDrawRenderable`](/docs/components/time-to-first-draw) | First-draw performance timestamp display |
QR code support is available from the separate `@opentui/qrcode` package.
@@ -311,7 +313,7 @@ This moves the renderable visually without affecting layout.
Enable offscreen rendering for complex content and use hooks to draw to the buffer:
```typescript
import { RGBA } from "@opentui/core"
import { BoxRenderable, RGBA } from "@opentui/core"
const complex = new BoxRenderable(renderer, {
id: "complex",
@@ -112,7 +112,7 @@ Applications that can reconstruct their scrollback may opt into a destructive re
The `externalOutputMode` option controls what happens to writes that go through the renderer's configured `stdout.write` while the renderer is active. It does not change `stderr`, the built-in console overlay, or renderer-owned frame bytes.
Renderer-owned native frames go through the renderer output backend: `process.stdout` by default, or a custom `stdout` through `NativeSpanFeed`. `externalOutputMode` only changes application writes through the configured `stdout.write` path.
Renderer-owned native frames go through the renderer output backend: `process.stdout` by default, or a custom `stdout` through [`NativeSpanFeed`](/docs/reference/native-span-feed). `externalOutputMode` only changes application writes through the configured `stdout.write` path.
- **`"capture-stdout"`**: Intercepts `stdout.write`, queues the text, and flushes it above the footer during split-footer renders. Only valid when `screenMode` is `"split-footer"`.
- **`"passthrough"`**: Leaves `stdout.write` untouched. Output goes directly to the configured `stdout`.
@@ -147,6 +147,8 @@ const renderer = await createCliRenderer({
Initial size comes from `stdout.columns` / `stdout.rows`, then `width` / `height`, then `80x24`. For custom `stdout`, `remote` defaults to `true`; set `remote: false` only when the stream behaves like the local terminal and should receive default terminal env forwarding.
When `stdout` is not `process.stdout`, the renderer creates a `NativeSpanFeed` unless `bufferedOutput: "memory"` is set. It forwards native frame bytes to the original `stdout.write` and keeps each borrowed native chunk alive until the write callback runs, providing asynchronous backpressure. Applications normally should not create this low-level transport directly; see the [NativeSpanFeed reference](/docs/reference/native-span-feed) for its ownership and lifecycle rules.
Call `renderer.resize(cols, rows)` when the external terminal changes size. `SIGWINCH` is only registered for `process.stdout`.
Each `stdin` or `stdout` object can be owned by one renderer at a time. `destroy()` releases ownership and restores `stdout.write`.
@@ -264,7 +266,7 @@ surface.destroy()
`commitRows` throws if you call it before `render()`, or if the renderer's width or `widthMethod` changed since the last `render()`. Re-render before committing fresh rows in either case.
For React and Solid, use the binding-level helpers that wrap `writeToScrollback` with JSX support. See [`createScrollbackWriter` / `writeSolidToScrollback`](/docs/bindings/solid#scrollback-writers) in the Solid docs.
For Solid, use the binding-level helpers that wrap `writeToScrollback` with JSX support. See [`createScrollbackWriter` / `writeSolidToScrollback`](/docs/bindings/solid#scrollback-writers). The React binding does not currently provide an equivalent JSX scrollback helper.
## The root renderable
@@ -3,39 +3,306 @@ title: Testing
description: Test renderers, input, mouse, and frame output
order: 12
skill:
entry: true
intents: [testing, test-renderer, snapshots, frames]
---
# Testing
`@opentui/core/testing` provides a renderer that does not write to the real terminal by default. It constructs `CliRenderer` directly with `screenMode: "main-screen"`, `consoleMode: "disabled"`, `externalOutputMode: "passthrough"`, and native memory output.
`@opentui/core/testing` provides a real `CliRenderer` backed by native in-memory output, plus deterministic input, mouse, clock, capability, highlighting, spy, and frame-recording helpers. It does not write to the host terminal by default.
## Test renderer
```typescript
import { createTestRenderer } from "@opentui/core/testing"
import { Text } from "@opentui/core"
const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ width: 40, height: 10 })
const setup = await createTestRenderer({ width: 40, height: 10 })
renderer.root.add(Text({ content: "Hello" }))
await renderOnce()
try {
setup.renderer.root.add(Text({ content: "Hello" }))
await setup.renderOnce()
console.log(captureCharFrame())
renderer.destroy()
console.log(setup.captureCharFrame())
} finally {
setup.renderer.destroy()
}
```
| Helper | Description |
| --------------------------- | ---------------------------------------- |
| `renderer` | The `CliRenderer` instance |
| `renderOnce()` | Run one render pass |
| `flush()` | Wait until scheduled rendering settles |
| `waitFor(predicate)` | Retry until a condition passes |
| `waitForFrame(predicate)` | Retry against captured frame text |
| `waitForVisualIdle()` | Wait for quiet native frames |
| `captureCharFrame()` | Read the current character frame as text |
| `captureSpans()` | Read styled span lines and cursor state |
| `externalOutput.takeText()` | Read captured split-footer output events |
| `getNativeStats()` | Read native render stats |
| `resize(width, height)` | Simulate a terminal resize |
| `mockInput` / `mockMouse` | Drive keyboard and mouse input |
`createTestRenderer(options)` requires an options object. `TestRendererOptions` extends `CliRendererConfig` and adds `width`, `height`, `kittyKeyboard`, and `otherModifiersMode`.
Use `createCliRenderer()` with custom `stdin` / `stdout` streams when a test needs the real output transport instead of memory output.
### Setup semantics and defaults
The helper constructs `CliRenderer` directly instead of calling `createCliRenderer()`/`setupTerminal()`. It therefore skips host-terminal raw mode and terminal setup while still creating the native renderer and applying its normal thread defaults.
| Setting | Test default |
| -------------------- | ---------------------------------------------------------------------------------- |
| `screenMode` | `"main-screen"` |
| `footerHeight` | `12` |
| `consoleMode` | `"disabled"` |
| `externalOutputMode` | `"passthrough"` |
| `bufferedOutput` | `"memory"` |
| width | `options.width`, custom `stdout.columns`, host `process.stdout.columns`, then `80` |
| height | `options.height`, custom `stdout.rows`, host `process.stdout.rows`, then `24` |
The legacy `kittyKeyboard: true` option maps to `useKittyKeyboard: { events: true }` and configures the mock key encoder. `otherModifiersMode` enables modifyOtherKeys-style sequences only when Kitty mode is off; Kitty mode takes precedence.
Tests own cleanup. Always call `setup.renderer.destroy()` in `finally` or test teardown. Use `createCliRenderer()` with custom `stdin`/`stdout` when the test must exercise the real output transport; the test renderer's default native destination is memory even when stream objects are supplied.
### Returned setup
| Member | Behavior |
| ----------------------------------- | ---------------------------------------------------------------------------------------------- |
| `renderer` | The `CliRenderer` instance (`TestRenderer` is a type alias) |
| `mockInput` | Keyboard driver created by `createMockKeys()` |
| `mockMouse` | SGR mouse driver created by `createMockMouse()` |
| `renderOnce()` | Wait for feed backpressure if present, then run one renderer loop pass |
| `flush(options?)` | Wait for visual idle; `maxPasses` defaults to `20` |
| `waitFor(predicate, options?)` | Retry a sync/async predicate for up to `maxPasses` (default `20`) while rendering can progress |
| `waitForFrame(predicate, options?)` | Retry against captured text and return the matching frame; default `maxPasses` is `20` |
| `waitForVisualIdle(options?)` | Wait for quiet native frames; defaults to `quietFrames: 1`, `maxFrames: 20` |
| `captureCharFrame()` | Decode the current character buffer as text |
| `captureSpans()` | Return `{ cols, rows, cursor: [x, y], lines }` with styled spans |
| `externalOutput` | Recorder for split-footer external-output commits |
| `getNativeStats()` | Return the current native render stats |
| `resize(width, height)` | Invoke the renderer's test resize path |
Invalid/non-positive wait limits use their defaults; finite positive values are floored. Timeout errors include frame and scheduler diagnostics, and `waitForFrame()` also includes its last captured frame.
### Waiting for observable output
Use `renderOnce()` for explicitly controlled single-pass tests. Use `waitForFrame()` when application work schedules rendering asynchronously:
```typescript
const setup = await createTestRenderer({ width: 30, height: 5 })
try {
setup.renderer.root.add(Text({ content: "Ready" }))
const frame = await setup.waitForFrame((value) => value.includes("Ready"))
console.log(frame)
} finally {
setup.renderer.destroy()
}
```
`waitForVisualIdle()` considers a rendered frame quiet when native `cellsUpdated` is zero. It also returns when the scheduler has no running, rendering, or scheduled work. `flush()` is a convenience wrapper over this behavior.
### Styled frames
`captureCharFrame()` is convenient for snapshots and text assertions. `captureSpans()` preserves the current buffer dimensions, cursor coordinates, and each line's styled spans:
```typescript
const setup = await createTestRenderer({ width: 20, height: 4 })
try {
setup.renderer.root.add(Text({ content: "Status", fg: "#22c55e" }))
await setup.renderOnce()
const captured = setup.captureSpans()
console.log(captured.cols, captured.rows, captured.cursor, captured.lines)
} finally {
setup.renderer.destroy()
}
```
### External output
The setup listens for `external_output` events and stores commits containing `text`, `rows`, snapshot `width`/`height`, `rowColumns`, `startOnNewLine`, and `trailingNewline`.
| Method | Behavior |
| --------------------------- | ----------------------------------------------------- |
| `externalOutput.take()` | Return all commits and clear the recorder |
| `externalOutput.takeText()` | Consume all commits and join their rows with newlines |
| `externalOutput.clear()` | Discard all commits |
## Keyboard input
`createTestRenderer()` exposes its configured keyboard driver as `mockInput`. `createMockKeys(renderer, options?)` is also exported for an existing renderer.
```typescript
import { InputRenderable } from "@opentui/core"
import { KeyCodes, createTestRenderer } from "@opentui/core/testing"
const setup = await createTestRenderer({ width: 30, height: 4 })
try {
const input = new InputRenderable(setup.renderer, { width: 20 })
setup.renderer.root.add(input)
input.focus()
await setup.mockInput.typeText("hello")
setup.mockInput.pressKey(KeyCodes.ARROW_LEFT)
setup.mockInput.pressBackspace()
await setup.renderOnce()
} finally {
setup.renderer.destroy()
}
```
Keyboard methods:
| Method | Behavior |
| ----------------------------------- | ---------------------------------------------- |
| `pressKey(key, modifiers?)` | Emit one key synchronously |
| `pressKeys(keys, delayMs = 0)` | Emit several keys, optionally delayed |
| `typeText(text, delayMs = 0)` | Emit the text one character at a time |
| `pressEnter(modifiers?)` | Emit return |
| `pressEscape(modifiers?)` | Emit escape |
| `pressTab(modifiers?)` | Emit tab; Shift+Tab uses the back-tab sequence |
| `pressBackspace(modifiers?)` | Emit backspace |
| `pressArrow(direction, modifiers?)` | Emit `"up"`, `"down"`, `"left"`, or `"right"` |
| `pressCtrlC()` | Emit Ctrl+C |
| `pasteBracketedText(text)` | Emit bracketed-paste start, content, and end |
Modifiers are `shift`, `ctrl`, `meta`, `super`, and `hyper`. `KeyCodes` contains return, linefeed, tab, backspace, delete, home/end, escape, four arrows, and F1-F12 sequences. `KeyInput` accepts a raw string or a `KeyCodes` key name. `pasteBytes(text)` is also exported for tests that need UTF-8 bytes without emitting input.
## Mouse input
`mockMouse` emits SGR mouse sequences through renderer stdin using zero-based test coordinates. `createMockMouse(renderer)` is also public.
```typescript
const setup = await createTestRenderer({ width: 40, height: 10 })
try {
await setup.mockMouse.click(4, 2)
await setup.mockMouse.drag(4, 2, 20, 6)
await setup.mockMouse.scroll(20, 6, "down")
console.log(setup.mockMouse.getCurrentPosition())
console.log(setup.mockMouse.getPressedButtons())
} finally {
setup.renderer.destroy()
}
```
Public operations are `moveTo`, `click`, `doubleClick`, `pressDown`, `release`, `drag`, `scroll`, `getCurrentPosition`, `getPressedButtons`, and low-level `emitMouseEvent`. Click, double-click, and drag default to 10 ms delays; drag emits five interpolated movement steps. Other operations default to no delay.
`MouseButtons` exports `LEFT` (0), `MIDDLE` (1), `RIGHT` (2), and wheel codes `WHEEL_UP` through `WHEEL_RIGHT` (64-67). Mouse modifiers are `shift`, `alt`, and `ctrl`.
## Terminal capabilities
Build a complete `TerminalCapabilities` fixture with partial overrides:
```typescript
import { createTerminalCapabilities, createTestRenderer, setRendererCapabilities } from "@opentui/core/testing"
const capabilities = createTerminalCapabilities({
rgb: true,
kitty_keyboard: true,
terminal: { name: "test-terminal", version: "1" },
})
const setup = await createTestRenderer({ width: 20, height: 4 })
try {
setRendererCapabilities(setup.renderer, capabilities)
} finally {
setup.renderer.destroy()
}
```
The baseline has feature booleans disabled, `unicode: "unicode"`, `osc52_support: "unknown"`, `multiplexer: "none"`, `remote: false`, and empty terminal name/version with `from_xtversion: false`. `setRendererCapabilities()` replaces the test renderer's capability state and returns the complete object.
## ManualClock
`ManualClock` implements OpenTUI's clock interface without wall-clock waits. It starts at zero and supports `now`, `setTime`, timeout/interval scheduling and clearing, `advance`, and `runAll`.
```typescript
const { ManualClock } = await import("@opentui/core/testing")
const clock = new ManualClock()
let fired = false
clock.setTimeout(() => {
fired = true
}, 100)
clock.advance(99)
console.log(fired) // false
clock.advance(1)
console.log(fired) // true
```
Times and delays are floored; negative delays advance by zero. Timers at the same timestamp fire in registration order. `setTime()` runs due timers when moving forward and directly changes time when moving backward. Use `runAll()` only when scheduled work is finite; a live interval continually reschedules itself.
## MockTreeSitterClient
`MockTreeSitterClient` subclasses `TreeSitterClient` without starting a worker. `highlightOnce()` remains pending until manually resolved or until an optional clock-backed auto-resolution timeout fires.
```typescript
const { MockTreeSitterClient } = await import("@opentui/core/testing")
const client = new MockTreeSitterClient()
client.setMockResult({ highlights: [[0, 5, "keyword"]] })
const pending = client.highlightOnce("const", "typescript")
client.resolveHighlightOnce()
try {
console.log(await pending)
} finally {
await client.destroy()
}
```
Public controls are `setMockResult`, `resolveHighlightOnce(index = 0)`, `resolveAllHighlightOnce`, and `isHighlighting`. `destroy()` resolves all pending highlights before normal client cleanup. Constructor options are `autoResolveTimeout` and `clock`.
## Spy
`createSpy()` returns a callable that records argument arrays:
```typescript
const { createSpy } = await import("@opentui/core/testing")
const spy = createSpy()
spy("saved", 3)
console.log(spy.calls)
console.log(spy.callCount())
console.log(spy.calledWith("saved", 3))
spy.reset()
```
`calledWith()` compares recorded and expected arguments through `JSON.stringify`; it is a small callback spy, not a test-framework mock replacement.
## TestRecorder
`TestRecorder` listens to renderer `frame` events and captures the character buffer after each native pass.
```typescript
import { Text } from "@opentui/core"
import { TestRecorder, createTestRenderer } from "@opentui/core/testing"
const setup = await createTestRenderer({ width: 20, height: 4 })
const recorder = new TestRecorder(setup.renderer, {
recordBuffers: { fg: true, attributes: true },
})
try {
recorder.rec()
setup.renderer.root.add(Text({ content: "Recorded" }))
await setup.renderOnce()
recorder.stop()
console.log(recorder.recordedFrames)
} finally {
recorder.stop()
setup.renderer.destroy()
}
```
`rec()` starts a new recording, clears previous frames, resets numbering to zero, and records a start timestamp. `stop()` detaches the listener. `clear()` empties frames and resets numbering. `recordedFrames` returns an array copy and `isRecording` reports current state.
Each exported `RecordedFrame` contains `frame`, elapsed `timestamp`, zero-based `frameNumber`, and optional copied buffers. Constructor options accept `recordBuffers: { fg?, bg?, attributes? }` and an injectable `now()` function. The barrel exports `TestRecorder` and `RecordedFrame`; the recorder's supporting option/buffer interfaces are not separate exports from `@opentui/core/testing`.
## Public export inventory
`@opentui/core/testing` exports:
- Renderer: `createTestRenderer` and the `TestRendererOptions`, `TestRenderer`, `TestRendererSetup`, `MockInput`, `MockMouse`, wait-option, and external-output types.
- Keyboard: `createMockKeys`, `pasteBytes`, `KeyCodes`, `KeyInput`, and `MockKeysOptions`.
- Mouse: `createMockMouse`, `MouseButtons`, `MouseButton`, `MousePosition`, `MouseModifiers`, `MouseEventType`, and `MouseEventOptions`.
- Tree-sitter: `MockTreeSitterClient`.
- Capabilities: `createTerminalCapabilities`, `setRendererCapabilities`, and `TerminalCapabilitiesOverrides`.
- General helpers: `createSpy`, `ManualClock`, `TestRecorder`, and `RecordedFrame`.
@@ -15,7 +15,9 @@ Use the bare engine when you want a custom host or when you want to build addons
```ts
import { Keymap, type KeymapHost } from "@opentui/keymap"
const keymap = new Keymap(host as KeymapHost<object>)
function createKeymap(host: KeymapHost<object>) {
return new Keymap(host)
}
```
Bare keymaps do not parse string bindings until you install binding parsers and event match resolvers. In practice that usually means `registerDefaultKeys()` or one of the default host helpers.
@@ -248,7 +248,9 @@ If neither built-in host fits your runtime, implement `KeymapHost` yourself and
```ts
import { Keymap, type KeymapHost } from "@opentui/keymap"
const keymap = new Keymap(host as KeymapHost<object>)
function createCustomKeymap(host: KeymapHost<object>) {
return new Keymap(host)
}
```
Custom hosts must provide conservative metadata. Use `unknown` when the host cannot prove a platform or modifier capability.
@@ -294,21 +294,24 @@ A pending sequence is the prefix the user has already typed, such as `g` while w
## Entry points
| Package | Description |
| -------------------------------- | -------------------------------------------------------------------------- |
| `@opentui/keymap` | Main engine entry: `Keymap`, key stringifiers and shared types |
| `@opentui/keymap/addons` | Universal addons for parser stages, metadata, diagnostics and sequences |
| `@opentui/keymap/addons/opentui` | Universal addons plus OpenTUI-specific base-layout and edit-buffer helpers |
| `@opentui/keymap/extras` | Pure config and formatting helpers |
| `@opentui/keymap/extras/graph` | Graph snapshot helpers for debug and graph UIs |
| `@opentui/keymap/testing` | Host-agnostic fake host and diagnostics for addon tests |
| `@opentui/keymap/opentui` | OpenTUI host adapter for terminal apps built on `@opentui/core` |
| `@opentui/keymap/html` | DOM host adapter for browser UIs rooted in an `HTMLElement` |
| `@opentui/keymap/react` | React provider and hooks for an OpenTUI keymap |
| `@opentui/keymap/solid` | Solid provider and hooks for an OpenTUI keymap |
| Package | Description |
| --------------------------------- | -------------------------------------------------------------------------- |
| `@opentui/keymap` | Main engine entry: `Keymap`, key stringifiers and shared types |
| `@opentui/keymap/addons` | Universal addons for parser stages, metadata, diagnostics and sequences |
| `@opentui/keymap/addons/opentui` | Universal addons plus OpenTUI-specific base-layout and edit-buffer helpers |
| `@opentui/keymap/extras` | Pure config and formatting helpers |
| `@opentui/keymap/extras/graph` | Graph snapshot helpers for debug and graph UIs |
| `@opentui/keymap/testing` | Host-agnostic fake host and diagnostics for addon tests |
| `@opentui/keymap/opentui` | OpenTUI host adapter for terminal apps built on `@opentui/core` |
| `@opentui/keymap/html` | DOM host adapter for browser UIs rooted in an `HTMLElement` |
| `@opentui/keymap/react` | React provider and hooks for an OpenTUI keymap |
| `@opentui/keymap/solid` | Solid provider and hooks for an OpenTUI keymap |
| `@opentui/keymap/runtime-modules` | Runtime-module map for external plugin loading |
Adapter entry points intentionally export adapter-specific helpers. Import the shared `Keymap`, stringifiers and core types from `@opentui/keymap`.
`@opentui/keymap/runtime-modules` exports `runtimeModules` for OpenTUI's runtime plugin support. The map includes the keymap root, both extras entries, both addon entries, the HTML and OpenTUI adapters, and lazy loaders for the React and Solid bindings. It does not include `@opentui/keymap/testing` or the runtime-modules entry itself.
## Bare vs default helpers
| Helper | What it creates |
@@ -9,12 +9,13 @@ skill:
# Environment variables
OpenTUI reads environment variables at runtime. Bun loads `.env` automatically, so you can set these in your shell or in a `.env` file.
OpenTUI reads environment variables from `process.env`. Bun loads `.env` automatically; with Node.js, use the shell or Node's configured environment-file support.
## Variables
| Variable | Type | Default | Description |
| ------------------------------- | --------- | ------- | --------------------------------------------------------------- |
| `OTUI_ASSET_ROOT` | `string` | `""` | Absolute root for relocated OpenTUI runtime assets |
| `OTUI_TS_STYLE_WARN` | `string` | `false` | Enable warnings for missing syntax styles |
| `OTUI_TREE_SITTER_WORKER_PATH` | `string` | `""` | Path to the Tree-sitter worker |
| `XDG_CONFIG_HOME` | `string` | `""` | Base directory for user-specific configuration files |
@@ -23,10 +24,10 @@ OpenTUI reads environment variables at runtime. Bun loads `.env` automatically,
| `OTUI_DEBUG_FFI` | `boolean` | `false` | Enable debug logging for the FFI bindings |
| `OTUI_SHOW_STATS` | `boolean` | `false` | Show the debug overlay at startup |
| `OTUI_TRACE_FFI` | `boolean` | `false` | Enable tracing for the FFI bindings |
| `OPENTUI_FORCE_WCWIDTH` | `boolean` | `false` | Use wcwidth for character width calculations |
| `OPENTUI_FORCE_UNICODE` | `boolean` | `false` | Force Mode 2026 Unicode support in terminal capabilities |
| `OPENTUI_GRAPHICS` | `boolean` | `true` | Enable Kitty graphics protocol detection |
| `OPENTUI_FORCE_NOZWJ` | `boolean` | `false` | Use no_zwj width method (Unicode without ZWJ joining) |
| `OPENTUI_FORCE_WCWIDTH` | presence | unset | Use wcwidth for character width calculations |
| `OPENTUI_FORCE_UNICODE` | presence | unset | Force Mode 2026 Unicode support in terminal capabilities |
| `OPENTUI_GRAPHICS` | `string` | auto | Override Kitty graphics protocol detection |
| `OPENTUI_FORCE_NOZWJ` | presence | unset | Use no_zwj width method (Unicode without ZWJ joining) |
| `OPENTUI_LIBC` | `string` | unset | Select Linux native libc package (`glibc`, `musl`) |
| `OPENTUI_FORCE_EXPLICIT_WIDTH` | `string` | - | Force explicit width detection (`true`/`1` or `false`/`0`) |
| `OPENTUI_NOTIFICATION_PROTOCOL` | `string` | `auto` | Force notification protocol (`osc9`, `osc777`, `osc99`, `none`) |
@@ -42,6 +43,9 @@ OpenTUI reads environment variables at runtime. Bun loads `.env` automatically,
## Notes
- `OTUI_TS_STYLE_WARN` is a presence-like string setting: any explicit nonempty value, including `false`, enables warnings.
- `OPENTUI_FORCE_WCWIDTH`, `OPENTUI_FORCE_UNICODE`, and `OPENTUI_FORCE_NOZWJ` are native presence flags. Any value, including `0` or `false`, enables the corresponding override. Leave them unset to disable them.
- `OPENTUI_GRAPHICS` recognizes the exact lowercase values `false`/`0` and `true`/`1`; other values leave automatic behavior unchanged.
- `OPENTUI_FORCE_EXPLICIT_WIDTH=false` skips OSC 66 queries on older terminals.
- Linux uses the glibc native package by default. Set `OPENTUI_LIBC=musl` before importing OpenTUI, or define `process.env.OPENTUI_LIBC` as `"musl"` at standalone build time, to use the musl native package. See [Standalone Executables](/docs/reference/standalone-executables).
- `OPENTUI_NOTIFICATION_PROTOCOL=none` disables notifications. Protocol overrides should only be used when terminal detection cannot identify a supported notification protocol.
@@ -53,3 +57,9 @@ OpenTUI reads environment variables at runtime. Bun loads `.env` automatically,
- `OTUI_NO_NATIVE_RENDER` still runs the render loop. In `"split-footer"` mode, the current output flush path can still emit ANSI cursor movement and clear sequences.
- `OTUI_DUMP_CAPTURES` runs from the renderer exit handler. A direct `renderer.destroy()` call does not trigger it by itself.
- `OTUI_STDIN_LOG=/tmp/opentui-stdin.bin` records stdin exactly as OpenTUI receives it, before parsing. The file is truncated when the renderer starts. It is binary and may contain passwords or other sensitive input, so store and share it carefully. Recording uses synchronous file writes and is intended only for short debugging sessions.
## `OTUI_ASSET_ROOT`
`OTUI_ASSET_ROOT` relocates OpenTUI runtime assets, including the native library, parser worker, default parsers, and Tree-sitter WASM. It is primarily used by [Node.js single executable applications](/docs/reference/standalone-executables#nodejs-sea).
The value must be an absolute directory. Place every file beneath it using the exact `key` returned by `getNodeAssets()` from `@opentui/core/node-assets`. Set it before importing or executing bundled OpenTUI code. An empty value is treated as unset; when a nonempty root is set, a missing requested asset throws and does not fall back to package-relative files.
@@ -0,0 +1,135 @@
---
title: NativeSpanFeed
description: Low-level native-to-JavaScript byte transport used for custom renderer output
order: 6
skill:
intents: [native-span-feed, custom-stdout, output-transport, backpressure]
---
# NativeSpanFeed
`NativeSpanFeed` is a zero-copy wrapper around a native Zig byte feed. `CliRenderer` uses it internally to deliver native frame bytes to a custom `stdout` while respecting asynchronous `Writable` callbacks.
Most applications should not create a feed directly. Pass streams to [`createCliRenderer()`](/docs/core-concepts/renderer#custom-streams) instead:
```typescript
const renderer = await createCliRenderer({
stdin,
stdout,
width: cols,
height: rows,
exitOnCtrlC: false,
exitSignals: [],
})
```
When `stdout` is not `process.stdout` and `bufferedOutput` is not `"memory"`, the renderer creates the feed, forwards each borrowed byte view to the original `stdout.write`, and keeps the native chunk pinned until the write callback runs. The renderer also drains and closes the feed during destruction.
## Low-level use
Direct use is intended for native integrations that already produce data through a native span-feed pointer. `NativeSpanFeed` is not a Node stream and does not expose `read()`, `write()`, or `pipe()`.
```typescript
import { NativeSpanFeed } from "@opentui/core"
const feed = NativeSpanFeed.create({
chunkSize: 64 * 1024,
initialChunks: 2,
})
const chunks: Uint8Array[] = []
const offData = feed.onData((bytes) => {
// Copy data that must outlive this synchronous handler.
chunks.push(bytes.slice())
})
try {
// A native producer that accepts NativeSpanFeed uses feed.streamPtr.
await feed.idle()
} finally {
offData()
feed.close()
}
```
The wrapper owns the attached feed after `create()` or `attach()` succeeds. Calling `close()` unregisters the callback and destroys the native feed. Do not use `streamPtr` or previously received views afterward.
## Public API
```typescript
type DataHandler = (data: Uint8Array) => void | Promise<void>
class NativeSpanFeed {
static create(options?: NativeSpanFeedOptions): NativeSpanFeed
static attach(streamPtr: Pointer, options?: NativeSpanFeedOptions): NativeSpanFeed
readonly streamPtr: Pointer
onData(handler: DataHandler): () => void
onError(handler: (code: number) => void): () => void
isBackpressured(): boolean
drainAll(): void
idle(): Promise<void>
close(): void
}
```
`attach()` wraps an existing native feed pointer and registers the JavaScript callback. Its current `options` parameter is accepted but not applied; configure the native feed when it is created.
`onData()` and `onError()` return unsubscribe functions. If committed data arrives before a data handler exists, the feed marks it pending and drains it when the first handler is added.
`drainAll()` synchronously drains all currently queued spans in batches. Data-available callbacks normally invoke it automatically when handlers are registered.
## Options
```typescript
type GrowthPolicy = "grow" | "block"
type NativeSpanFeedOptions = {
chunkSize?: number
initialChunks?: number
maxBytes?: bigint
growthPolicy?: GrowthPolicy
autoCommitOnFull?: boolean
spanQueueCapacity?: number
}
```
| Option | Default | Description |
| ------------------- | ----------- | ----------------------------------------------------------------------------------------------------- |
| `chunkSize` | `64 * 1024` | Bytes allocated for each native chunk |
| `initialChunks` | `2` | Chunks allocated at creation |
| `maxBytes` | `0n` | Allocation cap; `0n` means no cap |
| `growthPolicy` | `"grow"` | Add capacity when all available chunks or span slots are occupied; `"block"` reports no space instead |
| `autoCommitOnFull` | `true` | Let the native producer commit a chunk automatically when it fills |
| `spanQueueCapacity` | `0` | Native default sentinel, normalized to `4096` span entries |
An explicit `chunkSize: 0` normalizes to 64 KiB. An explicit `initialChunks: 0` normalizes to one chunk. An explicit `spanQueueCapacity: 0` selects the native capacity of 4096.
## Borrowed data and backpressure
Each `onData` argument is a `Uint8Array` view into native chunk memory, not an owned copy.
- A synchronous handler must copy the bytes if it retains them after returning.
- If a handler returns a promise, the chunk remains pinned until all promises returned for that span settle.
- Promise rejection still releases the native chunk; it is not delivered to `onError` by this wrapper.
- `isBackpressured()` is true while async handlers are pending, committed data is waiting for a handler, or native chunks remain pinned.
- `idle()` resolves when no callback or drain is active, no async handlers or pending data remain, and no chunks are pinned.
- `close()` is idempotent after destruction. If called during a callback, drain, or async handler, final destruction is deferred until that work settles.
Renderer-managed custom output uses these rules to turn the `stdout.write(..., callback)` lifetime into feed backpressure. During `renderer.destroy()`, OpenTUI drains existing frames, destroys the native renderer so it can commit shutdown bytes, drains those bytes, detaches handlers, and closes the feed. If an application immediately destroys its underlying transport after `renderer.destroy()`, allow a microtask for pending asynchronous write callbacks to finish.
## Stats type
`@opentui/core` exports the native data shape:
```typescript
type NativeSpanFeedStats = {
bytesWritten: bigint
spansCommitted: bigint
chunks: number
pendingSpans: number
}
```
`NativeSpanFeed` itself has no public stats method. Do not call a nonexistent `feed.getStats()` or `feed.stats` property.
@@ -0,0 +1,102 @@
---
title: Package entrypoints
description: Public package roots and subpaths across the OpenTUI workspace
order: 5
skill:
entry: true
intents: [package-exports, entrypoints, subpath-exports, imports]
---
# Package entrypoints
OpenTUI packages use explicit `exports` maps. Import only the root and subpaths listed below; source-file deep imports are not public package entrypoints.
## @opentui/core
| Entrypoint | Public purpose |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `@opentui/core` | Main imperative renderer, renderables, buffers, input, audio, plugins, Tree-sitter client, utilities, and shared types |
| `@opentui/core/testing` | Test renderer, input/mouse drivers, capability fixtures, clocks, spies, Tree-sitter mock, and frame recorder |
| `@opentui/core/runtime-plugin` | Bun runtime-module plugin factory, module-ID helpers, rewrite options, and runtime module entry types |
| `@opentui/core/runtime-plugin-support` | Installs core runtime-plugin support as an import side effect and re-exports its setup API |
| `@opentui/core/runtime-plugin-support/configure` | Configurable `ensureRuntimePluginSupport()` without import-time installation |
| `@opentui/core/yoga` | Native Yoga `Config`/`Node`, enums, constants, layout values, and callback types |
| `@opentui/core/tree-sitter/update-assets` | `updateAssets`, `runUpdateAssetsCli`, and `UpdateOptions` for Tree-sitter assets |
| `@opentui/core/parser.worker` | Tree-sitter parser worker module; no named application API |
| `@opentui/core/node-assets` | `getNodeAssets(target)` plus `NodeAsset` and `NodeAssetTarget` for Node distribution assets |
`runtime-plugin`, both runtime-plugin-support entries, and the Tree-sitter update CLI use Bun-specific tooling. See the [Plugin API](/docs/plugins/core) and [Tree-sitter reference](/docs/reference/tree-sitter) for their workflows.
## @opentui/react
| Entrypoint | Public purpose |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `@opentui/react` | React renderer root, intrinsic catalogue, components, hooks, slots, `TimeToFirstDraw`, and component types |
| `@opentui/react/renderer` | Published alias for the root renderer exports, including `createRoot` and `Root` |
| `@opentui/react/test-utils` | React-aware `testRender(node, options)` over `@opentui/core/testing` |
| `@opentui/react/runtime-plugin-support` | Installs the React runtime-module plugin as an import side effect |
| `@opentui/react/runtime-plugin-support/configure` | Configurable React `ensureRuntimePluginSupport()` without import-time installation |
| `@opentui/react/jsx-runtime` | Automatic JSX runtime: `Fragment`, `jsx`, `jsxs`, and OpenTUI JSX types |
| `@opentui/react/jsx-dev-runtime` | Development JSX runtime: `Fragment`, `jsxDEV`, and OpenTUI JSX types |
The runtime-plugin-support entries use Bun's plugin API. Normal applications use the root and compiler-selected JSX runtime; use `/test-utils` only in tests.
## @opentui/solid
| Entrypoint | Public purpose |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `@opentui/solid` | Solid renderer, root `render`/`testRender`, reconciler helpers, elements, scrollback, slots, hooks, and types |
| `@opentui/solid/preload` | Bun preload that installs the Solid source transform |
| `@opentui/solid/bun-plugin` | Solid transform plugin factory/default export and installation/reset helpers |
| `@opentui/solid/runtime-plugin-support` | Installs Solid transform and runtime-module support as an import side effect |
| `@opentui/solid/runtime-plugin-support/configure` | Configurable Solid runtime support without import-time installation |
| `@opentui/solid/components` | `extend`, `getComponentCatalogue`, and component extension types |
| `@opentui/solid/jsx-runtime` | Automatic JSX runtime and OpenTUI JSX namespace |
| `@opentui/solid/jsx-dev-runtime` | Development JSX runtime and OpenTUI JSX namespace |
`preload`, `bun-plugin`, and both runtime-plugin-support entries are Bun-specific. The root and JSX runtime setup are covered in [Solid.js bindings](/docs/bindings/solid).
## @opentui/keymap
| Entrypoint | Public purpose |
| --------------------------------- | --------------------------------------------------------------------------------------------- |
| `@opentui/keymap` | Host-agnostic `Keymap`, extension context, key stringifiers, and shared types |
| `@opentui/keymap/extras` | Binding lookup, command-binding, and formatting helpers |
| `@opentui/keymap/extras/graph` | Graph snapshots and graph projection types |
| `@opentui/keymap/addons` | Universal parser, field, metadata, leader, sequence, command, and diagnostics addons |
| `@opentui/keymap/addons/opentui` | Universal addons plus OpenTUI base-layout and edit-buffer/textarea helpers |
| `@opentui/keymap/testing` | Host-agnostic fake host, targets/events, diagnostics, and `createTestKeymap` |
| `@opentui/keymap/html` | DOM host/event adapter and bare/default HTML keymap factories |
| `@opentui/keymap/opentui` | `CliRenderer`/`Renderable` host adapter and bare/default OpenTUI keymap factories |
| `@opentui/keymap/react` | OpenTUI React provider, hooks, and store-backed reactive matcher |
| `@opentui/keymap/solid` | OpenTUI Solid provider, hooks, selector, and signal-backed reactive matcher |
| `@opentui/keymap/runtime-modules` | Runtime-module map for the keymap root, extras, addons, adapters, and lazy framework bindings |
Adapter entrypoints intentionally do not re-export the shared engine. Import `Keymap`, key stringifiers, and shared types from `@opentui/keymap`. See the [Keymap overview](/docs/keymap/overview) for how the entrypoints compose.
## @opentui/qrcode
| Entrypoint | Public purpose |
| ----------------------- | ------------------------------------------------------------------------- |
| `@opentui/qrcode` | QR encoder, segment/ECC APIs, SVG/terminal output, and `QRCodeRenderable` |
| `@opentui/qrcode/react` | `registerQRCode()` for the React `qr-code` intrinsic |
| `@opentui/qrcode/solid` | `registerQRCode()` for the Solid `qr_code` intrinsic |
See [QR encoder](/docs/reference/qr-encoder) and [QR Code component](/docs/components/qr-code) for the two API layers.
## @opentui/ssh
| Entrypoint | Public purpose |
| -------------- | ------------------------------------------------------------------------------------------- |
| `@opentui/ssh` | SSH server builder, authentication, middleware, sessions, logging, errors, and public types |
The package has no framework-specific subpaths; hand the session renderer to the React or Solid root API. See [SSH](/docs/reference/ssh).
## @opentui/three
| Entrypoint | Public purpose |
| -------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `@opentui/three` | Three.js WebGPU renderer, renderable, sprites, effects, resource pools, physics adapters, and `THREE` |
| `@opentui/three/runtime-modules` | Runtime-module map containing the `@opentui/three` root for Bun-loaded plugins |
Both entrypoints are Bun-only. See [Three.js WebGPU](/docs/reference/three).
@@ -0,0 +1,218 @@
---
title: QR encoder
description: Encode QR Code Model 2 matrices and render terminal text or SVG
order: 7
skill:
entry: true
intents: [qr, qrcode, qr-encoder, svg-qr, gs1, eci, structured-append]
---
# QR encoder
`@opentui/qrcode` includes a standalone QR Code Model 2 encoder in addition to [`QRCodeRenderable`](/docs/components/qr-code). Use the encoder when you need a module matrix, terminal string, SVG, raw bytes, explicit segments, GS1/FNC1, ECI, or structured append.
```bash
bun add @opentui/qrcode
```
## Encode text
```typescript
import { ErrorCorrectionLevel, QRCode } from "@opentui/qrcode"
const qr = QRCode.encodeText("https://opentui.com", ErrorCorrectionLevel.M)
console.log(`version=${qr.version} size=${qr.size} mask=${qr.mask}`)
console.log(qr.toTerminalString({ ansi: true }))
```
`encodeText()` chooses the first version in the configured range that fits, optimizes mixed text into numeric, alphanumeric, byte, and optionally Kanji segments, and automatically chooses the lowest-penalty mask unless one is forced.
The requested error-correction level defaults to M. Because `boostEcl` defaults to `true`, the resulting `qr.errorCorrectionLevel` may be upgraded when the same selected version has room for a stronger level.
## Encoding options
```typescript
interface EncodeOptions {
minVersion?: number
maxVersion?: number
mask?: number
boostEcl?: boolean
optimize?: boolean
kanji?: boolean
byteEncoding?: ByteEncoding
eciForUtf8?: boolean
eciAssignment?: number | null
fnc1First?: boolean
fnc1Second?: { applicationIndicator: string | number }
structuredAppend?: { position: number; total: number; parity: number }
}
```
| Option | Default | Description |
| ------------------ | ------------------------------ | ------------------------------------------------------------------------ |
| `minVersion` | `1` | Minimum QR Code Model 2 version, inclusive |
| `maxVersion` | `40` | Maximum version, inclusive |
| `mask` | automatic | Force mask `0` through `7`; otherwise choose by penalty score |
| `boostEcl` | `true` | Upgrade ECC without increasing the selected version when data still fits |
| `optimize` | `true` | Use mixed-mode dynamic-programming optimization for text |
| `kanji` | `false` | Permit Kanji mode for supported Shift JIS characters |
| `byteEncoding` | `"utf-8"` | Encoding used by text that falls back to byte segments |
| `eciForUtf8` | `true` for UTF-8 byte segments | Prefix UTF-8 byte-mode data with ECI assignment 26 |
| `eciAssignment` | inferred | Override the ECI before byte segments; `null` suppresses it |
| `fnc1First` | `false` | Insert FNC1 in first position |
| `fnc1Second` | - | Insert FNC1 in second position with an application indicator |
| `structuredAppend` | - | Insert a structured-append header |
`ByteEncoding` is `"utf-8" | "iso-8859-1" | "shift-jis"`. The exported `EciAssignment` constants are `ISO_8859_1: 3`, `SHIFT_JIS: 20`, and `UTF_8: 26`.
UTF-8 and Shift JIS text byte segments receive their known ECI by default. ISO-8859-1 receives no automatic ECI. Numeric, alphanumeric, and Kanji segments do not need a byte-encoding ECI. Use `encodeTextBytes(text, encoding)` when the encoded bytes themselves are needed.
## Other encoders
All encoder methods default to error-correction level M and `EncodeOptions = {}` unless shown otherwise.
| Method | Input and behavior |
| ------------------------------------------------------ | ------------------------------------------------------- |
| `QRCode.encodeText(text, ecl?, options?)` | Optimized text encoding |
| `QRCode.encodeBytes(bytes, ecl?, options?)` | One raw byte segment; does not add an ECI automatically |
| `QRCode.encodeEciText(text, ecl?, options?)` | Byte text with six-digit escaped ECI switches |
| `QRCode.encodeGs1Text(data, ecl?, options?)` | GS1 payload with FNC1 first position and no text ECI |
| `QRCode.encodeSegments(segments, ecl?, options?)` | Explicit segment sequence |
| `QRCode.encodeStructuredAppend(parts, ecl?, options?)` | Encode 216 arrays of segments with shared parity |
### Raw bytes and explicit ECI
For raw bytes that require an ECI designator, provide the ECI segment explicitly:
```typescript
import { EciAssignment, ErrorCorrectionLevel, QRCode, QrSegment } from "@opentui/qrcode"
const qr = QRCode.encodeSegments(
[QrSegment.makeEci(EciAssignment.ISO_8859_1), QrSegment.makeBytes([0x48, 0xe9])],
ErrorCorrectionLevel.M,
)
```
`QRCode.encodeEciText()` and `QrSegment.makeEciSegmentsFromEscapedText()` recognize a backslash followed by exactly six decimal digits as an ECI switch. A doubled backslash encodes one literal backslash. The segment helper defaults to ISO-8859-1 until a known ECI changes the encoding.
```typescript
const qr = QRCode.encodeEciText("\\000026Hello, 世界")
```
The encoder can signal any ECI assignment from 0 through 999999. It only performs text conversion for its known byte encodings; callers must provide already converted bytes for application-defined ECI transformations.
### GS1/FNC1
Pass a prebuilt GS1 payload string, or let `Gs1Element[]` join application identifiers and data. Parentheses are not encoded. Set `separatorAfter` where a field separator is required before another element:
```typescript
const qr = QRCode.encodeGs1Text([
{ ai: "10", data: "LOT42", separatorAfter: true },
{ ai: "17", data: "260731" },
])
```
For FNC1 second position, `applicationIndicator` accepts an integer from 0 to 99, exactly two decimal digits, or one Latin letter. `fnc1First` and `fnc1Second` cannot be used together.
### Structured append
```typescript
const parts = ["PART ONE", "PART TWO"].map((text) => QrSegment.makeSegments(text))
const symbols = QRCode.encodeStructuredAppend(parts)
```
`encodeStructuredAppend()` requires 216 parts, computes XOR parity from the source segment bytes, and adds the 1-based position and total to each symbol. For manual headers, use `QrSegment.makeStructuredAppendHeader(position, total, parity)` or `QRCode.computeStructuredAppendParity(segments)`.
## Segment API
`QrSegment` has a private constructor; create segments with its static methods:
| Method | Purpose |
| ----------------------------------------------------- | ----------------------------------------------- |
| `makeNumeric(digits)` | Numeric segment; digits only |
| `makeAlphanumeric(text)` | `0-9`, `A-Z`, space, and `$%*+-./:` |
| `makeBytes(bytes)` | Raw byte segment |
| `makeBytesFromText(text, encoding?)` | Encoded text byte segment; UTF-8 by default |
| `makeKanji(text)` | QR Kanji segment from supported characters |
| `makeKanjiFromShiftJis(bytes)` | QR Kanji segment from valid Shift JIS pairs |
| `makeEci(assignment)` | ECI designator from 0 through 999999 |
| `makeFnc1FirstPosition()` | FNC1 first-position segment |
| `makeFnc1SecondPosition(indicator)` | FNC1 second-position segment |
| `makeStructuredAppendHeader(position, total, parity)` | Structured-append header |
| `makeSegments(text, options?)` | One best whole-input mode, or byte fallback |
| `makeOptimizedSegments(text, version, options?)` | Mixed-mode optimization for a specific version |
| `makeEciSegmentsFromEscapedText(text, options?)` | Byte segments separated by escaped ECI switches |
Mode predicates `isNumeric()`, `isAlphanumeric()`, and `isKanji()` are also public. `segment.getTotalBits(version)` returns the encoded segment size for that version, or `Infinity` when its character count does not fit.
## Matrix and metadata
Encoded `QRCode` instances expose:
| Member | Description |
| ---------------------- | ------------------------------------------------------- |
| `version` | Model 2 version, 140 |
| `size` | Matrix side length, `version * 4 + 17` |
| `errorCorrectionLevel` | Actual ECC level after optional boosting |
| `mask` | Selected mask, 07 |
| `containsEci` | Whether the final segments contain ECI |
| `fnc1` | `"none"`, `"first"`, or `"second"` |
| `symbologyIdentifier` | AIM QR symbology identifier derived from ECI/FNC1 state |
| `getModule(x, y)` | Read one module; throws outside the matrix |
| `toMatrix()` | Deep-copy the matrix as `boolean[][]`; `true` is dark |
`QRCode.validateVersionPublic(version)` validates the 140 range. `QRCode.maskCondition(mask, x, y)` exposes the mask formulas for callers that inspect encoded matrices.
## Terminal output
```typescript
console.log(
qr.toTerminalString({
border: 4,
ansi: true,
invert: false,
}),
)
```
| Option | Default | Description |
| -------- | ------- | ------------------------------------------------------- |
| `border` | `4` | Quiet zone in modules; must be an integer of at least 4 |
| `ansi` | `false` | Paint explicit black/white ANSI backgrounds |
| `invert` | `false` | Swap light and dark output |
Each QR module occupies two terminal columns. The returned rows are joined with `"\n"` and have no trailing newline. A number passed directly to `toTerminalString(number)` is treated as the border with ANSI and inversion disabled.
## SVG output
```typescript
const svg = qr.toSvgString({
border: 4,
moduleSize: 8,
lightColor: "#ffffff",
darkColor: "#111827",
})
```
`toSvgString()` defaults to border 4, module size 1, light `#FFFFFF`, and dark `#000000`. The border must be an integer of at least 4. Pass a finite positive `moduleSize`: values less than or equal to zero throw, while the current implementation does not reject `NaN` or `Infinity` before producing unusable dimensions. Color strings are XML-escaped before insertion.
For a text-to-SVG shortcut:
```typescript
import { createQrSvg, ErrorCorrectionLevel } from "@opentui/qrcode"
const svg = createQrSvg("https://opentui.com", {
ecl: ErrorCorrectionLevel.H,
border: 4,
moduleSize: 8,
})
```
`createQrSvg()` defaults to ECC M, border 4, and module size 8; other encoding options are forwarded to `encodeText()`.
## Root exports and limits
The encoder exports from `@opentui/qrcode` are `QRCode`, `QrSegment`, `ErrorCorrectionLevel`, `EciAssignment`, `encodeTextBytes`, `createQrSvg`, and the types `ByteEncoding`, `EncodeOptions`, `StructuredAppendInfo`, `Fnc1SecondPositionInfo`, `Gs1Element`, and `TerminalRenderOptions`. The same root also exports `QRCodeRenderable`, `QRCodeOptions`, and `QRCodeFitMode` for OpenTUI rendering.
The implementation generates QR Code Model 2 versions 140. It does not generate legacy Model 1 or rMQR symbols and does not decode QR codes. Encoding throws when data cannot fit the requested version range and error-correction level. The SVG and terminal helpers enforce a minimum four-module quiet zone, but successful scanning still depends on output scale, contrast, terminal cell geometry, font, display, and camera conditions.
@@ -0,0 +1,321 @@
---
title: SSH
description: Serve imperative, React, or Solid OpenTUI applications over SSH
order: 8
skill:
entry: true
intents: [ssh, remote-tui, ssh-server, authentication, middleware]
---
# SSH
`@opentui/ssh` turns each accepted SSH shell into a `CliRenderer` whose input and output use the SSH channel and whose dimensions track the client's PTY. The package depends on `@opentui/core`, not React or Solid, so the same server can hand its renderer to any of the three APIs.
## Install and runtime
```bash
bun add @opentui/ssh @opentui/core
```
`@opentui/core` is a peer dependency. The SSH package declares support for Bun >= 1.3.0 and Node.js 26.4.0. Creating its native renderer under Node also follows the [OpenTUI Node runtime requirements](/docs/getting-started#runtime-support), including experimental FFI and any required permissions.
## Basic server
```typescript
import { BoxRenderable, TextRenderable } from "@opentui/core"
import { createServer } from "@opentui/ssh"
const server = createServer({
hostKey: { path: "./host_key" },
auth: { publicKey: "any" },
}).serve((session) => {
const box = new BoxRenderable(session.renderer, {
width: "100%",
height: "100%",
border: true,
borderStyle: "rounded",
})
box.add(new TextRenderable(session.renderer, { content: `Hello, ${session.identity.username}!` }))
session.renderer.root.add(box)
session.renderer.keyInput.on("keypress", (key) => {
if (key.name === "q" || (key.ctrl && key.name === "c")) session.end()
})
})
await server.listen(2222)
```
Connect from another terminal:
```bash
ssh -p 2222 localhost
```
The server owns the renderers it creates and destroys them when their sessions disconnect. Call `server.close()` when the process should stop accepting connections and close all live sessions.
## Builder lifecycle
The public construction sequence is:
```typescript
const builder = createServer(config)
const configured = builder.use(middleware)
const server = configured.serve(handler)
const info = await server.listen()
await server.close()
```
`createServer(config)` returns an immutable `ServerBuilder`. Every `.use()` returns a new builder with one more middleware and a widened context type. The builder intentionally has no `listen()` method. `.serve(handler)` seals the chain and returns the startable `Server`, so omitting the session handler is a type error.
`listen(port = 2222, host = "127.0.0.1")` resolves to:
```typescript
interface ListenInfo {
host: string
port: number
fingerprints: string[]
}
```
Pass port `0` to request an ephemeral port. An open server listening on anything other than `localhost`, `127.0.0.1`, or `::1` logs a warning but still listens.
## Server defaults
| Configuration | Default | Behavior |
| ------------------------------ | --------------------- | ------------------------------------------------------------------ |
| `auth` | `"open"` | Accept SSH `none` authentication |
| `hostKey` | ephemeral ed25519 key | Regenerated each server construction |
| `idleTimeout` | disabled | No inactivity timeout |
| `maxTimeout` | disabled | No absolute session timeout |
| `limits.session.perConnection` | `1` | Maximum live renderer-backed shells per SSH connection |
| `limits.session.global` | `100` | Maximum live renderer-backed shells across the server |
| `startupBanner` | `true` | Print listener, host-key, and authentication details after binding |
| `onError` | `console.error` | Report contained runtime errors |
| listen port | `2222` | Default SSH port for this server |
| listen host | `"127.0.0.1"` | Loopback-only default |
Session limits must be positive safe integers. Excess shell requests are rejected without closing the SSH connection or reporting a runtime error. Capacity remains occupied until that shell's transport teardown completes.
`idleTimeout` and `maxTimeout` accept integer milliseconds or strings with `ms`, `s`, `m`, or `h`, such as `"500ms"`, `"30s"`, or `"1h"`. Values must resolve to a positive safe integer from 1 millisecond through 24 hours. The idle timer is rearmed by client input; the maximum timer is an absolute lifetime.
## Host keys
Choose one host-key source:
```typescript
createServer({ hostKey: { path: "./host_key" } })
createServer({ hostKey: { pem: privateKeyPem } })
createServer({ hostKey: { pem: [ed25519Pem, rsaPem] } })
createServer() // ephemeral ed25519 key
```
- `path`: load the existing key, or generate and persist an ed25519 key on first use. On POSIX systems, generated directories use mode `0700` and the key uses `0600`; Windows uses the directory ACL.
- `pem`: accept one PEM/Buffer or an array. Every configured key produces a SHA256 fingerprint in `ListenInfo.fingerprints`, preserving input order.
- omitted: generate an ephemeral key that is not persisted.
Invalid or empty key configuration throws `ConfigError` while the server is being constructed.
## Authentication
`auth` is either `"open"` or an object containing one or more credential methods:
| Configuration | Behavior |
| -------------------------------------- | ------------------------------------------------------------------ |
| omit `auth` or use `"open"` | Accept unauthenticated sessions |
| `publicKey: "any"` | Verify proof of key ownership, then accept any verified key |
| `publicKey: { authorizedKeys }` | Accept keys from a file path or array of public-key lines |
| `publicKey: { allow }` | Run an async or synchronous policy after signature verification |
| `publicKey: { authorizedKeys, allow }` | Accept when the key is allowlisted **or** the policy returns true |
| `password` | Run `(ctx: { username, password }) => boolean \| Promise<boolean>` |
| `keyboardInteractive` | Run `(ctx: { username, prompt }) => boolean \| Promise<boolean>` |
An empty object configures no usable methods and throws `ConfigError`; use `"open"` deliberately for no authentication. Authorized-key files allow blank lines and comments, but OpenSSH options are not interpreted.
Public-key authentication verifies the client's signature before setting its fingerprint or invoking `allow`. The username is still supplied by the client and is not bound to that key. Authorize public-key users by the verified `fingerprint`, or explicitly enforce a username-to-key relationship in `allow`.
```typescript
const server = createServer({
auth: {
publicKey: {
authorizedKeys: "./authorized_keys",
allow: ({ username, fingerprint }) => username === "deploy" && trusted.has(fingerprint),
},
password: ({ username, password }) => username === "guest" && password === process.env.GUEST_PASSWORD,
},
}).serve((session) => {
if (session.identity.method === "publickey") {
console.log(session.identity.fingerprint)
}
})
```
If an authentication predicate throws, authentication fails closed and the error is reported through `onError`.
## Typed identity
`createServer()` infers `session.identity` from the configured authentication methods. `IdentityFor<A>` maps `"open"` to the `none` variant and an `AuthMethods` object to the union of the methods it enables:
```typescript
type Identity =
| { method: "none"; username: string }
| { method: "password"; username: string }
| { method: "keyboard-interactive"; username: string }
| {
method: "publickey"
username: string
fingerprint: string
publicKey: { algorithm: string; blob: Buffer }
}
```
A public-key-only configuration makes `fingerprint` directly available. Mixed methods produce a discriminated union:
```typescript
createServer({ auth: { publicKey: "any", password: checkPassword } }).serve((session) => {
if (session.identity.method === "publickey") {
console.log(session.identity.fingerprint)
}
})
```
## Session API
The handler passed to `.serve()` receives:
| Member | Description |
| -------------------- | ----------------------------------------------------------------------------- |
| `renderer` | Live `CliRenderer` connected to this SSH channel; handler-only |
| `identity` | Authentication identity narrowed from the server config |
| `context` | Per-session object accumulated by middleware; `{}` without middleware |
| `term` | Client terminal name, with `"xterm-256color"` as the no-PTY fallback |
| `cols`, `rows` | Current PTY dimensions; no-PTY fallback is `80x24` |
| `hasPty` | Whether the client requested a PTY |
| `remoteAddress` | Client `{ address, port? }` |
| `onResize(callback)` | Subscribe after renderer resize handling; returns an unsubscribe function |
| `onClose(callback)` | Subscribe to disconnect cleanup; returns an unsubscribe function |
| `write(data)` | Send raw `Buffer` or string bytes, bypassing frame diffing; no-op after close |
| `end()` | Close this session |
PTY dimensions are bounded by the implementation to 500 columns and 200 rows. Use `write()` only for terminal control that the renderer does not model, such as a bell or an OSC sequence.
Middleware receives `MiddlewareSession`, which has the common fields, `context`, and `deny()`, but no renderer. The renderer is created only after the middleware chain reaches the handler, so a denied session does not create one or enter the alternate screen.
## Middleware
Middleware executes in registration order, with the first registered function as the outermost layer. It supports three source-backed patterns:
```typescript
import { createServer, type Middleware } from "@opentui/ssh"
const timing: Middleware = async (session, next) => {
const startedAt = Date.now()
try {
return await next()
} finally {
console.log(`${session.identity.username}: ${Date.now() - startedAt}ms`)
}
}
const server = createServer({ auth: { publicKey: "any" } })
.use(timing)
.use((session, next) => {
if (blocked.has(session.identity.fingerprint)) session.deny("This key is not authorized.")
return next()
})
.use((session, next) => next({ tier: admins.has(session.identity.fingerprint) ? "admin" : "user" }))
.serve((session) => {
console.log(session.context.tier)
})
```
- Setup/teardown: `await next()` resolves when the session ends, so `finally` performs teardown.
- Gate: `session.deny(reason)` writes the reason on the main screen, closes the session, and unwinds through `DenyError` without reporting a failure.
- Enrich: `next({ ... })` adds typed fields to downstream `session.context`. Return the resulting `Handoff`.
Reusable middleware can use `Middleware`; use `MiddlewareFunction` when it requires a known upstream context type.
### Logging middleware
```typescript
import { createServer, logging } from "@opentui/ssh"
const events = []
createServer({ auth: { publicKey: "any" } })
.use(logging({ log: (event) => events.push(event) }))
.serve((session) => {
console.log(session.identity.username)
})
```
`logging()` emits a `connect` event on entry and a `disconnect` event with `durationMs` during teardown. Events also contain `identity`, `remoteAddress`, `term`, `cols`, and `rows`. Without a custom sink, it writes one formatted line to `console.log`. Sink failures are isolated and ignored; application and transport errors remain the responsibility of `onError`.
## React and Solid handoff
`@opentui/ssh` does not depend on either framework. Install the framework binding in the application and pass the existing session renderer to it.
### React
```tsx
import { createRoot } from "@opentui/react"
import { createServer } from "@opentui/ssh"
const App = ({ name }: { name: string }) => <text>Hello, {name}!</text>
createServer().serve((session) => {
const root = createRoot(session.renderer)
root.render(<App name={session.identity.username} />)
session.onClose(() => root.unmount())
})
```
Create one React root per session and unmount the app-owned React tree in `onClose`. The SSH package independently owns and destroys the `CliRenderer`.
### Solid
```tsx
import { render } from "@opentui/solid"
import { createServer } from "@opentui/ssh"
const App = (props: { name: string }) => <text>Hello, {props.name}!</text>
createServer().serve(async (session) => {
await render(() => <App name={session.identity.username} />, session.renderer)
})
```
Solid's `render(node, renderer)` adopts the existing renderer. The Solid root is disposed when that renderer is destroyed, so this handoff does not add a separate `onClose` disposer.
## Cleanup, errors, and shutdown
- Session disconnect destroys the renderer before `onClose` callbacks run. Use those callbacks for app-owned roots, timers, counters, and listeners, not renderer destruction.
- `server.close()` stops accepting connections, destroys live renderers, waits for their session teardown, and closes the SSH listener.
- `onError` reports contained handler, middleware, auth predicate, resize/close callback, connection, transport, and post-listen server errors. It defaults to `console.error`.
- A bind failure rejects `listen()` instead of going to `onError`.
- `logging()` observes connection lifecycle only; it is not an error sink.
- `SshError` carries a stable `code`; `ConfigError` uses `"CONFIG"` for invalid startup configuration.
- `DenyError` is intentional middleware control flow and is swallowed by session handling after delivering the reason.
## Public exports
Runtime exports from `@opentui/ssh`:
- `createServer`
- `logging`
- `SshError`
- `ConfigError`
- `DenyError`
Type exports:
- `AuthConfig`, `AuthMethods`
- `Identity`, `IdentityFor`, `PublicKey`, `PublicKeyPolicy`, `RemoteAddress`
- `Session`, `SessionHandler`, `MiddlewareSession`
- `Middleware`, `MiddlewareFunction`, `Next`, `Handoff`
- `ServerConfig`, `ServerBuilder`, `Server`, `ListenInfo`
- `LogEvent`, `LoggingOptions`
Internal helpers such as `isDeny`, `SessionCommon`, `CredentialMethods`, and `KeyboardPrompt` are not exported from the package root.
@@ -1,16 +1,27 @@
---
title: Standalone executables
description: Build OpenTUI apps with Bun's standalone executable compiler
description: Build OpenTUI apps as Bun executables or Node.js single executables
order: 4
skill:
entry: true
intents: [standalone, executable, bun-compile, node-sea, node-assets]
---
# Standalone executables
Bun can embed OpenTUI's native package binaries in `bun build --compile` executables when the native package import is statically analyzable.
OpenTUI supports Bun's standalone executable compiler and Node.js single executable applications (SEA). The two runtimes package native and Tree-sitter assets differently.
## Linux libc
## Bun
Linux uses the glibc native package by default. For musl builds, define `process.env.OPENTUI_LIBC` at build time so Bun can remove the unused branch and embed only the musl package.
Bun can embed OpenTUI's native library, parser worker, default grammars, and Tree-sitter WASM in `bun build --compile` executables. No asset extraction or `OTUI_ASSET_ROOT` setup is normally required.
```bash
bun build --compile ./app.ts --outfile app
```
### Linux libc
Linux uses the glibc native package by default. Define `process.env.OPENTUI_LIBC` at build time so Bun removes the unused native-package branch and embeds only the target libc.
```ts
await Bun.build({
@@ -25,28 +36,145 @@ await Bun.build({
})
```
Use `"glibc"` for glibc Linux builds:
Use `"glibc"` for a glibc target. If the variable is not defined at build time, Bun must retain both runtime selection branches, so both native packages for that architecture may be required.
```ts
define: {
"process.env.OPENTUI_LIBC": JSON.stringify("glibc"),
}
```
Make sure every target native package is installed before compiling. Multi-platform release builds can install optional packages for all supported OS and CPU combinations:
If `process.env.OPENTUI_LIBC` is not defined at build time, Bun must keep both Linux native package branches for that architecture because runtime environment selection is still possible.
## Native Packages
Make sure the target native packages are present in `node_modules` before compiling. For multi-platform release builds, install optional native packages for all target OS and CPU combinations before running `Bun.build()`.
```sh
```bash
bun install --os="*" --cpu="*" @opentui/core@<version>
```
## Alpine
On Alpine, Bun's Linux musl executable can require the standard C++ runtime libraries:
Bun's Linux musl standalone runtime can require the standard C++ runtime libraries on Alpine. Install them in minimal Alpine images:
```sh
```bash
apk add --no-cache libstdc++ libgcc
```
## Node.js SEA
OpenTUI supports Node.js 26.4.0 single executable applications with experimental FFI. Unlike Bun, Node SEA assets are bytes inside the executable. OpenTUI's native library and worker need filesystem paths, so the application must extract the OpenTUI assets before bundled OpenTUI code executes.
The build flow is:
1. Bundle the application as one Node-targeted ESM file.
2. Call `getNodeAssets()` at build time for the target platform, architecture, and Linux libc.
3. Add every returned `{ key, source }` entry to the SEA `assets` map without changing its key.
4. Prepend startup code that extracts those assets and sets `OTUI_ASSET_ROOT` to the absolute extraction directory.
5. Build the SEA with `--experimental-ffi` in `execArgv`.
`getNodeAssets()` is an ESM, build-time manifest API. Do not call it from inside the finished SEA: it resolves installed packages and verifies source files on disk.
```js
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { resolve } from "node:path"
import { getNodeAssets } from "@opentui/core/node-assets"
const buildDir = resolve("build")
mkdirSync(buildDir, { recursive: true })
const libc = process.platform === "linux" && process.env.OPENTUI_LIBC === "musl" ? "musl" : "glibc"
const assets = getNodeAssets({
platform: process.platform,
arch: process.arch,
...(process.platform === "linux" ? { libc } : {}),
})
run("bun", ["build", "./app.ts", "--target=node", `--outfile=${resolve(buildDir, "bundle.mjs")}`])
const hash = createHash("sha256")
for (const asset of assets) {
hash.update(asset.key)
hash.update(readFileSync(asset.source))
}
const assetVersion = hash.digest("hex").slice(0, 16)
const prelude = `
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { getRawAsset, isSea } from "node:sea"
if (!isSea()) throw new Error("Expected a Node SEA executable")
const assetRoot = join(tmpdir(), ${JSON.stringify(`opentui-assets-${assetVersion}`)})
for (const key of ${JSON.stringify(assets.map(({ key }) => key))}) {
const target = join(assetRoot, key)
if (existsSync(target)) continue
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, new Uint8Array(getRawAsset(key)))
}
process.env.OTUI_ASSET_ROOT = assetRoot
${process.platform === "linux" ? `process.env.OPENTUI_LIBC = ${JSON.stringify(libc)}` : ""}
`
const seaMain = resolve(buildDir, "sea-main.mjs")
writeFileSync(seaMain, prelude + readFileSync(resolve(buildDir, "bundle.mjs"), "utf8"))
const output = resolve(buildDir, process.platform === "win32" ? "app.exe" : "app")
const config = {
main: seaMain,
mainFormat: "module",
executable: process.execPath,
output,
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: false,
execArgv: ["--experimental-ffi", "--no-warnings"],
execArgvExtension: "none",
assets: Object.fromEntries(assets.map(({ key, source }) => [key, source])),
}
const configPath = resolve(buildDir, "sea-config.json")
writeFileSync(configPath, JSON.stringify(config, null, 2))
run(process.execPath, ["--build-sea", configPath])
function run(command, args) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} failed with status ${result.status}`)
}
```
Run this build file with Node.js 26.4.0. The example builds for the host platform and architecture and uses Bun only as the ESM bundler. The matching optional native package must be installed.
The application uses normal OpenTUI imports:
```typescript
import { createCliRenderer, Text } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.root.add(Text({ content: "Hello from Node SEA" }))
```
### Node asset manifest
```ts
type NodeAssetTarget = {
platform: "darwin" | "linux" | "win32"
arch: "arm64" | "x64"
libc?: "glibc" | "musl"
}
type NodeAsset = {
readonly key: string
readonly source: string
}
```
`source` is an absolute path to an existing build-time file. The manifest includes the selected native library, parser worker, current default grammar/query assets, and Tree-sitter WASM. Its keys are validated, unique, sorted, and relocatable. Do not depend on a fixed asset count.
`libc` is valid only for Linux and defaults to glibc when omitted. Unsupported targets, invalid libc combinations, missing native packages, and missing files throw while generating the manifest.
### Extraction ownership
- `OTUI_ASSET_ROOT` must be absolute and must be set before OpenTUI's bundled module body executes.
- Files beneath it must use the exact manifest keys. If a requested file is missing, OpenTUI throws instead of falling back to installed package paths.
- The extraction destination must be writable. The application owns caching, concurrent-process coordination, cleanup, permissions, and integrity policy.
- Keep `mainFormat: "module"`, `useSnapshot: false`, and `useCodeCache: false`. The tested configuration depends on ESM and runtime `import()` support.
- Cross-target builds require a matching target Node executable and matching native package. The repository acceptance test builds and runs for its host.
- Node SEA and Node FFI are experimental Node.js features. Code signing and platform distribution remain application responsibilities.
See [Environment variables](/docs/reference/env-vars#otui_asset_root) for the runtime override.
@@ -0,0 +1,262 @@
---
title: Three.js WebGPU
description: Render Three.js WebGPU scenes into OpenTUI buffers
order: 9
skill:
entry: true
intents: [three, threejs, webgpu, 3d, sprites, physics]
---
# Three.js WebGPU
`@opentui/three` connects Three.js's WebGPU renderer to OpenTUI buffers. Use `ThreeRenderable` to place a scene inside the normal renderable tree, or use `ThreeCliRenderer` directly when the scene should draw into a buffer you manage.
## Bun-only runtime
```bash
bun add @opentui/three
```
The package declares Bun >= 1.3.0 and imports `bun-webgpu` directly. It does not declare Node support, and OpenTUI's Node examples bundle disables the Three examples. Treat both `@opentui/three` entrypoints as Bun-only.
The package exports only:
- `@opentui/three`
- `@opentui/three/runtime-modules`
It does not provide React or Solid component subpaths. `ThreeRenderable` is the OpenTUI integration surface.
## ThreeRenderable
This example follows the package's rotating-cube examples while using the `THREE` namespace re-export:
```typescript
import { RGBA, createCliRenderer } from "@opentui/core"
import { THREE, ThreeRenderable } from "@opentui/three"
const renderer = await createCliRenderer({ targetFps: 60 })
renderer.start()
const scene = new THREE.Scene()
scene.add(new THREE.AmbientLight(new THREE.Color(0.35, 0.35, 0.35), 1))
const light = new THREE.DirectionalLight(new THREE.Color(1, 0.95, 0.9), 1.2)
light.position.set(2.5, 2, 3)
scene.add(light)
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshPhongMaterial({ color: new THREE.Color(0.25, 0.8, 1) }),
)
scene.add(cube)
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100)
camera.position.set(0, 0, 3)
const view = new ThreeRenderable(renderer, {
width: "100%",
height: "100%",
scene,
camera,
renderer: {
focalLength: 8,
alpha: true,
backgroundColor: RGBA.fromValues(0, 0, 0, 0),
},
})
renderer.root.add(view)
renderer.setFrameCallback(async (deltaMs) => {
cube.rotation.x += 0.6 * (deltaMs / 1000)
cube.rotation.y += 0.4 * (deltaMs / 1000)
})
```
### Options and defaults
```typescript
interface ThreeRenderableOptions extends RenderableOptions<ThreeRenderable> {
scene?: THREE.Scene | null
camera?: THREE.PerspectiveCamera | THREE.OrthographicCamera
renderer?: Omit<ThreeCliRendererOptions, "width" | "height" | "autoResize">
autoAspect?: boolean
}
```
| Option | Default | Description |
| -------------------- | --------------------- | -------------------------------------------------------- |
| `scene` | `null` | Scene drawn by the renderable |
| `camera` | engine default camera | Perspective or orthographic active camera |
| `renderer` | engine defaults | Three renderer settings excluding size and auto-resize |
| `autoAspect` | `true` | Update a perspective camera's aspect on layout resize |
| inherited `live` | `true` | Keep the CLI render loop live unless explicitly disabled |
| inherited `buffered` | forced `true` | Draw through the renderable's frame buffer |
The nested renderer background defaults to opaque black. `ThreeRenderable` forces its engine's `autoResize` off because layout resize events call `setSize()` directly.
### Lifecycle and API
`ThreeRenderable` requires a real `CliRenderer` context. It registers a CLI frame callback at construction, but does not initialize WebGPU until it has a scene, a frame buffer, and a draw to perform. Initialization failure is logged once and later frames do not retry it. Concurrent draws for the same renderable are skipped.
On resize, positive dimensions update the engine. With `autoAspect: true`, perspective cameras receive the renderable's display-aware aspect ratio and `updateProjectionMatrix()`; orthographic camera bounds are not changed automatically.
Public members:
| Member | Description |
| ----------------------------------------------- | --------------------------------------------- |
| `aspectRatio` | Current display-aware renderable aspect ratio |
| `renderer` | Underlying `ThreeCliRenderer` |
| `getScene()` / `setScene(scene)` | Read or replace the scene |
| `getActiveCamera()` / `setActiveCamera(camera)` | Read or replace the active camera |
| `setAutoAspect(enabled)` | Enable or disable perspective aspect updates |
Destroying the renderable removes its frame callback, destroys its `ThreeCliRenderer`, and then performs normal renderable cleanup.
## ThreeCliRenderer
Use `ThreeCliRenderer` directly when you need to choose the destination `OptimizedBuffer` on every frame:
```typescript
import { RGBA } from "@opentui/core"
import { THREE, ThreeCliRenderer } from "@opentui/three"
const engine = new ThreeCliRenderer(renderer, {
width: renderer.terminalWidth,
height: renderer.terminalHeight,
focalLength: 8,
backgroundColor: RGBA.fromValues(0, 0, 0, 1),
})
await engine.init()
engine.setActiveCamera(camera)
renderer.setFrameCallback(async (deltaMs) => {
await engine.drawScene(scene, renderer.nextRenderBuffer, deltaMs / 1000)
})
```
Unlike `ThreeRenderable`, direct use requires an explicit `await engine.init()` before canvas-dependent methods such as screenshots, supersampling configuration, or actual scene rendering. The engine registers for `CliRenderer` destruction and destroys itself with the host renderer; call `destroy()` yourself when ending its lifetime earlier.
### Options and defaults
```typescript
interface ThreeCliRendererOptions {
width: number
height: number
focalLength?: number
backgroundColor?: RGBA
superSample?: SuperSampleType
alpha?: boolean
autoResize?: boolean
libPath?: string
}
```
| Option | Default | Description |
| ----------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `width`, `height` | required | Output dimensions in terminal cells |
| `focalLength` | omitted | If omitted, the default perspective camera uses a 1-degree FOV; otherwise FOV is derived from output height and focal length |
| `backgroundColor` | opaque black | Three renderer clear color |
| `superSample` | `SuperSampleType.GPU` | `"none"`, `"gpu"`, or `"cpu"` |
| `alpha` | `false` | Enable alpha in the Three WebGPU renderer |
| `autoResize` | `true` | Follow host `CliRenderer` resize events |
| `libPath` | - | Passed to `bun-webgpu` global setup |
When CPU or GPU supersampling is active, internal render dimensions are twice the output width and height. The default supersampling algorithm is `SuperSampleAlgorithm.STANDARD`; the alternative is `PRE_SQUEEZED`.
The engine's default camera is a perspective camera at `(0, 0, 3)`, looking at the origin, with near `0.1` and far `1000`. `CELL_ASPECT_RATIO`, when present, overrides its computed aspect ratio. Otherwise it uses the CLI renderer's pixel resolution when available, then falls back to terminal width divided by twice terminal height.
After initialization, the Three renderer uses `NoToneMapping` and `LinearSRGBColorSpace`.
### Methods
| Method | Description |
| -------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `init()` | Create the WebGPU device, CLI canvas, and Three WebGPU renderer |
| `drawScene(scene, buffer, deltaTime)` | Render the active camera into an `OptimizedBuffer` |
| `setActiveCamera(camera)` / `getActiveCamera()` | Manage the active camera |
| `setBackgroundColor(color)` | Change the Three clear color |
| `setSize(width, height, forceUpdate = false)` | Resize output, canvas, viewport, and camera projection |
| `toggleSuperSampling()` | Cycle none -> CPU -> GPU -> none |
| `getSuperSampleAlgorithm()` / `setSuperSampleAlgorithm(value)` | Read or change the supersampling algorithm |
| `saveToFile(path)` | Save the current canvas texture through Jimp |
| `toggleDebugStats()` | Toggle renderer timing text |
| `renderStats(buffer)` | Draw current timing values into a buffer |
| `destroy()` | Remove resize/debug listeners and dispose the canvas and Three renderer |
Concurrent `drawScene()` calls are not supported; the implementation warns and skips the overlapping draw. The host renderer's debug-overlay toggle also controls Three timing stats.
## Texture and sprite helpers
The root package exports these additional surfaces:
### Textures and basic sprites
- `TextureUtils.loadTextureFromFile(path)` and alias `fromFile(path)` load a Jimp-decoded `DataTexture`, vertically flip the source image, and return `null` after logging a load failure.
- `TextureUtils.createCheckerboard()`, `createGradient()`, and `createNoise()` create procedural textures. Their default size is 256 and they use nearest filtering with clamp-to-edge wrapping.
- `SpriteUtils.fromFile()` creates a Three `Sprite`; its default material parameters are `alphaTest: 0.1` and `depthWrite: true`.
- `SpriteUtils.sheetFromFile()` and `SheetSprite.setIndex()` address a horizontal sprite sheet.
### Resources and instancing
- Types: `ResourceConfig`, `SheetProperties`, `InstanceManagerOptions`, `MeshPoolOptions`.
- Classes: `MeshPool`, `InstanceManager`, `SpriteResource`, `SpriteResourceManager`.
- `SpriteResourceManager.createResource({ imagePath, sheetNumFrames })` loads/caches a texture and creates a sheet resource.
- `InstanceManager` allocates slots in one Three `InstancedMesh`; its `renderOrder` defaults to `0` and `frustumCulled` defaults to `false`.
### Sprite animation
- Types: `AnimationStateConfig`, `ResolvedAnimationState`, `AnimationDefinition`, `SpriteDefinition`.
- Classes: `SpriteAnimator`, `TiledSprite`.
- `SpriteAnimator.createSprite()` creates an instanced tiled sprite and `update(deltaTime)` advances all managed sprites.
- Animation defaults are frame duration 100 ms, frame offset 0, loop enabled, initial frame 0, and no horizontal or vertical flip.
- Sprite defaults are generated IDs, scale `1`, maximum 1024 instances, render order `0`, and depth writing enabled.
- `TiledSprite` exposes transform, animation, playback, visibility, frame, and destruction controls.
### Particles and explosions
- `SpriteParticleGenerator` and `ParticleEffectParameters` provide instanced sprite particles with explicit capacity, lifetime, origins, velocity, angular velocity, and spawn radius. Optional defaults are resolved by its implementation; required effect fields have no package-wide defaults.
- `ExplodingSpriteEffect`, `ExplosionManager`, `ExplosionEffectParameters`, creation/recreation data, and `ExplosionHandle` implement GPU sprite explosions. `DEFAULT_EXPLOSION_PARAMETERS` is exported.
- `PhysicsExplodingSpriteEffect`, `PhysicsExplosionManager`, their parameter/data/handle types, and `DEFAULT_PHYSICS_EXPLOSION_PARAMETERS` implement the physics-backed variant.
The regular explosion default is a 5x5 grid lasting 2000 ms with strength 5, gravity 9.8, and fade-out enabled. The physics default is a 5x5 grid lasting 3000 ms with explosion force 25, torque strength 15, and fade-out enabled. Import the exported default objects for the complete current parameter sets instead of duplicating them.
### Physics adapters
- `RapierRigidBody` and `RapierPhysicsWorld` adapt `@dimforge/rapier2d-simd-compat` bodies/worlds.
- `PlanckRigidBody` and `PlanckPhysicsWorld` adapt `planck` bodies/worlds.
- Both dependencies are optional package dependencies.
The shared `PhysicsWorld`, `PhysicsRigidBody`, and descriptor interfaces live in an internal module and are not re-exported from `@opentui/three`. Do not rely on importing those interface names from the package root.
### Low-level canvas and Three namespace
`CLICanvas` is the `bun-webgpu` canvas/readback implementation used by `ThreeCliRenderer`; it is exported along with `SuperSampleAlgorithm`. The root also exports `THREE`, a namespace containing the installed `three` package.
## Runtime-loaded plugins
`@opentui/three/runtime-modules` exports a side-effect-free module map for OpenTUI's runtime plugin support:
```typescript
import { ensureRuntimePluginSupport } from "@opentui/core/runtime-plugin-support/configure"
import { runtimeModules as threeRuntimeModules } from "@opentui/three/runtime-modules"
ensureRuntimePluginSupport({
additional: threeRuntimeModules,
})
```
The map contains the `@opentui/three` root module. It does not provide separate mappings for `three`, `three/webgpu`, or `three/tsl`.
## Public export groups
The root export groups are:
- Rendering: `ThreeRenderable`, `ThreeRenderableOptions`, `ThreeCliRenderer`, `ThreeCliRendererOptions`, `SuperSampleType`.
- Canvas: `CLICanvas`, `SuperSampleAlgorithm`.
- Textures/sprites: `TextureUtils`, `SpriteUtils`, `SheetSprite`.
- Resource pools: `MeshPool`, `InstanceManager`, `SpriteResource`, `SpriteResourceManager`, and their option/config types.
- Animation: `SpriteAnimator`, `TiledSprite`, and animation/sprite definition types.
- Effects: sprite particle, regular explosion, and physics explosion classes, handles, data, parameters, and default parameter objects.
- Physics adapters: Rapier and Planck world/body wrappers.
- Three.js: `THREE` namespace re-export.
+26 -15
View File
@@ -25,7 +25,7 @@ export interface DocPage {
title: string
navTitle: string
description?: string
order?: number
order: number
skill: SkillMetadata
}
@@ -148,6 +148,15 @@ async function buildDocPage(filePath: string): Promise<DocPage> {
if (typeof raw.title !== "string") {
throw new Error(`Missing or invalid title in ${toSourcePath(filePath)}`)
}
if (typeof raw.order !== "number" || !Number.isInteger(raw.order) || raw.order <= 0) {
throw new Error(`Missing or invalid positive integer order in ${toSourcePath(filePath)}`)
}
if (raw.description !== undefined && typeof raw.description !== "string") {
throw new Error(`Invalid description in ${toSourcePath(filePath)}`)
}
if (raw.navTitle !== undefined && typeof raw.navTitle !== "string") {
throw new Error(`Invalid navTitle in ${toSourcePath(filePath)}`)
}
const sourcePath = toSourcePath(filePath)
const slug = toSlug(filePath)
@@ -163,7 +172,7 @@ async function buildDocPage(filePath: string): Promise<DocPage> {
title: raw.title,
navTitle: typeof raw.navTitle === "string" ? raw.navTitle : raw.title,
description: typeof raw.description === "string" ? raw.description : undefined,
order: typeof raw.order === "number" ? raw.order : undefined,
order: raw.order,
skill,
}
}
@@ -259,7 +268,7 @@ function parseSimpleYamlValue(rawValue: string): unknown {
}
if (value.startsWith("[") && value.endsWith("]")) {
return splitInlineArray(value.slice(1, -1)).map((item) => String(parseSimpleYamlValue(item)))
return splitInlineArray(value.slice(1, -1)).map((item) => parseSimpleYamlValue(item))
}
return value
@@ -305,10 +314,20 @@ function normalizeSkill(rawSkill: unknown, sourcePath: string): SkillMetadata {
}
const skill = rawSkill as RawSkillMetadata
if (skill.include !== undefined && typeof skill.include !== "boolean") {
throw new Error(`Invalid skill.include in ${sourcePath}`)
}
if (skill.entry !== undefined && typeof skill.entry !== "boolean") {
throw new Error(`Invalid skill.entry in ${sourcePath}`)
}
if (skill.intents !== undefined && !Array.isArray(skill.intents)) {
throw new Error(`Invalid skill.intents in ${sourcePath}`)
}
if (Array.isArray(skill.intents) && skill.intents.some((value) => typeof value !== "string" || !value.trim())) {
throw new Error(`skill.intents must contain non-empty strings in ${sourcePath}`)
}
const rawIntents = skill.intents
const intents = Array.isArray(rawIntents)
? rawIntents.map((value) => String(value).trim().toLowerCase()).filter((value) => value.length > 0)
: []
const intents = Array.isArray(rawIntents) ? rawIntents.map((value) => value.trim().toLowerCase()) : []
return {
include: typeof skill.include === "boolean" ? skill.include : true,
@@ -354,15 +373,7 @@ function comparePages(left: DocPage, right: DocPage): number {
return sectionDelta
}
if (left.order === undefined && right.order !== undefined) {
return 1
}
if (left.order !== undefined && right.order === undefined) {
return -1
}
if (left.order !== undefined && right.order !== undefined && left.order !== right.order) {
if (left.order !== right.order) {
return left.order - right.order
}