
Opentui
- 3 installs
- 1 repo stars
- Updated July 28, 2026
- evanfang0054/cc-system-creator-scripts
Helps with ai & agent building tasks.
About
opentui is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
- opentui
- AI & Agent Building
- AI-coding skill
Opentui by the numbers
- 3 all-time installs (skills.sh)
- Ranked #13,657 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/evanfang0054/cc-system-creator-scripts --skill opentuiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 28, 2026 |
| Repository | evanfang0054/cc-system-creator-scripts ↗ |
What it does
Helps with ai & agent building tasks.
Files
OpenTUI 平台技能
使用 OpenTUI 构建终端用户界面的综合技能。使用下面的决策树找到合适的框架和组件,然后加载详细的参考文档。
关键规则
在所有 OpenTUI 代码中遵循这些规则:
1. 使用 `create-tui` 创建新项目。 参见框架 REFERENCE.md 快速入门。 2. `create-tui` 选项必须在参数之前。 bunx create-tui -t react my-app 有效,bunx create-tui my-app -t react 无效。 3. 永远不要直接调用 `process.exit()`。 使用 renderer.destroy()(参见 core/gotchas.md)。 4. 文本样式在 React/Solid 中需要嵌套标签。 使用修饰符元素,而不是 props(参见 components/text-display.md)。
如何使用此技能
参考文件结构
框架参考文档遵循 5 文件模式。跨领域概念是单文件指南。
./references/<framework>/ 中的每个框架包含:
| 文件 | 用途 | 何时阅读 |
|---|---|---|
REFERENCE.md | 概述、何时使用、快速入门 | 始终首先阅读 |
api.md | 运行时 API、组件、钩子 | 编写代码时 |
configuration.md | 设置、tsconfig、打包 | 配置项目时 |
patterns.md | 常见模式、最佳实践 | 实现指导 |
gotchas.md | 陷阱、限制、调试 | 故障排除时 |
./references/<concept>/ 中的跨领域概念以 REFERENCE.md 为入口点。
阅读顺序
1. 从所选框架的 REFERENCE.md 开始 2. 然后阅读与任务相关的其他文件:
- 构建组件 ->
api.md+components/<category>.md - 设置项目 ->
configuration.md - 布局/定位 ->
layout/REFERENCE.md - 故障排除 ->
gotchas.md+testing/REFERENCE.md
示例路径
./references/react/REFERENCE.md # React 从这里开始
./references/react/api.md # React 组件和钩子
./references/solid/configuration.md # Solid 项目设置
./references/components/inputs.md # Input、Textarea、Select 文档
./references/core/gotchas.md # 核心调试技巧运行时说明
OpenTUI 在 Bun 上运行,并使用 Zig 进行原生构建。阅读 ./references/core/gotchas.md 了解运行时要求和构建指南。
快速决策树
"我应该使用哪个框架?"
哪个框架?
├─ 我想要完全控制、最大性能、无框架开销
│ └─ core/ (命令式 API)
├─ 我了解 React,想要熟悉的组件模式
│ └─ react/ (React reconciler)
├─ 我想要细粒度响应性、最佳重渲染
│ └─ solid/ (Solid reconciler)
└─ 我正在 OpenTUI 之上构建库/框架
└─ core/ (命令式 API)"我需要显示内容"
显示内容?
├─ 纯文本或样式文本 -> components/text-display.md
├─ 带边框/背景的容器 -> components/containers.md
├─ 可滚动内容区域 -> components/containers.md (scrollbox)
├─ ASCII 艺术横幅/标题 -> components/text-display.md (ascii-font)
├─ 带语法高亮的代码 -> components/code-diff.md
├─ 差异查看器(统一/拆分) -> components/code-diff.md
└─ 带诊断的行号 -> components/code-diff.md"我需要用户输入"
用户输入?
├─ 单行文本字段 -> components/inputs.md (input)
├─ 多行文本编辑器 -> components/inputs.md (textarea)
├─ 从列表中选择(垂直) -> components/inputs.md (select)
├─ 基于标签的选择(水平) -> components/inputs.md (tab-select)
└─ 自定义键盘快捷键 -> keyboard/REFERENCE.md"我需要布局/定位"
布局?
├─ Flexbox 样式布局(行、列、换行) -> layout/REFERENCE.md
├─ 绝对定位 -> layout/patterns.md
├─ 响应终端大小 -> layout/patterns.md
├─ 内容居中 -> layout/patterns.md
└─ 复杂嵌套布局 -> layout/patterns.md"我需要动画"
动画?
├─ 基于时间轴的动画 -> animation/REFERENCE.md
├─ 缓动函数 -> animation/REFERENCE.md
├─ 属性过渡 -> animation/REFERENCE.md
└─ 循环动画 -> animation/REFERENCE.md"我需要处理输入"
输入处理?
├─ 键盘事件(按键、释放) -> keyboard/REFERENCE.md
├─ 焦点管理 -> keyboard/REFERENCE.md
├─ 粘贴事件 -> keyboard/REFERENCE.md
├─ 鼠标事件 -> components/containers.md
└─ 文本选择 -> components/text-display.md"我需要测试我的 TUI"
测试?
├─ 快照测试 -> testing/REFERENCE.md
├─ 交互测试 -> testing/REFERENCE.md
├─ 测试渲染器设置 -> testing/REFERENCE.md
└─ 调试测试 -> testing/REFERENCE.md"我需要调试/故障排除"
故障排除?
├─ 运行时错误、崩溃 -> <framework>/gotchas.md
├─ 布局问题 -> layout/REFERENCE.md + layout/patterns.md
├─ 输入/焦点问题 -> keyboard/REFERENCE.md
└─ 复现 + 回归测试 -> testing/REFERENCE.md故障排除索引
- 终端清理、崩溃 ->
core/gotchas.md - 文本样式未应用 ->
components/text-display.md - 输入焦点/快捷键 ->
keyboard/REFERENCE.md - 布局错位 ->
layout/REFERENCE.md - 不稳定的快照 ->
testing/REFERENCE.md
有关组件命名差异和文本修饰符,请参见 components/REFERENCE.md。
产品索引
框架
| 框架 | 入口文件 | 描述 |
|---|---|---|
| Core | ./references/core/REFERENCE.md | 命令式 API、所有原语 |
| React | ./references/react/REFERENCE.md | 声明式 TUI 的 React reconciler |
| Solid | ./references/solid/REFERENCE.md | 声明式 TUI 的 SolidJS reconciler |
跨领域概念
| 概念 | 入口文件 | 描述 |
|---|---|---|
| 布局 | ./references/layout/REFERENCE.md | Yoga/Flexbox 布局系统 |
| 组件 | ./references/components/REFERENCE.md | 按类别分类的组件参考 |
| 键盘 | ./references/keyboard/REFERENCE.md | 键盘输入处理 |
| 动画 | ./references/animation/REFERENCE.md | 基于时间轴的动画 |
| 测试 | ./references/testing/REFERENCE.md | 测试渲染器和快照 |
组件类别
| 类别 | 入口文件 | 组件 |
|---|---|---|
| 文本与显示 | ./references/components/text-display.md | text、ascii-font、styled text |
| 容器 | ./references/components/containers.md | box、scrollbox、borders |
| 输入 | ./references/components/inputs.md | input、textarea、select、tab-select |
| 代码与差异 | ./references/components/code-diff.md | code、line-number、diff |
资源
仓库: https://github.com/anomalyco/opentui 核心文档: https://github.com/anomalyco/opentui/tree/main/packages/core/docs 示例: https://github.com/anomalyco/opentui/tree/main/packages/core/src/examples 精选列表: https://github.com/msmps/awesome-opentui
动画系统
OpenTUI 提供基于时间轴的动画系统,用于平滑的属性过渡。
概述
OpenTUI 中的动画使用:
- Timeline(时间轴):编排多个动画
- Animation Engine(动画引擎):管理时间轴和渲染
- Easing Functions(缓动函数):控制动画曲线
使用场景
当您需要基于时间轴的动画、缓动曲线或渐进式过渡时,请参考此文档。
基本用法
React
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function AnimatedBox() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
})
useEffect(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
}, [])
return (
<box
width={width}
height={3}
backgroundColor="#6a5acd"
/>
)
}Solid
import { useTimeline } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
function AnimatedBox() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
})
onMount(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
})
return (
<box
width={width()}
height={3}
backgroundColor="#6a5acd"
/>
)
}Core
import { createCliRenderer, Timeline, engine } from "@opentui/core"
const renderer = await createCliRenderer()
engine.attach(renderer)
const timeline = new Timeline({
duration: 2000,
autoplay: true,
})
timeline.add(
{ x: 0 },
{
x: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
box.setLeft(Math.round(anim.targets[0].x))
},
}
)
engine.addTimeline(timeline)Timeline 选项
const timeline = useTimeline({
duration: 2000, // 总持续时间(毫秒)
loop: false, // 循环播放时间轴
autoplay: true, // 自动开始
onComplete: () => {}, // 时间轴完成时调用
onPause: () => {}, // 时间轴暂停时调用
})Timeline 方法
// 添加动画
timeline.add(target, properties, startTime?)
// 控制播放
timeline.play() // 开始/恢复
timeline.pause() // 暂停
timeline.restart() // 从头重新开始
// 状态
timeline.progress // 当前进度(0-1)
timeline.duration // 总持续时间动画属性
timeline.add(
{ value: 0 }, // 带有初始值的目标对象
{
value: 100, // 最终值
duration: 1000, // 动画持续时间(毫秒)
ease: "linear", // 缓动函数
delay: 0, // 开始前的延迟
onUpdate: (anim) => {
// 每帧调用
const current = anim.targets[0].value
},
onComplete: () => {
// 此动画完成时调用
},
},
0 // 时间轴中的开始时间(可选)
)缓动函数
可用的缓动函数:
Linear(线性)
| 名称 | 描述 |
|---|---|
linear | 恒定速度 |
Quad(二次方)
| 名称 | 描述 |
|---|---|
easeInQuad | 慢速开始 |
easeOutQuad | 慢速结束 |
easeInOutQuad | 慢速开始和结束 |
Cubic(三次方)
| 名称 | 描述 |
|---|---|
easeInCubic | 更慢的开始 |
easeOutCubic | 更慢的结束 |
easeInOutCubic | 更慢的开始和结束 |
Quart(四次方)
| 名称 | 描述 |
|---|---|
easeInQuart | 非常慢的开始 |
easeOutQuart | 非常慢的结束 |
easeInOutQuart | 非常慢的开始和结束 |
Expo(指数)
| 名称 | 描述 |
|---|---|
easeInExpo | 指数级开始 |
easeOutExpo | 指数级结束 |
easeInOutExpo | 指数级开始和结束 |
Back(回弹)
| 名称 | 描述 |
|---|---|
easeInBack | 先回拉再前进 |
easeOutBack | 超出目标后回弹 |
easeInOutBack | 两者结合 |
Elastic(弹性)
| 名称 | 描述 |
|---|---|
easeInElastic | 弹性开始 |
easeOutElastic | 弹性结束(有弹性) |
easeInOutElastic | 两者结合 |
Bounce(弹跳)
| 名称 | 描述 |
|---|---|
easeInBounce | 开始时弹跳 |
easeOutBounce | 结束时弹跳 |
easeInOutBounce | 两者结合 |
常见模式
进度条
function ProgressBar({ progress }: { progress: number }) {
const [width, setWidth] = useState(0)
const maxWidth = 50
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ value: width },
{
value: (progress / 100) * maxWidth,
duration: 300,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].value))
},
}
)
}, [progress])
return (
<box flexDirection="column" gap={1}>
<text>Progress: {progress}%</text>
<box width={maxWidth} height={1} backgroundColor="#333">
<box width={width} height={1} backgroundColor="#00FF00" />
</box>
</box>
)
}淡入效果
function FadeIn({ children }) {
const [opacity, setOpacity] = useState(0)
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ opacity: 0 },
{
opacity: 1,
duration: 500,
ease: "easeOutQuad",
onUpdate: (anim) => {
setOpacity(anim.targets[0].opacity)
},
}
)
}, [])
return (
<box style={{ opacity }}>
{children}
</box>
)
}循环动画
function Spinner() {
const [frame, setFrame] = useState(0)
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
useEffect(() => {
const interval = setInterval(() => {
setFrame(f => (f + 1) % frames.length)
}, 80)
return () => clearInterval(interval)
}, [])
return <text>{frames[frame]} Loading...</text>
}错落动画
function StaggeredList({ items }) {
const [visibleCount, setVisibleCount] = useState(0)
useEffect(() => {
let count = 0
const interval = setInterval(() => {
count++
setVisibleCount(count)
if (count >= items.length) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [items.length])
return (
<box flexDirection="column">
{items.slice(0, visibleCount).map((item, i) => (
<text key={i}>{item}</text>
))}
</box>
)
}滑入效果
function SlideIn({ children, from = "left" }) {
const [offset, setOffset] = useState(from === "left" ? -20 : 20)
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ offset: from === "left" ? -20 : 20 },
{
offset: 0,
duration: 300,
ease: "easeOutCubic",
onUpdate: (anim) => {
setOffset(Math.round(anim.targets[0].offset))
},
}
)
}, [])
return (
<box position="relative" left={offset}>
{children}
</box>
)
}性能优化建议
批量更新
Timeline 会在渲染循环内自动批量更新。
使用整数值
对基于字符的定位,应将动画值四舍五入:
onUpdate: (anim) => {
setX(Math.round(anim.targets[0].x))
}清理 Timeline
Hooks 会自动清理,但对于核心模式:
// 当不再需要 timeline 时
engine.removeTimeline(timeline)注意事项
终端刷新率
终端 UI 通常最高以 60 FPS 刷新。非常快的动画可能会显得卡顿。
字符网格
动画受限于字符单元格。无法实现亚像素级别的定位。
在 Effect 中清理
务必清理 intervals 和 timelines:
useEffect(() => {
const interval = setInterval(...)
return () => clearInterval(interval)
}, [])代码与差异组件
用于在 OpenTUI 中显示语法高亮代码和差异的组件。
Code 组件
显示语法高亮的代码块。
基本用法
// React
<code
code={`function hello() {
console.log("Hello, World!");
}`}
language="typescript"
/>
// Solid
<code
code={sourceCode}
language="javascript"
/>
// Core
const codeBlock = new CodeRenderable(renderer, {
id: "code",
code: sourceCode,
language: "typescript",
})支持的语言
OpenTUI 使用 Tree-sitter 进行语法高亮。常用语言:
typescript,javascriptpythonrustgojsonhtml,cssmarkdownbash,shell
样式设置
<code
code={sourceCode}
language="typescript"
backgroundColor="#1a1a2e"
showLineNumbers
/>Line Number 组件
带行号、高亮和诊断信息的代码显示。
基本用法
// React
<line-number
code={sourceCode}
language="typescript"
/>
// Solid (note underscore)
<line_number
code={sourceCode}
language="typescript"
/>
// Core
const codeView = new LineNumberRenderable(renderer, {
id: "code-view",
code: sourceCode,
language: "typescript",
})行号选项
// React
<line-number
code={sourceCode}
language="typescript"
startLine={1} // Starting line number
showLineNumbers={true} // Display line numbers
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
startLine={1}
showLineNumbers={true}
/>行高亮
高亮特定行:
// React
<line-number
code={sourceCode}
language="typescript"
highlightedLines={[5, 10, 15]} // Highlight these lines
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
highlightedLines={[5, 10, 15]}
/>诊断信息
在特定行显示错误、警告和信息:
// React
<line-number
code={sourceCode}
language="typescript"
diagnostics={[
{ line: 3, severity: "error", message: "Unexpected token" },
{ line: 7, severity: "warning", message: "Unused variable" },
{ line: 12, severity: "info", message: "Consider using const" },
]}
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
diagnostics={[
{ line: 3, severity: "error", message: "Unexpected token" },
]}
/>诊断严重性级别:
error- 红色指示器warning- 黄色指示器info- 蓝色指示器hint- 灰色指示器
差异高亮
显示已添加/删除的行:
<line-number
code={sourceCode}
language="typescript"
addedLines={[5, 6, 7]} // Green background
removedLines={[10, 11]} // Red background
/>Diff 组件
统一或分屏的差异查看器,支持语法高亮。
基本用法
// React
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
/>
// Solid
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
/>
// Core
const diffView = new DiffRenderable(renderer, {
id: "diff",
oldCode: originalCode,
newCode: modifiedCode,
language: "typescript",
})显示模式
// Unified diff (default)
<diff
oldCode={old}
newCode={new}
mode="unified"
/>
// Split/side-by-side diff
<diff
oldCode={old}
newCode={new}
mode="split"
/>选项
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified"
showLineNumbers
context={3} // Lines of context around changes
/>样式设置
<diff
oldCode={old}
newCode={new}
addedLineColor="#2d4f2d" // Background for added lines
removedLineColor="#4f2d2d" // Background for removed lines
unchangedLineColor="transparent"
/>使用场景
代码编辑器
function CodeEditor() {
const [code, setCode] = useState(`function hello() {
console.log("Hello!");
}`)
return (
<box flexDirection="column" height="100%">
<box height={1}>
<text>editor.ts</text>
</box>
<textarea
value={code}
onChange={setCode}
language="typescript"
showLineNumbers
flexGrow={1}
focused
/>
</box>
)
}代码审查
function CodeReview({ oldCode, newCode }) {
return (
<box flexDirection="column" height="100%">
<box height={1} backgroundColor="#333">
<text>Changes in src/utils.ts</text>
</box>
<diff
oldCode={oldCode}
newCode={newCode}
language="typescript"
mode="split"
showLineNumbers
/>
</box>
)
}语法高亮预览
function MarkdownPreview({ content }) {
// Extract code blocks from markdown
const codeBlocks = extractCodeBlocks(content)
return (
<scrollbox height={20}>
{codeBlocks.map((block, i) => (
<box key={i} marginBottom={1}>
<code
code={block.code}
language={block.language}
/>
</box>
))}
</scrollbox>
)
}错误显示
function ErrorView({ errors, code }) {
const diagnostics = errors.map(err => ({
line: err.line,
severity: "error",
message: err.message,
}))
return (
<line-number
code={code}
language="typescript"
diagnostics={diagnostics}
highlightedLines={errors.map(e => e.line)}
/>
)
}注意事项
Solid 使用下划线
// React
<line-number />
// Solid
<line_number />语法高亮需要指定语言
// 无高亮(纯文本)
<code code={text} />
// 带高亮
<code code={text} language="typescript" />大文件处理
对于非常大的文件,建议:
- 使用分页或虚拟滚动
- 仅加载可见部分
- 使用
scrollbox包装器
<scrollbox height={30}>
<line-number
code={largeFile}
language="typescript"
/>
</scrollbox>Tree-sitter 加载
语法高亮需要 Tree-sitter 语法。如果高亮不工作:
1. 检查语言是否受支持 2. 验证语法是否已安装 3. 如果使用自定义路径,检查 OTUI_TREE_SITTER_WORKER_PATH
容器组件
用于在 OpenTUI 中分组和组织内容的组件。
Box 组件
主要的容器组件,支持边框、背景和布局功能。
基本用法
// React/Solid
<box>
<text>Content inside box</text>
</box>
// Core
const box = new BoxRenderable(renderer, {
id: "container",
})
box.add(child)边框
<box border>
简单边框
</box>
<box
border
borderStyle="single" // single | double | rounded | bold | none
borderColor="#FFFFFF"
>
样式边框
</box>
// 单独的边框
<box
borderTop
borderBottom
borderLeft={false}
borderRight={false}
>
仅顶部和底部
</box>边框样式:
| 样式 | 外观 |
|---|---|
single | ┌─┐│ │└─┘ |
double | ╔═╗║ ║╚═╝ |
rounded | ╭─╮│ │╰─╯ |
bold | ┏━┓┃ ┃┗━┛ |
标题
<box
border
title="Settings"
titleAlignment="center" // left | center | right
>
Panel content
</box>背景
<box backgroundColor="#1a1a2e">
Dark background
</box>
<box backgroundColor="transparent">
No background
</box>布局
Box 默认是 flex 容器:
<box
flexDirection="row" // row | column | row-reverse | column-reverse
justifyContent="center" // flex-start | flex-end | center | space-between | space-around
alignItems="center" // flex-start | flex-end | center | stretch | baseline
gap={2} // Space between children
>
<text>Item 1</text>
<text>Item 2</text>
</box>间距
<box
padding={2} // All sides
paddingTop={1}
paddingRight={2}
paddingBottom={1}
paddingLeft={2}
margin={1}
marginTop={1}
>
Spaced content
</box>尺寸
<box
width={40} // Fixed width
height={10} // Fixed height
width="50%" // Percentage of parent
minWidth={20} // Minimum width
maxWidth={80} // Maximum width
flexGrow={1} // Grow to fill space
>
Sized box
</box>鼠标事件
<box
onMouseDown={(event) => {
console.log("Clicked at:", event.x, event.y)
}}
onMouseUp={(event) => {}}
onMouseMove={(event) => {}}
>
Clickable box
</box>ScrollBox 组件
用于超出视口内容的可滚动容器。
基本用法
// React
<scrollbox height={10}>
{items.map((item, i) => (
<text key={i}>{item}</text>
))}
</scrollbox>
// Solid
<scrollbox height={10}>
<For each={items()}>
{(item) => <text>{item}</text>}
</For>
</scrollbox>
// Core
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "list",
height: 10,
})
items.forEach(item => {
scrollbox.add(new TextRenderable(renderer, { content: item }))
})键盘滚动焦点
<scrollbox focused height={20}>
{/* 使用方向键滚动 */}
</scrollbox>滚动条样式
// React
<scrollbox
style={{
rootOptions: {
backgroundColor: "#24283b",
},
wrapperOptions: {
backgroundColor: "#1f2335",
},
viewportOptions: {
backgroundColor: "#1a1b26",
},
contentOptions: {
backgroundColor: "#16161e",
},
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
{content}
</scrollbox>滚动位置(Core)
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "list",
height: 20,
})
// 编程方式滚动
scrollbox.scrollTo(0) // 滚动到顶部
scrollbox.scrollTo(100) // 滚动到指定位置
scrollbox.scrollBy(10) // 相对滚动
scrollbox.scrollToBottom() // 滚动到底部组合模式
Card 组件
function Card({ title, children }) {
return (
<box
border
borderStyle="rounded"
padding={2}
marginBottom={1}
>
{title && (
<text fg="#00FFFF" bold>
{title}
</text>
)}
<box marginTop={title ? 1 : 0}>
{children}
</box>
</box>
)
}Panel 组件
function Panel({ title, children, width = 40 }) {
return (
<box
border
borderStyle="double"
width={width}
backgroundColor="#1a1a2e"
>
{title && (
<box
borderBottom
padding={1}
backgroundColor="#2a2a4e"
>
<text bold>{title}</text>
</box>
)}
<box padding={2}>
{children}
</box>
</box>
)
}列表容器
function List({ items, renderItem }) {
return (
<scrollbox height={15} focused>
{items.map((item, i) => (
<box
key={i}
padding={1}
backgroundColor={i % 2 === 0 ? "#222" : "#333"}
>
{renderItem(item, i)}
</box>
))}
</scrollbox>
)
}嵌套容器
<box flexDirection="column" height="100%">
{/* Header */}
<box height={3} border>
<text>Header</text>
</box>
{/* Main area with sidebar */}
<box flexDirection="row" flexGrow={1}>
<box width={20} border>
<text>Sidebar</text>
</box>
<box flexGrow={1}>
<scrollbox height="100%">
{/* Scrollable content */}
</scrollbox>
</box>
</box>
{/* Footer */}
<box height={1}>
<text>Footer</text>
</box>
</box>注意事项
百分比尺寸需要父容器尺寸
// 错误 - 父容器没有明确尺寸
<box>
<box width="50%">不会工作</box>
</box>
// 正确
<box width="100%">
<box width="50%">正常工作</box>
</box>FlexGrow 需要指定尺寸的父容器
// 错误
<box>
<box flexGrow={1}>不会增长</box>
</box>
// 正确
<box height="100%">
<box flexGrow={1}>会增长</box>
</box>ScrollBox 需要高度
// 错误 - 没有高度约束
<scrollbox>
{items}
</scrollbox>
// 正确
<scrollbox height={20}>
{items}
</scrollbox>边框占用空间
边框会占用 box 内部的空间:
<box width={10} border>
{/* 内部内容区域为 8 个字符(10 - 2 个边框字符) */}
</box>输入组件
用于在 OpenTUI 中进行用户输入的组件。
Input 组件
单行文本输入字段。
基本用法
// React
<input
value={value}
onChange={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
/>
// Solid
<input
value={value()}
onInput={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
/>
// Core
const input = new InputRenderable(renderer, {
id: "name",
placeholder: "Enter text...",
})
input.on(InputRenderableEvents.CHANGE, (value) => {
console.log("Value:", value)
})
input.focus()样式设置
<input
width={30}
backgroundColor="#1a1a1a"
textColor="#FFFFFF"
cursorColor="#00FF00"
focusedBackgroundColor="#2a2a2a"
placeholderColor="#666666"
/>事件
// React
<input
onChange={(value) => console.log("Changed:", value)}
onFocus={() => console.log("Focused")}
onBlur={() => console.log("Blurred")}
/>
// Core
input.on(InputRenderableEvents.CHANGE, (value) => {})
input.on(InputRenderableEvents.FOCUS, () => {})
input.on(InputRenderableEvents.BLUR, () => {})受控输入
// React
function ControlledInput() {
const [value, setValue] = useState("")
return (
<input
value={value}
onChange={setValue}
focused
/>
)
}
// Solid
function ControlledInput() {
const [value, setValue] = createSignal("")
return (
<input
value={value()}
onInput={setValue}
focused
/>
)
}Textarea 组件
多行文本输入字段。
基本用法
// React
<textarea
value={text}
onChange={(newText) => setText(newText)}
placeholder="Enter multiple lines..."
width={40}
height={10}
focused
/>
// Solid
<textarea
value={text()}
onInput={(newText) => setText(newText)}
placeholder="Enter multiple lines..."
width={40}
height={10}
focused
/>
// Core
const textarea = new TextareaRenderable(renderer, {
id: "editor",
width: 40,
height: 10,
placeholder: "Enter text...",
})功能特性
<textarea
showLineNumbers // Display line numbers
wrapText // Wrap long lines
readOnly // Disable editing
tabSize={2} // Tab character width
/>语法高亮
<textarea
language="typescript"
value={code}
onChange={setCode}
/>Select 组件
用于从选项中进行选择的列表选择器。
基本用法
// React
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
{ name: "Option 3", description: "Third option", value: "3" },
]}
onSelect={(index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Solid
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
]}
onSelect={(index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Core
const select = new SelectRenderable(renderer, {
id: "menu",
options: [
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
],
})
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
})
select.focus()选项格式
interface SelectOption {
name: string // 显示文本
description?: string // 可选的描述,显示在下方
value?: any // 关联的值
}样式设置
<select
height={8} // Visible height
selectedIndex={0} // Initially selected
showScrollIndicator // Show scroll arrows
selectedBackgroundColor="#333"
selectedTextColor="#fff"
highlightBackgroundColor="#444"
/>导航
默认快捷键:
Up/k- 向上移动Down/j- 向下移动Enter- 选择项目
事件
重要:onSelect 和 onChange 有不同的用途:
| 事件 | 触发条件 | 使用场景 |
|---|---|---|
onSelect | 按下 Enter 键 - 用户确认选择 | 对选中的项目执行操作 |
onChange | 使用方向键 - 用户浏览列表 | 在用户浏览时预览、更新界面 |
// React/Solid
<select
onSelect={(index, option) => {
// Called when Enter is pressed - selection confirmed
console.log("User selected:", option.name)
performAction(option)
}}
onChange={(index, option) => {
// Called when navigating with arrow keys
console.log("Browsing:", option.name)
showPreview(option)
}}
/>
// Core
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
// Called when Enter is pressed
})
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
// Called when navigating with arrow keys
})Tab Select 组件
基于水平标签页的选择器。
基本用法
// React
<tab-select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
{ name: "Help", description: "Documentation" },
]}
onSelect={(index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Solid (note underscore)
<tab_select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
]}
onSelect={(index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Core
const tabs = new TabSelectRenderable(renderer, {
id: "tabs",
options: [...],
tabWidth: 20,
})
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
})
tabs.focus()事件
与 Select 相同的模式 - onSelect 用于 Enter 键,onChange 用于导航:
<tab-select
onSelect={(index, option) => {
// Called when Enter is pressed - switch to tab
setActiveTab(index)
}}
onChange={(index, option) => {
// Called when navigating with arrow keys
showTabPreview(option)
}}
/>样式设置
// React
<tab-select
tabWidth={20} // 每个标签的宽度
selectedIndex={0} // 初始选中的标签
/>
// Solid
<tab_select
tabWidth={20}
selectedIndex={0}
/>导航
默认快捷键:
Left/[- 上一个标签Right/]- 下一个标签Enter- 选择标签
焦点管理
单个聚焦输入
function SingleInput() {
return <input placeholder="I'm focused" focused />
}多个输入的焦点状态
// React
function Form() {
const [focusIndex, setFocusIndex] = useState(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
{fields.map((field, i) => (
<input
key={field}
placeholder={`Enter ${field}`}
focused={i === focusIndex}
/>
))}
</box>
)
}焦点方法(Core)
input.focus() // 获取焦点
input.blur() // 移除焦点
input.isFocused() // 检查焦点状态表单模式
登录表单
function LoginForm() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [focusField, setFocusField] = useState<"username" | "password">("username")
useKeyboard((key) => {
if (key.name === "tab") {
setFocusField(f => f === "username" ? "password" : "username")
}
if (key.name === "enter") {
handleLogin()
}
})
return (
<box flexDirection="column" gap={1} border padding={2}>
<box flexDirection="row" gap={1}>
<text>Username:</text>
<input
value={username}
onChange={setUsername}
focused={focusField === "username"}
width={20}
/>
</box>
<box flexDirection="row" gap={1}>
<text>Password:</text>
<input
value={password}
onChange={setPassword}
focused={focusField === "password"}
width={20}
/>
</box>
</box>
)
}带结果的搜索
function SearchableList({ items, onItemSelected }) {
const [query, setQuery] = useState("")
const [focusSearch, setFocusSearch] = useState(true)
const [preview, setPreview] = useState(null)
const filtered = items.filter(item =>
item.toLowerCase().includes(query.toLowerCase())
)
useKeyboard((key) => {
if (key.name === "tab") {
setFocusSearch(f => !f)
}
})
return (
<box flexDirection="column">
<input
value={query}
onChange={setQuery}
placeholder="Search..."
focused={focusSearch}
/>
<select
options={filtered.map(item => ({ name: item }))}
focused={!focusSearch}
height={10}
onSelect={(index, option) => {
// Enter pressed - confirm selection
onItemSelected(option)
}}
onChange={(index, option) => {
// Navigating - show preview
setPreview(option)
}}
/>
</box>
)
}注意事项
需要焦点
输入必须获得焦点才能接收键盘输入:
// 错误 - 不会接收输入
<input placeholder="Type here" />
// 正确
<input placeholder="Type here" focused />Select 选项格式
选项必须是带有 name 属性的对象:
// 错误
<select options={["a", "b", "c"]} />
// 正确
<select options={[
{ name: "A", description: "Option A" },
{ name: "B", description: "Option B" },
]} />Solid 使用下划线
// React
<tab-select />
// Solid
<tab_select />Value 与 onInput(Solid)
Solid 使用 onInput 而不是 onChange:
// React
<input value={value} onChange={setValue} />
// Solid
<input value={value()} onInput={setValue} />OpenTUI 组件
所有 OpenTUI 组件的参考文档,按类别组织。组件在所有三个框架(Core、React、Solid)中都可用,API 略有差异。
何时使用
当您需要找到合适的组件类别或比较 Core、React 和 Solid 之间的命名时,请使用此参考文档。
组件类别
| 类别 | 组件 | 文件 |
|---|---|---|
| 文本与显示 | text, ascii-font, styled text | text-display.md |
| 容器 | box, scrollbox, borders | containers.md |
| 输入 | input, textarea, select, tab-select | inputs.md |
| 代码与差异 | code, line-number, diff | code-diff.md |
组件选择器
需要组件?
├─ 样式文本或 ASCII 艺术字 -> text-display.md
├─ 容器、边框、滚动 -> containers.md
├─ 表单或输入控件 -> inputs.md
└─ 代码块、差异、行号 -> code-diff.md组件命名
组件在不同框架中有不同的名称:
| 概念 | Core (类) | React (JSX) | Solid (JSX) |
|---|---|---|---|
| Text | TextRenderable | <text> | <text> |
| Box | BoxRenderable | <box> | <box> |
| ScrollBox | ScrollBoxRenderable | <scrollbox> | <scrollbox> |
| Input | InputRenderable | <input> | <input> |
| Textarea | TextareaRenderable | <textarea> | <textarea> |
| Select | SelectRenderable | <select> | <select> |
| Tab Select | TabSelectRenderable | <tab-select> | <tab_select> |
| ASCII Font | ASCIIFontRenderable | <ascii-font> | <ascii_font> |
| Code | CodeRenderable | <code> | <code> |
| Line Number | LineNumberRenderable | <line-number> | <line_number> |
| Diff | DiffRenderable | <diff> | <diff> |
注意:Solid 使用下划线(tab_select),而 React 使用连字符(tab-select)。
通用属性
所有组件都共享这些布局属性(参见 Layout):
// Positioning
position="relative" | "absolute"
left, top, right, bottom
// Dimensions
width, height
minWidth, maxWidth, minHeight, maxHeight
// Flexbox
flexDirection, flexGrow, flexShrink, flexBasis
justifyContent, alignItems, alignSelf
flexWrap, gap
// Spacing
padding, paddingTop, paddingRight, paddingBottom, paddingLeft
margin, marginTop, marginRight, marginBottom, marginLeft
// Display
display="flex" | "none"
overflow="visible" | "hidden" | "scroll"
zIndex快速示例
Core(命令式)
import { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
const box = new BoxRenderable(renderer, {
id: "container",
border: true,
padding: 2,
})
const text = new TextRenderable(renderer, {
id: "greeting",
content: "Hello!",
fg: "#00FF00",
})
box.add(text)
renderer.root.add(box)React
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return (
<box border padding={2}>
<text fg="#00FF00">Hello!</text>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)Solid
import { render } from "@opentui/solid"
function App() {
return (
<box border padding={2}>
<text fg="#00FF00">Hello!</text>
</box>
)
}
render(() => <App />)另请参阅
- Core API - 命令式组件类
- React API - React 组件属性
- Solid API - Solid 组件属性
- Layout - 布局系统详情
文本与显示组件
用于在 OpenTUI 中显示文本内容的组件。
Text 组件
用于显示样式文本的主要组件。
基本用法
// React/Solid
<text>Hello, World!</text>
// With content prop
<text content="Hello, World!" />
// Core
const text = new TextRenderable(renderer, {
id: "greeting",
content: "Hello, World!",
})样式设置(React/Solid)
对于 React 和 Solid,使用嵌套修饰标签进行文本样式设置:
<text fg="#FFFFFF" bg="#000000">
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
</text>重要:不要将bold、italic、underline、dim、strikethrough作为<text>的属性使用 — 它们不起作用。始终使用嵌套标签如<strong>、<em>、<u>或带样式的<span>。
样式设置(Core)- 文本属性
import { TextRenderable, TextAttributes } from "@opentui/core"
const text = new TextRenderable(renderer, {
content: "Styled",
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
})可用属性:
TextAttributes.BOLDTextAttributes.DIMTextAttributes.ITALICTextAttributes.UNDERLINETextAttributes.BLINKTextAttributes.INVERSETextAttributes.HIDDENTextAttributes.STRIKETHROUGH
文本选择
<text selectable>
This text can be selected by the user
</text>
<text selectable={false}>
This text cannot be selected
</text>文本修饰符
必须用于 <text> 内部的内联样式元素:
Span
内联样式文本:
<text>
普通文本,内联 <span fg="red">红色文本</span>
</text>粗体/Strong
<text>
<strong>粗体文本</strong>
<b>也是粗体</b>
</text>斜体/强调
<text>
<em>斜体文本</em>
<i>也是斜体</i>
</text>下划线
<text>
<u>带下划线的文本</u>
</text>换行
<text>
第一行
<br />
第二行
</text>链接
<text>
访问 <a href="https://example.com">我们的网站</a>
</text>组合修饰符
<text>
<span fg="#00FF00">
<strong>粗体绿色</strong>
</span>
和
<span fg="#FF0000">
<em><u>斜体下划线红色</u></em>
</span>
</text>样式文本模板(Core)
用于复杂样式设置的 t 模板字符串:
import { t, bold, italic, underline, fg, bg, dim } from "@opentui/core"
const styled = t`
${bold("粗体")} 和 ${italic("斜体")} 文本。
${fg("#FF0000")("红色文本")} 带有 ${bg("#0000FF")("蓝色背景")}。
${dim("变暗")} 和 ${underline("下划线")}。
`
const text = new TextRenderable(renderer, {
content: styled,
})样式函数
| 函数 | 描述 |
|---|---|
bold(text) | 粗体文本 |
italic(text) | 斜体文本 |
underline(text) | 下划线文本 |
dim(text) | 变暗文本 |
strikethrough(text) | 删除线文本 |
fg(color)(text) | 设置前景色 |
bg(color)(text) | 设置背景色 |
ASCII Font 组件
显示大型 ASCII 艺术文本横幅。
基本用法
// React
<ascii-font text="TITLE" font="tiny" />
// Solid
<ascii_font text="TITLE" font="tiny" />
// Core
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "TITLE",
font: "tiny",
})可用字体
| 字体 | 描述 |
|---|---|
tiny | 紧凑的 ASCII 字体 |
block | 块状字母 |
slick | 时尚的现代风格 |
shade | 阴影 3D 效果 |
样式设置
// React
<ascii-font
text="HELLO"
font="block"
color="#00FF00"
/>
// Core
import { RGBA } from "@opentui/core"
const title = new ASCIIFontRenderable(renderer, {
text: "HELLO",
font: "block",
color: RGBA.fromHex("#00FF00"),
})输出示例
Font: tiny
╭─╮╭─╮╭─╮╭╮╭╮╭─╮╶╮╶ ╶╮
│ ││─┘├┤ │╰╯││ │ │
╰─╯╵ ╰─╯╵ ╵╰─╯╶╯╶╰─╯
Font: block
█▀▀█ █▀▀█ █▀▀ █▀▀▄
█ █ █▀▀▀ █▀▀ █ █
▀▀▀▀ ▀ ▀▀▀ ▀ ▀颜色
颜色格式
// 十六进制颜色
<text fg="#FF0000">红色</text>
<text fg="#F00">短十六进制</text>
// 命名颜色
<text fg="red">红色</text>
<text fg="blue">蓝色</text>
// 透明
<text bg="transparent">无背景</text>RGBA 类
来自 @opentui/core 的 RGBA 类可在所有框架(Core、React、Solid)中用于程序化颜色操作:
import { RGBA } from "@opentui/core"
// From hex string (most common)
const red = RGBA.fromHex("#FF0000")
const shortHex = RGBA.fromHex("#F00") // Short form supported
// From integers (0-255 range for each channel)
const green = RGBA.fromInts(0, 255, 0, 255) // r, g, b, a
const semiGreen = RGBA.fromInts(0, 255, 0, 128) // 50% transparent
// From normalized floats (0.0-1.0 range)
const blue = RGBA.fromValues(0.0, 0.0, 1.0, 1.0) // r, g, b, a
const overlay = RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // Dark semi-transparent
// Common use cases
const backgroundColor = RGBA.fromHex("#1a1a2e")
const textColor = RGBA.fromHex("#FFFFFF")
const borderColor = RGBA.fromInts(122, 162, 247, 255) // Tokyo Night blue
const shadowColor = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% black各方法的使用场景:
fromHex()- 处理设计规范或 CSS 颜色时使用fromInts()- 有 8 位颜色值(0-255)时使用fromValues()- 进行颜色计算或插值时使用(标准化 0.0-1.0)
在 React/Solid 中使用 RGBA
// React or Solid - RGBA works with color props
import { RGBA } from "@opentui/core"
const primaryColor = RGBA.fromHex("#7aa2f7")
function MyComponent() {
return (
<box backgroundColor={primaryColor} borderColor={primaryColor}>
<text fg={RGBA.fromHex("#c0caf5")}>Styled with RGBA</text>
</box>
)
}大多数接受颜色字符串("#FF0000"、"red")的属性也直接接受 RGBA 对象。
文本换行
文本根据父容器进行换行:
<box width={40}>
<text>
这段长文本在到达 40 个字符宽的父容器边缘时会
自动换行。
</text>
</box>动态内容
React
function Counter() {
const [count, setCount] = useState(0)
return <text>计数:{count}</text>
}Solid
function Counter() {
const [count, setCount] = createSignal(0)
return <text>计数:{count()}</text>
}Core
const text = new TextRenderable(renderer, {
id: "counter",
content: "计数:0",
})
// 稍后更新
text.setContent("计数:1")注意事项
文本修饰符在 Text 外部使用
// 错误 - 修饰符只能在 <text> 内部使用
<box>
<strong>不会工作</strong>
</box>
// 正确
<box>
<text>
<strong>这会工作</strong>
</text>
</box>空文本
// 可能导致布局问题
<text></text>
// 更好 - 使用空格或条件
<text>{content || " "}</text>颜色格式
// 错误
<text fg="FF0000">缺少 #</text>
// 正确
<text fg="#FF0000">包含 #</text>核心 API 参考
Renderer(渲染器)
createCliRenderer(config?)
创建并初始化 CLI 渲染器。
import { createCliRenderer, type CliRendererConfig } from "@opentui/core"
const renderer = await createCliRenderer({
targetFPS: 60, // 目标每秒帧数
exitOnCtrlC: true, // 在 Ctrl+C 时退出进程
consoleOptions: { // 调试控制台覆盖层
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
onDestroy: () => {}, // 清理回调
})CliRenderer 实例
renderer.root // 根可渲染节点
renderer.width // 终端宽度(列数)
renderer.height // 终端高度(行数)
renderer.keyInput // 键盘事件发射器
renderer.console // 控制台覆盖层控制器
renderer.start() // 启动渲染循环
renderer.stop() // 停止渲染循环
renderer.destroy() // 清理并退出备用屏幕
renderer.requestRender() // 请求重新渲染Console Overlay(控制台覆盖层)
renderer.console.show() // 显示控制台覆盖层
renderer.console.hide() // 隐藏控制台覆盖层
renderer.console.toggle() // 切换可见性/焦点
renderer.console.clear() // 清空控制台内容Renderables(可渲染组件)
所有 renderables 都扩展自基类 Renderable 并共享通用属性。
通用属性
interface CommonProps {
id?: string // 唯一标识符
// 定位
position?: "relative" | "absolute"
left?: number | string
top?: number | string
right?: number | string
bottom?: number | string
// 尺寸
width?: number | string | "auto"
height?: number | string | "auto"
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
// Flexbox
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
flexGrow?: number
flexShrink?: number
flexBasis?: number | string
flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly"
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
alignContent?: "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-around"
// 间距
padding?: number
paddingTop?: number
paddingRight?: number
paddingBottom?: number
paddingLeft?: number
margin?: number
marginTop?: number
marginRight?: number
marginBottom?: number
marginLeft?: number
gap?: number
// 显示
display?: "flex" | "none"
overflow?: "visible" | "hidden" | "scroll"
zIndex?: number
}Renderable 方法
renderable.add(child) // 添加子可渲染组件
renderable.remove(child) // 移除子可渲染组件
renderable.getRenderable(id) // 通过 ID 查找子组件
renderable.focus() // 聚焦此可渲染组件
renderable.blur() // 移除焦点
renderable.destroy() // 销毁并清理
renderable.on(event, handler) // 添加事件监听器
renderable.off(event, handler) // 移除事件监听器
renderable.emit(event, ...args) // 触发事件TextRenderable
显示样式化的文本内容。
import { TextRenderable, TextAttributes, t, bold, fg, underline } from "@opentui/core"
const text = new TextRenderable(renderer, {
id: "text",
content: "Hello World",
fg: "#FFFFFF", // Foreground color
bg: "#000000", // Background color
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
selectable: true, // Allow text selection
})
// 使用模板字面量的样式化文本
const styled = new TextRenderable(renderer, {
content: t`${bold("Bold")} and ${fg("#FF0000")(underline("red underlined"))}`,
})TextAttributes 标志:
TextAttributes.BOLDTextAttributes.DIMTextAttributes.ITALICTextAttributes.UNDERLINETextAttributes.BLINKTextAttributes.INVERSETextAttributes.HIDDENTextAttributes.STRIKETHROUGH
BoxRenderable
带有边框和布局的容器。
import { BoxRenderable } from "@opentui/core"
const box = new BoxRenderable(renderer, {
id: "box",
width: 40,
height: 10,
backgroundColor: "#1a1a2e",
border: true,
borderStyle: "single" | "double" | "rounded" | "bold" | "none",
borderColor: "#FFFFFF",
title: "Panel Title",
titleAlignment: "left" | "center" | "right",
onMouseDown: (event) => {},
onMouseUp: (event) => {},
onMouseMove: (event) => {},
})InputRenderable
单行文本输入。
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
const input = new InputRenderable(renderer, {
id: "input",
width: 30,
placeholder: "Enter text...",
value: "", // 初始值
backgroundColor: "#1a1a1a",
textColor: "#FFFFFF",
cursorColor: "#00FF00",
focusedBackgroundColor: "#2a2a2a",
})
input.on(InputRenderableEvents.CHANGE, (value: string) => {
console.log("Value:", value)
})
input.focus() // 必须聚焦才能接收输入SelectRenderable
列表选择组件。
import { SelectRenderable, SelectRenderableEvents } from "@opentui/core"
const select = new SelectRenderable(renderer, {
id: "select",
width: 30,
height: 10,
options: [
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
],
selectedIndex: 0,
})
// 当按下 Enter 键时调用 - 确认选择
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Selected:", option.name)
performAction(option)
})
// 当使用方向键导航时调用
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
console.log("Browsing:", option.name)
showPreview(option)
})
select.focus() // 使用 up/down/j/k 导航,按 enter 选择事件区别:
ITEM_SELECTED- 按下 Enter 键,用户确认选择SELECTION_CHANGED- 使用方向键,用户正在浏览选项
TabSelectRenderable
水平标签选择。
import { TabSelectRenderable, TabSelectRenderableEvents } from "@opentui/core"
const tabs = new TabSelectRenderable(renderer, {
id: "tabs",
width: 60,
options: [
{ name: "Home", description: "Dashboard" },
{ name: "Settings", description: "Configuration" },
],
tabWidth: 20,
})
// 当按下 Enter 键时调用 - 选中标签
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Tab selected:", option.name)
switchToTab(index)
})
// 当使用方向键导航时调用
tabs.on(TabSelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
console.log("Browsing tab:", option.name)
})
tabs.focus() // 使用 left/right/[/] 导航,按 enter 选择事件区别(与 SelectRenderable 相同):
ITEM_SELECTED- 按下 Enter 键,用户确认标签SELECTION_CHANGED- 使用方向键,用户正在浏览标签
ScrollBoxRenderable
可滚动容器。
import { ScrollBoxRenderable } from "@opentui/core"
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "scrollbox",
width: 40,
height: 20,
showScrollbar: true,
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
})
// 添加超出视口的内容
for (let i = 0; i < 100; i++) {
scrollbox.add(new TextRenderable(renderer, {
id: `line-${i}`,
content: `Line ${i}`,
}))
}
scrollbox.focus() // 使用方向键滚动ASCIIFontRenderable
ASCII 艺术文本。
import { ASCIIFontRenderable, RGBA } from "@opentui/core"
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "OPENTUI",
font: "tiny" | "block" | "slick" | "shade",
color: RGBA.fromHex("#FFFFFF"),
})FrameBufferRenderable
低级 2D 渲染表面。
import { FrameBufferRenderable, RGBA } from "@opentui/core"
const canvas = new FrameBufferRenderable(renderer, {
id: "canvas",
width: 50,
height: 20,
})
// 直接像素操作
canvas.frameBuffer.fillRect(10, 5, 20, 8, RGBA.fromHex("#FF0000"))
canvas.frameBuffer.drawText("Custom", 12, 7, RGBA.fromHex("#FFFFFF"))
canvas.frameBuffer.setCell(x, y, char, fg, bg)Constructs(VNode API)
创建 VNode 而不是直接实例的声明式包装器。
import { Text, Box, Input, Select, instantiate, delegate } from "@opentui/core"
// Create VNode tree
const ui = Box(
{ border: true, padding: 1 },
Text({ content: "Hello" }),
Input({ placeholder: "Type here..." }),
)
// Instantiate onto renderer
renderer.root.add(ui)
// Delegate focus to nested element
const form = delegate(
{ focus: "email-input" },
Box(
{},
Text({ content: "Email:" }),
Input({ id: "email-input", placeholder: "you@example.com" }),
),
)
form.focus() // Focuses the input, not the box颜色(RGBA)
RGBA 类从 @opentui/core 导出,但在所有框架(Core、React、Solid)中都可以使用。它用于程序化的颜色操作。
创建颜色
import { RGBA, parseColor } from "@opentui/core"
// 从十六进制字符串(最常见)
RGBA.fromHex("#FF0000") // 完整十六进制
RGBA.fromHex("#F00") // 简短十六进制
// 从整数(0-255 范围)
RGBA.fromInts(255, 0, 0, 255) // r, g, b, a - 完全不透明的红色
RGBA.fromInts(255, 0, 0, 128) // 50% 透明的红色
RGBA.fromInts(0, 0, 0, 0) // 完全透明
// 从标准化浮点数(0.0-1.0 范围)
RGBA.fromValues(1.0, 0.0, 0.0, 1.0) // 完全不透明的红色
RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // 深灰色,70% 不透明
RGBA.fromValues(0.0, 0.5, 1.0, 1.0) // 浅蓝色常见颜色模式
// 主题颜色
const primary = RGBA.fromHex("#7aa2f7") // Tokyo Night 蓝色
const background = RGBA.fromHex("#1a1a2e")
const foreground = RGBA.fromHex("#c0caf5")
const error = RGBA.fromHex("#f7768e")
// 覆盖层和阴影
const modalOverlay = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% 黑色
const shadow = RGBA.fromInts(0, 0, 0, 77) // 30% 黑色
// 边框
const activeBorder = RGBA.fromHex("#7aa2f7")
const inactiveBorder = RGBA.fromInts(65, 72, 104, 255)parseColor 工具函数
// 接受多种格式
parseColor("#FF0000") // 十六进制字符串
parseColor("red") // CSS 颜色名称
parseColor("transparent") // 特殊值
parseColor(RGBA.fromHex("#F00")) // 传递 RGBA 对象何时使用每个方法
| 方法 | 使用场景 |
|---|---|
fromHex() | 使用设计规范、CSS 颜色、配置文件时 |
fromInts() | 有 8 位值(0-255),常见于图形编程 |
fromValues() | 进行颜色插值、动画、数学运算 |
parseColor() | 接受可能是任何格式的用户输入或配置 |
在 React/Solid 中使用 RGBA
// 从 @opentui/core 导入,在任何框架中使用
import { RGBA } from "@opentui/core"
// React 或 Solid 组件
function ThemedBox() {
const bg = RGBA.fromHex("#1a1a2e")
const border = RGBA.fromInts(122, 162, 247, 255)
return (
<box backgroundColor={bg} borderColor={border} border>
<text fg={RGBA.fromHex("#c0caf5")}>Works everywhere!</text>
</box>
)
}React/Solid 中的颜色属性接受字符串格式("#FF0000"、"red")和 RGBA 对象。
键盘输入
import { type KeyEvent } from "@opentui/core"
renderer.keyInput.on("keypress", (key: KeyEvent) => {
console.log(key.name) // "a", "escape", "f1" 等
console.log(key.sequence) // 原始转义序列
console.log(key.ctrl) // 按住 Ctrl
console.log(key.shift) // 按住 Shift
console.log(key.meta) // 按住 Alt
console.log(key.option) // 按住 Option(macOS)
console.log(key.eventType) // "press" | "release" | "repeat"
})
renderer.keyInput.on("paste", (text: string) => {
console.log("Pasted:", text)
})动画时间轴
import { Timeline, engine } from "@opentui/core"
const timeline = new Timeline({
duration: 2000,
loop: false,
autoplay: true,
})
timeline.add(
{ width: 0 },
{
width: 50,
duration: 1000,
ease: "easeOutQuad",
onUpdate: (anim) => {
box.setWidth(anim.targets[0].width)
},
},
)
engine.attach(renderer)
engine.addTimeline(timeline)类型导出
import type {
CliRenderer,
CliRendererConfig,
RenderContext,
KeyEvent,
Renderable,
// ... 以及更多
} from "@opentui/core"核心配置
渲染器配置
createCliRenderer 选项
import { createCliRenderer, ConsolePosition } from "@opentui/core"
const renderer = await createCliRenderer({
// 渲染
targetFPS: 60, // 目标每秒帧数(默认:60)
// 行为
exitOnCtrlC: true, // 在 Ctrl+C 时退出(默认:true)
// 控制台覆盖层
consoleOptions: {
position: ConsolePosition.BOTTOM, // BOTTOM | TOP | LEFT | RIGHT
sizePercent: 30, // 屏幕百分比
colorInfo: "#00FFFF",
colorWarn: "#FFFF00",
colorError: "#FF0000",
colorDebug: "#888888",
startInDebugMode: false,
},
// 生命周期
onDestroy: () => {
// 清理回调
},
})环境变量
OpenTUI 支持多个环境变量用于配置和调试。
调试和开发
| 变量 | 类型 | 默认值 | 描述 |
|---|---|---|---|
OTUI_DEBUG | boolean | false | 启用调试模式,捕获原始输入 |
OTUI_DEBUG_FFI | boolean | false | FFI 绑定的调试日志 |
OTUI_TRACE_FFI | boolean | false | FFI 绑定的跟踪 |
OTUI_SHOW_STATS | boolean | false | 启动时显示调试覆盖层 |
OTUI_DUMP_CAPTURES | boolean | false | 退出时转储捕获的输出 |
控制台
| 变量 | 类型 | 默认值 | 描述 |
|---|---|---|---|
OTUI_USE_CONSOLE | boolean | true | 启用控制台捕获 |
SHOW_CONSOLE | boolean | false | 启动时显示控制台 |
渲染
| 变量 | 类型 | 默认值 | 描述 |
|---|---|---|---|
OTUI_NO_NATIVE_RENDER | boolean | false | 禁用 ANSI 输出(用于调试) |
OTUI_USE_ALTERNATE_SCREEN | boolean | true | 使用备用屏幕缓冲区 |
OTUI_OVERRIDE_STDOUT | boolean | true | 覆盖标准输出流 |
终端能力
| 变量 | 类型 | 默认值 | 描述 |
|---|---|---|---|
OPENTUI_NO_GRAPHICS | boolean | false | 禁用 Kitty 图形协议 |
OPENTUI_FORCE_UNICODE | boolean | false | 强制 Mode 2026 Unicode 支持 |
OPENTUI_FORCE_WCWIDTH | boolean | false | 使用 wcwidth 获取字符宽度 |
OPENTUI_FORCE_NOZWJ | boolean | false | 禁用 ZWJ 表符号连接 |
OPENTUI_FORCE_EXPLICIT_WIDTH | string | - | 强制显式宽度("true"/"false") |
Tree-sitter(语法高亮)
| 变量 | 类型 | 默认值 | 描述 |
|---|---|---|---|
OTUI_TS_STYLE_WARN | boolean | false | 在缺少语法样式时警告 |
OTUI_TREE_SITTER_WORKER_PATH | string | "" | 自定义 tree-sitter worker 路径 |
XDG 路径
| 变量 | 类型 | 默认值 | 描述 |
|---|---|---|---|
XDG_CONFIG_HOME | string | "" | 用户配置目录 |
XDG_DATA_HOME | string | "" | 用户数据目录 |
使用示例
开发模式
# 显示调试覆盖层和控制台
OTUI_SHOW_STATS=true SHOW_CONSOLE=true bun run src/index.ts
# 调试 FFI 问题
OTUI_DEBUG_FFI=true OTUI_TRACE_FFI=true bun run src/index.ts
# 禁用原生渲染进行测试
OTUI_NO_NATIVE_RENDER=true bun run src/index.ts终端兼容性
# 为有问题的终端强制使用 wcwidth
OPENTUI_FORCE_WCWIDTH=true bun run src/index.ts
# 为 SSH 会话禁用图形
OPENTUI_NO_GRAPHICS=true bun run src/index.ts项目设置
package.json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --watch run src/index.ts",
"test": "bun test"
},
"dependencies": {
"@opentui/core": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "latest"
}
}tsconfig.json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}构建原生代码
原生代码更改需要重新构建:
# 从仓库根目录(如果开发 OpenTUI 本身)
bun run build
# 原生编译需要 Zig
# 安装:https://ziglang.org/learn/getting-started/注意:TypeScript 更改不需要构建。Bun 直接运行 TypeScript。
核心陷阱和注意事项
运行时环境
使用 Bun,而非 Node.js
OpenTUI 是为 Bun 构建的。始终使用 Bun 命令:
# 正确
bun install @opentui/core
bun run src/index.ts
bun test
# 错误
npm install @opentui/core
node src/index.ts
npx jest推荐使用的 Bun API
优先使用 Bun 的内置 API:
// 正确 - Bun API
Bun.file("path").text() // 而非 fs.readFile
Bun.serve({ ... }) // 而非 express
Bun.$`ls -la` // 而非 execa
import { Database } from "bun:sqlite" // 而非 better-sqlite3
// 错误 - Node.js 模式
import fs from "node:fs"
import express from "express"避免使用 process.exit()
切勿直接使用 `process.exit()` - 它会阻止正确的终端清理,并可能使终端处于损坏状态(备用屏幕模式、原始输入模式等)。
// 错误 - 终端可能处于损坏状态
if (error) {
console.error("Fatal error")
process.exit(1)
}
// 正确 - 使用 renderer.destroy() 进行清理
if (error) {
console.error("Fatal error")
await renderer.destroy()
process.exit(1) // 仅在 destroy 之后
}
// 更好 - 让 destroy 处理退出
const renderer = await createCliRenderer({
exitOnCtrlC: true, // 正确处理 Ctrl+C
})
// 对于程序化退出
renderer.destroy() // 清理并退出renderer.destroy() 在退出前将终端恢复到原始状态。
环境变量
Bun 会自动加载 .env 文件。不要使用 dotenv:
// 正确
const apiKey = process.env.API_KEY
// 错误
import dotenv from "dotenv"
dotenv.config()调试 TUI
无法看到 console.log 输出
OpenTUI 会捕获控制台输出用于调试覆盖层。在 TUI 运行时,您无法在终端中看到日志。
解决方案:
1. 使用控制台覆盖层:
const renderer = await createCliRenderer()
renderer.console.show()
console.log("This appears in the overlay")2. 使用键盘切换:
renderer.keyInput.on("keypress", (key) => {
if (key.name === "f12") {
renderer.console.toggle()
}
})3. 写入文件:
import { appendFileSync } from "node:fs"
function debugLog(msg: string) {
appendFileSync("debug.log", `${new Date().toISOString()} ${msg}\n`)
}4. 禁用控制台捕获:
OTUI_USE_CONSOLE=false bun run src/index.ts在测试中重现问题
不要猜测 bug。创建可重现的测试:
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
test("reproduces the issue", async () => {
const { renderer, snapshot } = await createTestRenderer({
width: 40,
height: 10,
})
// 重现 bug 的设置
const box = new BoxRenderable(renderer, { ... })
renderer.root.add(box)
// 使用快照验证
expect(snapshot()).toMatchSnapshot()
})焦点管理
组件必须获得焦点
输入组件只有在获得焦点时才接收键盘输入:
const input = new InputRenderable(renderer, {
id: "input",
placeholder: "Type here...",
})
renderer.root.add(input)
// 错误 - 输入框不会接收按键
//(没有调用 focus)
// 正确
input.focus()嵌套组件中的焦点
当组件在容器内时,直接聚焦该组件:
const container = new BoxRenderable(renderer, { id: "container" })
const input = new InputRenderable(renderer, { id: "input" })
container.add(input)
renderer.root.add(container)
// 错误
container.focus()
// 正确
input.focus()
// 或使用 getRenderable
container.getRenderable("input")?.focus()
// 或使用 delegate(constructs)
const form = delegate(
{ focus: "input" },
Box({}, Input({ id: "input" })),
)
form.focus() // 路由到输入框构建要求
需要 Zig
原生代码编译需要 Zig:
# 首先安装 Zig
# macOS
brew install zig
# Linux
# 从 https://ziglang.org/download/ 下载
# 然后构建
bun run build何时构建
- TypeScript 更改:不需要构建(Bun 直接运行 TS)
- 原生代码更改:需要构建
# 仅在更改原生(Zig)代码时需要
cd packages/core
bun run build常见错误
"无法读取 undefined 的属性"
通常意味着 renderable 未被添加到树中:
// 错误 - 未添加到树中
const text = new TextRenderable(renderer, { content: "Hello" })
// text.someMethod() // 可能失败
// 正确
const text = new TextRenderable(renderer, { content: "Hello" })
renderer.root.add(text)
text.someMethod()布局未更新
Yoga 布局是延迟计算的。强制重新计算:
// 更改布局属性后
box.setWidth(newWidth)
renderer.requestRender()文本溢出/裁剪
文本默认不换行。设置显式宽度:
// 可能溢出
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
})
// 包含在宽度内
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
width: 40, // 将基于父元素裁剪或换行
})颜色不显示
检查终端能力和颜色格式:
// 正确的格式
fg: "#FF0000" // 十六进制
fg: "red" // CSS 颜色名称
fg: RGBA.fromHex("#FF0000")
// 错误
fg: "FF0000" // 缺少 #
fg: 0xFF0000 // 数字(不支持)性能
避免频繁重新渲染
尽可能批量更新:
// 错误 - 多次渲染调用
item1.setContent("...")
item2.setContent("...")
item3.setContent("...")
// 更好 - 所有更新后单次渲染
//(OpenTUI 会自动批量处理,但要注意)
items.forEach((item, i) => {
item.setContent(data[i])
})最小化树深度
深度嵌套会影响布局计算:
// 避免不必要的包装器
// 错误
Box({}, Box({}, Box({}, Text({ content: "Hello" }))))
// 正确
Box({}, Text({ content: "Hello" }))使用 display: none
隐藏元素而不是移除/重新添加:
// 用于切换可见性
element.setDisplay("none") // 隐藏
element.setDisplay("flex") // 可见
// 而不是
parent.remove(element)
parent.add(element)测试
测试运行器
使用 Bun 的测试运行器:
import { test, expect, beforeEach, afterEach } from "bun:test"
test("my test", () => {
expect(1 + 1).toBe(2)
})从包目录运行测试
从特定包目录运行测试:
# 正确
cd packages/core
bun test
# 对于原生测试
cd packages/core
bun run test:native过滤测试
# Bun 测试过滤器
bun test --filter "component name"
# 原生测试过滤器
bun run test:native -Dtest-filter="test name"键盘处理
键名
KeyEvent.name 的常见键名:
// 字母/数字
"a", "b", ..., "z"
"1", "2", ..., "0"
// 特殊键
"escape", "enter", "return", "tab", "backspace", "delete"
"up", "down", "left", "right"
"home", "end", "pageup", "pagedown"
"f1", "f2", ..., "f12"
"space"
// 修饰键(检查布尔属性)
key.ctrl // 按住 Ctrl
key.shift // 按住 Shift
key.meta // 按住 Alt
key.option // 按住 Option(macOS)键盘事件类型
renderer.keyInput.on("keypress", (key) => {
// eventType: "press" | "release" | "repeat"
if (key.eventType === "repeat") {
// 键正被按住
}
})核心模式
组合模式
命令式组合
创建 renderables 并使用 .add() 组合:
import { createCliRenderer, BoxRenderable, TextRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
// 创建父元素
const container = new BoxRenderable(renderer, {
id: "container",
flexDirection: "column",
padding: 1,
})
// 创建子元素
const header = new TextRenderable(renderer, {
id: "header",
content: "Header",
fg: "#00FF00",
})
const body = new TextRenderable(renderer, {
id: "body",
content: "Body content",
})
// 组合树结构
container.add(header)
container.add(body)
renderer.root.add(container)声明式组合(Constructs)
使用 VNode 函数进行更清晰的组合:
import { createCliRenderer, Box, Text, Input, delegate } from "@opentui/core"
const renderer = await createCliRenderer()
// 作为函数调用组合
const ui = Box(
{ flexDirection: "column", padding: 1 },
Text({ content: "Header", fg: "#00FF00" }),
Box(
{ flexDirection: "row", gap: 2 },
Text({ content: "Name:" }),
Input({ id: "name", placeholder: "Enter name..." }),
),
)
renderer.root.add(ui)可重用组件
为可重用的 UI 部分创建工厂函数:
// 命令式工厂
function createLabeledInput(
renderer: RenderContext,
props: { id: string; label: string; placeholder: string }
) {
const container = new BoxRenderable(renderer, {
id: `${props.id}-container`,
flexDirection: "row",
gap: 1,
})
container.add(new TextRenderable(renderer, {
id: `${props.id}-label`,
content: props.label,
}))
container.add(new InputRenderable(renderer, {
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
}))
return container
}
// 声明式工厂
function LabeledInput(props: { id: string; label: string; placeholder: string }) {
return delegate(
{ focus: `${props.id}-input` },
Box(
{ flexDirection: "row", gap: 1 },
Text({ content: props.label }),
Input({
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
}),
),
)
}焦点委托
将焦点调用路由到嵌套元素:
import { delegate, Box, Input, Text } from "@opentui/core"
const form = delegate(
{
focus: "email-input", // 将 .focus() 路由到此子元素
blur: "email-input", // 将 .blur() 路由此子元素
},
Box(
{ border: true, padding: 1 },
Text({ content: "Email:" }),
Input({ id: "email-input", placeholder: "you@example.com" }),
),
)
// 这会聚焦内部的输入框,而不是盒子
form.focus()事件处理
键盘事件
const renderer = await createCliRenderer()
// 全局键盘处理器
renderer.keyInput.on("keypress", (key) => {
if (key.name === "escape") {
renderer.destroy()
process.exit(0)
}
if (key.ctrl && key.name === "c") {
// Ctrl+C 处理(如果 exitOnCtrlC 为 false)
}
if (key.name === "tab") {
// Tab 导航
focusNext()
}
})
// 粘贴事件
renderer.keyInput.on("paste", (text) => {
currentInput?.setValue(currentInput.value + text)
})组件事件
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
const input = new InputRenderable(renderer, {
id: "search",
placeholder: "Search...",
})
input.on(InputRenderableEvents.CHANGE, (value) => {
performSearch(value)
})
// 选择事件
const select = new SelectRenderable(renderer, {
id: "menu",
options: [...],
})
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
handleSelection(option)
})
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
showPreview(option)
})鼠标事件
const button = new BoxRenderable(renderer, {
id: "button",
border: true,
onMouseDown: (event) => {
button.setBackgroundColor("#444444")
},
onMouseUp: (event) => {
button.setBackgroundColor("#222222")
handleClick()
},
onMouseMove: (event) => {
// 悬停效果
},
})状态管理
本地状态
在闭包或对象中管理状态:
// 基于闭包的状态
function createCounter(renderer: RenderContext) {
let count = 0
const display = new TextRenderable(renderer, {
id: "count",
content: `Count: ${count}`,
})
const increment = () => {
count++
display.setContent(`Count: ${count}`)
}
return { display, increment }
}
// 基于类的状态
class CounterWidget {
private count = 0
private display: TextRenderable
constructor(renderer: RenderContext) {
this.display = new TextRenderable(renderer, {
id: "count",
content: this.formatCount(),
})
}
private formatCount() {
return `Count: ${this.count}`
}
increment() {
this.count++
this.display.setContent(this.formatCount())
}
getRenderable() {
return this.display
}
}焦点管理
跨组件跟踪和管理焦点:
class FocusManager {
private focusables: Renderable[] = []
private currentIndex = 0
register(renderable: Renderable) {
this.focusables.push(renderable)
}
focusNext() {
this.focusables[this.currentIndex]?.blur()
this.currentIndex = (this.currentIndex + 1) % this.focusables.length
this.focusables[this.currentIndex]?.focus()
}
focusPrevious() {
this.focusables[this.currentIndex]?.blur()
this.currentIndex = (this.currentIndex - 1 + this.focusables.length) % this.focusables.length
this.focusables[this.currentIndex]?.focus()
}
}
// 使用
const focusManager = new FocusManager()
focusManager.register(input1)
focusManager.register(input2)
focusManager.register(select1)
renderer.keyInput.on("keypress", (key) => {
if (key.name === "tab") {
key.shift ? focusManager.focusPrevious() : focusManager.focusNext()
}
})生命周期模式
清理
始终清理资源:
const renderer = await createCliRenderer()
// 跟踪 intervals/timeouts
const intervals: Timer[] = []
intervals.push(setInterval(() => {
updateClock()
}, 1000))
// 退出时清理
process.on("SIGINT", () => {
intervals.forEach(clearInterval)
renderer.destroy()
process.exit(0)
})
// 或使用 onDestroy 回调
const renderer = await createCliRenderer({
onDestroy: () => {
intervals.forEach(clearInterval)
},
})动态更新
基于外部数据更新 UI:
async function createDashboard(renderer: RenderContext) {
const statsText = new TextRenderable(renderer, {
id: "stats",
content: "Loading...",
})
// 轮询更新
const updateStats = async () => {
const data = await fetchStats()
statsText.setContent(`CPU: ${data.cpu}% | Memory: ${data.memory}%`)
}
// 初始加载
await updateStats()
// 定期更新
setInterval(updateStats, 5000)
return statsText
}布局模式
响应式布局
适应终端尺寸:
const renderer = await createCliRenderer()
const mainPanel = new BoxRenderable(renderer, {
id: "main",
width: "100%",
height: "100%",
flexDirection: renderer.width > 80 ? "row" : "column",
})
// 监听调整大小
process.stdout.on("resize", () => {
mainPanel.setFlexDirection(renderer.width > 80 ? "row" : "column")
})分割面板
function createSplitView(renderer: RenderContext, ratio = 0.3) {
const container = new BoxRenderable(renderer, {
id: "split",
flexDirection: "row",
width: "100%",
height: "100%",
})
const left = new BoxRenderable(renderer, {
id: "left",
width: `${ratio * 100}%`,
border: true,
})
const right = new BoxRenderable(renderer, {
id: "right",
flexGrow: 1,
border: true,
})
container.add(left)
container.add(right)
return { container, left, right }
}调试模式
控制台覆盖层
使用内置控制台进行调试:
const renderer = await createCliRenderer({
consoleOptions: {
startInDebugMode: true,
},
})
// 显示控制台
renderer.console.show()
// 所有控制台方法都可用
console.log("Debug info")
console.warn("Warning")
console.error("Error")
// 使用键盘切换
renderer.keyInput.on("keypress", (key) => {
if (key.name === "f12") {
renderer.console.toggle()
}
})状态检查
function debugState(label: string, state: unknown) {
console.log(`[${label}]`, JSON.stringify(state, null, 2))
}
// 在您的更新逻辑中
debugState("form", { name: nameInput.value, email: emailInput.value })OpenTUI 核心 (@opentui/core)
构建终端用户界面的基础库。提供包含所有原语的命令式 API,为您对渲染、状态和行为的最大控制力。
概述
OpenTUI Core 运行在 Bun 上,使用原生 Zig 绑定来处理性能关键操作:
- Renderer(渲染器):管理终端输出、输入事件和渲染循环
- Renderables(可渲染组件):具有 Yoga 布局的分层 UI 构建块
- Constructs(构造器):用于组合 Renderables 的声明式包装器
- FrameBuffer(帧缓冲):用于自定义图形的低级 2D 渲染表面
何时使用 Core
在以下情况下使用核心命令式 API:
- 在 OpenTUI 之上构建库或框架
- 需要对渲染和状态的最大控制
- 希望获得尽可能小的打包体积(无需 React/Solid 运行时)
- 构建性能关键型应用程序
- 与现有命令式代码库集成
何时不使用 Core
| 场景 | 改用 |
|---|---|
| 熟悉 React 模式 | @opentui/react |
| 希望使用细粒度响应式 | @opentui/solid |
| 构建典型应用程序 | React 或 Solid 协调器 |
| 快速原型开发 | React 或 Solid 协调器 |
快速开始
使用 create-tui(推荐)
bunx create-tui@latest -t core my-app
cd my-app
bun run src/index.tsCLI 会为您创建 my-app 目录 - 该目录必须不存在。
代理指导:始终使用带 -t <template> 标志的自主模式。切勿使用交互模式(不带 -t 的 bunx create-tui@latest my-app),因为它需要代理无法响应用户提示。
手动设置
mkdir my-tui && cd my-tui
bun init
bun install @opentui/coreimport { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
// 创建一个盒子容器
const container = new BoxRenderable(renderer, {
id: "container",
width: 40,
height: 10,
border: true,
borderStyle: "rounded",
padding: 1,
})
// 在盒子内创建文本
const greeting = new TextRenderable(renderer, {
id: "greeting",
content: "Hello, OpenTUI!",
fg: "#00FF00",
})
// 组合树结构
container.add(greeting)
renderer.root.add(container)核心概念
Renderer(渲染器)
CliRenderer 协调一切:
- 管理终端视口和备用屏幕
- 处理输入事件(键盘、鼠标、粘贴)
- 运行渲染循环(可配置 FPS)
- 为可渲染树提供根节点
Renderables vs Constructs
| Renderables(命令式) | Constructs(声明式) |
|---|---|
new TextRenderable(renderer, {...}) | Text({...}) |
| 创建时需要 renderer | 创建 VNode,稍后实例化 |
| 通过方法直接修改 | 链式调用被记录,在实例化时重放 |
| 完全控制 | 更清晰的组合 |
组合选项
Renderables 可以通过两种方式组合: 1. 命令式:创建实例,调用 .add() 组合 2. 声明式(Constructs):创建 VNodes,将子元素作为参数传递
基本命令
bun install @opentui/core # 安装
bun run src/index.ts # 直接运行(无需构建)
bun test # 运行测试运行时要求
OpenTUI 运行在 Bun 上,并使用 Zig 进行原生构建。
# 包管理
bun install @opentui/core
# 运行
bun run src/index.ts
bun test
# 构建(仅在更改原生代码时需要)
bun run build构建原生组件需要 Zig。
本参考文档包含
- 配置 - 渲染器选项、环境变量
- API - 渲染器、Renderables、类型、工具
- 模式 - 组合、事件、状态管理
- 陷阱 - 常见问题、调试、限制
另请参阅
- React - 用于声明式 TUI 的 React 协调器
- Solid - 用于声明式 TUI 的 Solid 协调器
- Layout - Yoga/Flexbox 布局系统
- Components - 按类别分类的组件参考
- Keyboard - 输入处理和快捷键
- Testing - 测试渲染器和快照
键盘输入处理
如何在 OpenTUI 应用程序中处理键盘输入。
概述
OpenTUI 通过以下方式提供键盘输入处理:
- Core:
renderer.keyInputEventEmitter - React:
useKeyboard()hook - Solid:
useKeyboard()hook
使用场景
当您需要键盘快捷键、感知焦点的输入处理或自定义键绑定时,请参考此文档。
KeyEvent 对象
所有键盘处理器都接收一个 KeyEvent 对象:
interface KeyEvent {
name: string // 按键名称:"a"、"escape"、"f1" 等
sequence: string // 原始转义序列
ctrl: boolean // 按住了 Ctrl 修饰键
shift: boolean // 按住了 Shift 修饰键
meta: boolean // 按住了 Alt 修饰键
option: boolean // 按住了 Option 修饰键(macOS)
eventType: "press" | "release" | "repeat"
repeated: boolean // 按键正在被按住(重复事件)
}基本用法
Core
import { createCliRenderer, type KeyEvent } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.keyInput.on("keypress", (key: KeyEvent) => {
if (key.name === "escape") {
renderer.destroy()
return
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})React
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>按 ESC 退出</text>
}Solid
import { useKeyboard, useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>按 ESC 退出</text>
}按键名称
字母键
小写:a、b、c...z
按住 Shift:检查 key.shift && key.name === "a" 表示大写
数字键
0、1、2...9
功能键
f1、f2、f3...f12
特殊键
| 按键名称 | 描述 |
|---|---|
escape | Escape 键 |
enter | Enter/Return |
return | Enter/Return(别名) |
tab | Tab 键 |
backspace | Backspace |
delete | Delete 键 |
space | 空格键 |
方向键
| 按键名称 | 描述 |
|---|---|
up | 向上箭头 |
down | 向下箭头 |
left | 向左箭头 |
right | 向右箭头 |
导航键
| 按键名称 | 描述 |
|---|---|
home | Home 键 |
end | End 键 |
pageup | Page Up |
pagedown | Page Down |
insert | Insert 键 |
修饰键
检查 KeyEvent 上的修饰键属性:
renderer.keyInput.on("keypress", (key) => {
if (key.ctrl && key.name === "c") {
// Ctrl+C
}
if (key.shift && key.name === "tab") {
// Shift+Tab
}
if (key.meta && key.name === "s") {
// Alt+S(在大多数系统上 meta = Alt)
}
if (key.option && key.name === "a") {
// Option+A(macOS)
}
})修饰键组合
// Ctrl+Shift+S
if (key.ctrl && key.shift && key.name === "s") {
saveAs()
}
// Ctrl+Alt+Delete(注意系统快捷键!)
if (key.ctrl && key.meta && key.name === "delete") {
// ...
}事件类型
按下事件(默认)
正常的按键按下:
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "press") {
// 初始按键按下
}
})重复事件
按键被按住:
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "repeat" || key.repeated) {
// 按键正在被按住
}
})释放事件
按键释放(可选启用):
// React
useKeyboard(
(key) => {
if (key.eventType === "release") {
// 按键释放
}
},
{ release: true } // 启用释放事件
)
// Solid
useKeyboard(
(key) => {
if (key.eventType === "release") {
// 按键释放
}
},
{ release: true }
)模式
导航菜单
function Menu() {
const [selectedIndex, setSelectedIndex] = useState(0)
const items = ["Home", "Settings", "Help", "Quit"]
useKeyboard((key) => {
switch (key.name) {
case "up":
case "k":
setSelectedIndex(i => Math.max(0, i - 1))
break
case "down":
case "j":
setSelectedIndex(i => Math.min(items.length - 1, i + 1))
break
case "enter":
handleSelect(items[selectedIndex])
break
}
})
return (
<box flexDirection="column">
{items.map((item, i) => (
<text
key={item}
fg={i === selectedIndex ? "#00FF00" : "#FFFFFF"}
>
{i === selectedIndex ? "> " : " "}{item}
</text>
))}
</box>
)
}模态框退出
function Modal({ onClose, children }) {
useKeyboard((key) => {
if (key.name === "escape") {
onClose()
}
})
return (
<box border padding={2}>
{children}
</box>
)
}Vim 风格模式
function Editor() {
const [mode, setMode] = useState<"normal" | "insert">("normal")
const [content, setContent] = useState("")
useKeyboard((key) => {
if (mode === "normal") {
switch (key.name) {
case "i":
setMode("insert")
break
case "escape":
// 已经在 normal 模式中
break
case "j":
moveCursorDown()
break
case "k":
moveCursorUp()
break
}
} else if (mode === "insert") {
if (key.name === "escape") {
setMode("normal")
}
// 输入组件在 insert 模式下处理文本
}
})
return (
<box flexDirection="column">
<text>模式: {mode}</text>
<textarea
value={content}
onChange={setContent}
focused={mode === "insert"}
/>
</box>
)
}游戏控制
function Game() {
const [pressed, setPressed] = useState(new Set<string>())
useKeyboard(
(key) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (key.eventType === "release") {
newKeys.delete(key.name)
} else {
newKeys.add(key.name)
}
return newKeys
})
},
{ release: true }
)
// 游戏逻辑使用按下按键的集合
useEffect(() => {
if (pressed.has("up") || pressed.has("w")) {
moveUp()
}
if (pressed.has("down") || pressed.has("s")) {
moveDown()
}
}, [pressed])
return <text>使用 WASD 或方向键移动</text>
}键盘快捷键帮助
function ShortcutsHelp() {
const shortcuts = [
{ keys: "Ctrl+S", action: "保存" },
{ keys: "Ctrl+Q", action: "退出" },
{ keys: "Ctrl+F", action: "查找" },
{ keys: "Tab", action: "下一个字段" },
{ keys: "Shift+Tab", action: "上一个字段" },
]
return (
<box border title="键盘快捷键" padding={1}>
{shortcuts.map(({ keys, action }) => (
<box key={keys} flexDirection="row">
<text width={15} fg="#00FFFF">{keys}</text>
<text>{action}</text>
</box>
))}
</box>
)
}粘贴事件
处理粘贴的文本:
Core
renderer.keyInput.on("paste", (text: string) => {
console.log("粘贴的文本:", text)
})React/Solid
// 目前通过全局键盘监听器处理
// 或通过输入组件的 onChange 处理焦点和输入组件
输入组件(<input>、<textarea>、<select>)在获得焦点时会捕获键盘事件:
<input focused /> // 接收键盘输入
// 全局 useKeyboard 仍然会触发,但输入组件会消耗字符为了防止冲突,在处理全局快捷键之前检查是否有输入组件获得焦点:
function App() {
const renderer = useRenderer()
const [inputFocused, setInputFocused] = useState(false)
useKeyboard((key) => {
if (inputFocused) return // 让输入组件处理
// 全局快捷键
if (key.name === "escape") {
renderer.destroy()
}
})
return (
<input
focused={inputFocused}
onFocus={() => setInputFocused(true)}
onBlur={() => setInputFocused(false)}
/>
)
}注意事项
终端限制
某些按键组合会被终端或操作系统捕获:
Ctrl+C通常发送 SIGINT(使用exitOnCtrlC: false来处理)Ctrl+Z暂停进程- 某些功能键可能被拦截
SSH 和远程会话
通过 SSH 连接时按键检测可能会有所不同。请在目标环境中测试。
多个处理器
多个 useKeyboard 调用都会接收事件。请协调处理器以防止冲突。
布局模式
终端用户界面的常见布局方案。
全屏应用
填充整个终端:
function App() {
return (
<box width="100%" height="100%">
{/* 内容填充终端 */}
</box>
)
}页头/内容/页脚
经典的应用布局:
function AppLayout() {
return (
<box flexDirection="column" width="100%" height="100%">
{/* 页头 - 固定高度 */}
<box height={3} borderStyle="single" borderBottom>
<text>页头</text>
</box>
{/* 内容 - 填充剩余空间 */}
<box flexGrow={1}>
<text>主要内容</text>
</box>
{/* 页脚 - 固定高度 */}
<box height={1}>
<text>状态:就绪</text>
</box>
</box>
)
}侧边栏布局
function SidebarLayout() {
return (
<box flexDirection="row" width="100%" height="100%">
{/* 侧边栏 - 固定宽度 */}
<box width={25} borderStyle="single" borderRight>
<text>侧边栏</text>
</box>
{/* 主内容 - 填充剩余空间 */}
<box flexGrow={1}>
<text>主要内容</text>
</box>
</box>
)
}可调整大小的侧边栏
根据终端宽度响应式调整:
function ResponsiveSidebar() {
const dims = useTerminalDimensions() // React: useTerminalDimensions()
const showSidebar = dims.width > 60
const sidebarWidth = Math.min(30, Math.floor(dims.width * 0.3))
return (
<box flexDirection="row" width="100%" height="100%">
{showSidebar && (
<box width={sidebarWidth} border>
<text>侧边栏</text>
</box>
)}
<box flexGrow={1}>
<text>主要内容</text>
</box>
</box>
)
}居中内容
水平居中
<box width="100%" justifyContent="center">
<box width={40}>
<text>水平居中</text>
</box>
</box>垂直居中
<box height="100%" alignItems="center">
<text>垂直居中</text>
</box>两轴都居中
<box
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
>
<box width={40} height={10} border>
<text>两轴都居中</text>
</box>
</box>模态框/对话框
居中的覆盖层:
function Modal({ children, visible }) {
if (!visible) return null
return (
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
backgroundColor="rgba(0,0,0,0.5)"
>
<box
width={50}
height={15}
border
borderStyle="double"
backgroundColor="#1a1a2e"
padding={2}
>
{children}
</box>
</box>
)
}网格布局
使用 flexWrap:
function Grid({ items, columns = 3 }) {
const itemWidth = `${Math.floor(100 / columns)}%`
return (
<box flexDirection="row" flexWrap="wrap" width="100%">
{items.map((item, i) => (
<box key={i} width={itemWidth} padding={1}>
<text>{item}</text>
</box>
))}
</box>
)
}分割面板
水平分割
function HorizontalSplit({ ratio = 0.5 }) {
return (
<box flexDirection="row" width="100%" height="100%">
<box width={`${ratio * 100}%`} border>
<text>左侧面板</text>
</box>
<box flexGrow={1} border>
<text>右侧面板</text>
</box>
</box>
)
}垂直分割
function VerticalSplit({ ratio = 0.5 }) {
return (
<box flexDirection="column" width="100%" height="100%">
<box height={`${ratio * 100}%`} border>
<text>上方面板</text>
</box>
<box flexGrow={1} border>
<text>下方面板</text>
</box>
</box>
)
}表单布局
标签 + 输入框配对:
function FormField({ label, children }) {
return (
<box flexDirection="row" marginBottom={1}>
<box width={15}>
<text>{label}:</text>
</box>
<box flexGrow={1}>
{children}
</box>
</box>
)
}
function LoginForm() {
return (
<box flexDirection="column" padding={2} border width={50}>
<FormField label="用户名">
<input placeholder="输入用户名" />
</FormField>
<FormField label="密码">
<input placeholder="输入密码" />
</FormField>
<box marginTop={2} justifyContent="flex-end">
<box border padding={1}>
<text>登录</text>
</box>
</box>
</box>
)
}导航标签
function TabBar({ tabs, activeIndex, onSelect }) {
return (
<box flexDirection="row" borderBottom>
{tabs.map((tab, i) => (
<box
key={i}
padding={1}
backgroundColor={i === activeIndex ? "#333" : "transparent"}
onMouseDown={() => onSelect(i)}
>
<text fg={i === activeIndex ? "#fff" : "#888"}>
{tab}
</text>
</box>
))}
</box>
)
}粘性页脚
页脚始终位于底部:
function StickyFooterLayout() {
return (
<box flexDirection="column" width="100%" height="100%">
{/* 内容区域 */}
<box flexGrow={1} flexDirection="column">
{/* 您的内容在这里 */}
<text>可能较短的内容</text>
</box>
{/* 页脚被推到底部 */}
<box height={1}>
<text fg="#888">按 ? 获取帮助 | 按 q 退出</text>
</box>
</box>
)
}绝对定位覆盖层
工具提示或弹出窗口:
function Tooltip({ x, y, children }) {
return (
<box
position="absolute"
left={x}
top={y}
border
backgroundColor="#333"
padding={1}
zIndex={100}
>
{children}
</box>
)
}响应式断点
根据终端大小的不同布局:
function ResponsiveApp() {
const { width, height } = useTerminalDimensions()
// 定义断点
const isSmall = width < 60
const isMedium = width >= 60 && width < 100
const isLarge = width >= 100
if (isSmall) {
// 移动端风格:堆叠布局
return (
<box flexDirection="column">
<Navigation />
<Content />
</box>
)
}
if (isMedium) {
// 平板风格:侧边栏 + 内容
return (
<box flexDirection="row">
<box width={20}><Navigation /></box>
<box flexGrow={1}><Content /></box>
</box>
)
}
// 大屏:完整布局
return (
<box flexDirection="row">
<box width={25}><Navigation /></box>
<box flexGrow={1}><Content /></box>
<box width={30}><Sidebar /></box>
</box>
)
}等高列
function EqualColumns() {
return (
<box flexDirection="row" alignItems="stretch" height={20}>
<box flexGrow={1} border>
<text>短内容</text>
</box>
<box flexGrow={1} border>
<text>
跨越多行并
占用空间的
较长内容
</text>
</box>
<box flexGrow={1} border>
<text>中等长度内容</text>
</box>
</box>
)
}间距工具
一致的间距模式:
// 间距组件
function Spacer({ size = 1 }) {
return <box height={size} width={size} />
}
// 分隔符组件
function Divider() {
return <box height={1} width="100%" backgroundColor="#333" />
}
// 使用方式
<box flexDirection="column">
<text>第一节</text>
<Spacer size={2} />
<Divider />
<Spacer size={2} />
<text>第二节</text>
</box>OpenTUI 布局系统
OpenTUI 使用 Yoga 布局引擎,提供类似 CSS Flexbox 的功能,用于在终端中定位和调整组件大小。
概述
核心概念:
- Flexbox 模型:熟悉的 CSS Flexbox 属性
- Yoga 引擎:Facebook 的跨平台布局引擎
- 终端单位:尺寸以字符单元格为单位(列 × 行)
- 百分比支持:基于父元素的相对大小
Flex 容器属性
flexDirection
控制主轴方向:
// Row(默认)- 子元素水平流动
<box flexDirection="row">
<text>1</text>
<text>2</text>
<text>3</text>
</box>
// 输出:1 2 3
// Column - 子元素垂直流动
<box flexDirection="column">
<text>1</text>
<text>2</text>
<text>3</text>
</box>
// 输出:
// 1
// 2
// 3
// 反向变体
<box flexDirection="row-reverse">...</box> // 3 2 1
<box flexDirection="column-reverse">...</box> // 从下到上justifyContent
沿主轴对齐子元素:
<box flexDirection="row" width={40} justifyContent="flex-start">
{/* 子元素在开始位置(对于 row 来说是左侧) */}
</box>
<box flexDirection="row" width={40} justifyContent="flex-end">
{/* 子元素在结束位置(对于 row 来说是右侧) */}
</box>
<box flexDirection="row" width={40} justifyContent="center">
{/* 子元素居中 */}
</box>
<box flexDirection="row" width={40} justifyContent="space-between">
{/* 第一个在开始,最后一个在结束,其余均匀分布 */}
</box>
<box flexDirection="row" width={40} justifyContent="space-around">
{/* 每个子元素周围有相等的空间 */}
</box>
<box flexDirection="row" width={40} justifyContent="space-evenly">
{/* 所有子元素和边缘之间有相等的空间 */}
</box>alignItems
沿交叉轴对齐子元素:
<box flexDirection="row" height={10} alignItems="flex-start">
{/* 子元素在顶部 */}
</box>
<box flexDirection="row" height={10} alignItems="flex-end">
{/* 子元素在底部 */}
</box>
<box flexDirection="row" height={10} alignItems="center">
{/* 子元素垂直居中 */}
</box>
<box flexDirection="row" height={10} alignItems="stretch">
{/* 子元素拉伸以填充高度 */}
</box>
<box flexDirection="row" height={10} alignItems="baseline">
{/* 子元素按文本基线对齐 */}
</box>flexWrap
控制子元素是否换行:
<box flexDirection="row" flexWrap="nowrap" width={20}>
{/* 子元素溢出(默认) */}
</box>
<box flexDirection="row" flexWrap="wrap" width={20}>
{/* 子元素换到下一行 */}
</box>
<box flexDirection="row" flexWrap="wrap-reverse" width={20}>
{/* 子元素向上换行 */}
</box>gap
子元素之间的间距:
<box flexDirection="row" gap={2}>
<text>A</text>
<text>B</text>
<text>C</text>
</box>
// 输出:A B C(之间有 2 个空格)Flex 子元素属性
flexGrow
子元素相对于兄弟元素的增长程度:
<box flexDirection="row" width={30}>
<box flexGrow={1}><text>1</text></box>
<box flexGrow={2}><text>2</text></box>
<box flexGrow={1}><text>1</text></box>
</box>
// 宽度:7.5 | 15 | 7.5(1:2:1 比例)flexShrink
当空间受限时子元素的收缩程度:
<box flexDirection="row" width={20}>
<box width={15} flexShrink={1}><text>可收缩</text></box>
<box width={15} flexShrink={0}><text>固定</text></box>
</box>flexBasis
在增长/收缩之前的初始大小:
<box flexDirection="row">
<box flexBasis={20} flexGrow={1}>从 20 开始,可以增长</box>
<box flexBasis="50%">父元素的一半</box>
</box>alignSelf
为该子元素覆盖父元素的 alignItems:
<box flexDirection="row" height={10} alignItems="center">
<text>居中</text>
<text alignSelf="flex-start">顶部</text>
<text alignSelf="flex-end">底部</text>
</box>尺寸
固定尺寸
<box width={40} height={10}>
{/* 正好 40 列 × 10 行 */}
</box>百分比尺寸
父元素必须具有明确的大小:
<box width="100%" height="100%">
<box width="50%" height="50%">
{/* 父元素的一半 */}
</box>
</box>最小/最大约束
<box
minWidth={20}
maxWidth={60}
minHeight={5}
maxHeight={20}
>
{/* 受约束的尺寸 */}
</box>间距
Padding(内边距)
// 所有边
<box padding={2}>内容</box>
// 单独的边
<box
paddingTop={1}
paddingRight={2}
paddingBottom={1}
paddingLeft={2}
>
内容
</box>Margin(外边距)
// 所有边
<box margin={1}>内容</box>
// 单独的边
<box
marginTop={1}
marginRight={2}
marginBottom={1}
marginLeft={2}
>
内容
</box>定位
Relative(默认)
元素按正常文档顺序流动:
<box position="relative">
{/* 正常流动 */}
</box>Absolute
元素相对于最近的已定位祖先元素定位:
<box position="relative" width="100%" height="100%">
<box
position="absolute"
left={10}
top={5}
width={20}
height={5}
>
定位在 (10, 5)
</box>
</box>定位属性
<box
position="absolute"
left={10} // 距离左边缘
top={5} // 距离上边缘
right={10} // 距离右边缘
bottom={5} // 距离下边缘
>
内容
</box>显示
可见性控制
// 可见(默认)
<box display="flex">可见</box>
// 隐藏(从布局中移除)
<box display="none">隐藏</box>溢出
<box overflow="visible">
{/* 内容可以超出边界(默认) */}
</box>
<box overflow="hidden">
{/* 内容在边界处被裁剪 */}
</box>
<box overflow="scroll">
{/* 当内容超出边界时可滚动 */}
</box>Z-Index
控制重叠元素的堆叠顺序:
<box position="relative">
<box position="absolute" zIndex={1}>在后面</box>
<box position="absolute" zIndex={2}>在前面</box>
</box>另请参阅
- 布局模式 - 常见的布局方案
- 组件/容器 - Box 和 ScrollBox 详细信息
React API 参考
渲染
createRoot(renderer)
创建用于渲染的 React 根。
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
const renderer = await createCliRenderer({
exitOnCtrlC: false, // 自己处理 Ctrl+C
})
const root = createRoot(renderer)
root.render(<App />)Hooks
useRenderer()
访问 OpenTUI renderer 实例。
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
// 访问 renderer 属性
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
// 显示调试控制台
renderer.console.show()
}, [renderer])
return <text>Hello</text>
}useKeyboard(handler, options?)
处理键盘事件。
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // 永远不要直接使用 process.exit()!
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})
return <text>Press ESC to exit</text>
}
// 包含释放事件
function GameControls() {
const [pressed, setPressed] = useState(new Set<string>())
useKeyboard(
(event) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true } // 包含释放事件
)
return <text>Pressed: {Array.from(pressed).join(", ")}</text>
}选项:
release?: boolean- 包含按键释放事件(默认:false)
KeyEvent 属性:
name: string- 按键名称("a"、"escape"、"f1" 等)sequence: string- 原始转义序列ctrl: boolean- Ctrl 修饰符shift: boolean- Shift 修饰符meta: boolean- Alt 修饰符option: boolean- Option 修饰符(macOS)eventType: "press" | "release" | "repeat"repeated: boolean- 按键正在被按住
useOnResize(callback)
处理终端调整大小事件。
import { useOnResize } from "@opentui/react"
function App() {
useOnResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize the terminal</text>
}useTerminalDimensions()
获取响应式终端尺寸。
import { useTerminalDimensions } from "@opentui/react"
function ResponsiveLayout() {
const { width, height } = useTerminalDimensions()
return (
<box flexDirection={width > 80 ? "row" : "column"}>
<box flexGrow={1}>
<text>Width: {width}</text>
</box>
<box flexGrow={1}>
<text>Height: {height}</text>
</box>
</box>
)
}useTimeline(options?)
使用时间轴系统创建动画。
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function AnimatedBox() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
useEffect(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
}, [timeline])
return <box style={{ width, height: 3, backgroundColor: "#6a5acd" }} />
}选项:
duration?: number- 默认持续时间(毫秒)loop?: boolean- 循环时间轴autoplay?: boolean- 自动开始(默认:true)onComplete?: () => void- 完成回调onPause?: () => void- 暂停回调
Timeline 方法:
add(target, properties, startTime?)- 添加动画play()- 开始播放pause()- 暂停播放restart()- 从头重新开始
组件
Text 组件
<text
content="Hello" // 或者使用 children
fg="#FFFFFF" // 前景色
bg="#000000" // 背景色
selectable={true} // 允许文本选择
>
{/* 使用嵌套修饰符标签进行样式设置 */}
<span fg="red">Red</span>
<strong>Bold</strong>
<em>Italic</em>
<u>Underline</u>
<br />
<a href="https://...">Link</a>
</text>注意:不要在<text>上将bold、italic、underline作为 props 使用。请改用嵌套的修饰符标签,如<strong>、<em>、<u>。
Box 组件
<box
// 边框
border // 启用边框
borderStyle="single" // single | double | rounded | bold
borderColor="#FFFFFF"
title="Title"
titleAlignment="center" // left | center | right
// 颜色
backgroundColor="#1a1a2e"
// 布局(参见 layout/REFERENCE.md)
flexDirection="row"
justifyContent="center"
alignItems="center"
gap={2}
// 间距
padding={2}
paddingTop={1}
margin={1}
// 尺寸
width={40}
height={10}
flexGrow={1}
// 事件
onMouseDown={(e) => {}}
onMouseUp={(e) => {}}
onMouseMove={(e) => {}}
>
{children}
</box>Scrollbox 组件
<scrollbox
focused // 启用键盘滚动
style={{
rootOptions: { backgroundColor: "#24283b" },
wrapperOptions: { backgroundColor: "#1f2335" },
viewportOptions: { backgroundColor: "#1a1b26" },
contentOptions: { backgroundColor: "#16161e" },
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
{/* 可滚动内容 */}
{items.map((item, i) => (
<box key={i}>
<text>{item}</text>
</box>
))}
</scrollbox>Input 组件
<input
value={value}
onChange={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused // 开始时获得焦点
width={30}
backgroundColor="#1a1a1a"
textColor="#FFFFFF"
cursorColor="#00FF00"
focusedBackgroundColor="#2a2a2a"
/>Textarea 组件
<textarea
value={text}
onChange={(newValue) => setText(newValue)}
placeholder="Enter multiple lines..."
focused
width={40}
height={10}
showLineNumbers
wrapText
/>Select 组件
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
]}
onChange={(index, option) => setSelected(option)}
selectedIndex={0}
focused
showScrollIndicator
height={8}
/>Tab Select 组件
<tab-select
options={[
{ name: "Home", description: "Dashboard" },
{ name: "Settings", description: "Configuration" },
]}
onChange={(index, option) => setTab(option)}
tabWidth={20}
focused
/>ASCII Font 组件
<ascii-font
text="TITLE"
font="tiny" // tiny | block | slick | shade
color="#FFFFFF"
/>Code 组件
<code
code={sourceCode}
language="typescript"
showLineNumbers
highlightLines={[1, 5, 10]}
/>Line Number 组件
<line-number
code={sourceCode}
language="typescript"
startLine={1}
highlightedLines={[5]}
diagnostics={[
{ line: 3, severity: "error", message: "Syntax error" }
]}
/>Diff 组件
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified" // unified | split
showLineNumbers
/>类型导出
import type {
// Component props
TextProps,
BoxProps,
InputProps,
SelectProps,
// Hook types
KeyEvent,
// From core
CliRenderer,
} from "@opentui/react"React 配置
项目设置
快速开始
bunx create-tui@latest -t react my-app
cd my-app && bun installCLI 会为您创建 my-app 目录 - 该目录必须不存在。
选项:--no-git(跳过 git init),--no-install(跳过 bun install)
手动设置
mkdir my-tui && cd my-tui
bun init
bun install @opentui/react @opentui/core reactTypeScript 配置
tsconfig.json
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}关键设置:
jsx: "react-jsx"- 使用新的 JSX 转换jsxImportSource: "@opentui/react"- 从 OpenTUI 导入 JSX 运行时
为什么需要 DOM lib?
DOM lib 是 React 类型所需要的。OpenTUI 的 JSX 类型扩展自 React 的类型。
包配置
package.json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.tsx",
"dev": "bun --watch run src/index.tsx",
"test": "bun test",
"build": "bun build src/index.tsx --outdir=dist --target=bun"
},
"dependencies": {
"@opentui/core": "latest",
"@opentui/react": "latest",
"react": ">=19.0.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": ">=19.0.0",
"typescript": "latest"
}
}项目结构
推荐结构:
my-tui-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── MainContent.tsx
│ ├── hooks/
│ │ └── useAppState.ts
│ ├── App.tsx
│ └── index.tsx
├── package.json
└── tsconfig.json入口点(src/index.tsx)
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
import { App } from "./App"
const renderer = await createCliRenderer({
exitOnCtrlC: true,
})
createRoot(renderer).render(<App />)App 组件(src/App.tsx)
import { Header } from "./components/Header"
import { Sidebar } from "./components/Sidebar"
import { MainContent } from "./components/MainContent"
export function App() {
return (
<box flexDirection="column" width="100%" height="100%">
<Header />
<box flexDirection="row" flexGrow={1}>
<Sidebar />
<MainContent />
</box>
</box>
)
}Renderer 配置
createCliRenderer 选项
import { createCliRenderer, ConsolePosition } from "@opentui/core"
const renderer = await createCliRenderer({
// 渲染
targetFPS: 60,
// 行为
exitOnCtrlC: true, // 设置为 false 以自己处理 Ctrl+C
// 调试控制台
consoleOptions: {
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
// 清理
onDestroy: () => {
// 清理代码
},
})构建用于分发
使用 Bun 打包
// build.ts
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
})运行:bun run build.ts
创建可执行文件
// build.ts
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
compile: {
target: "bun-darwin-arm64", // 或 bun-linux-x64 等
outfile: "my-app",
},
})环境变量
为开发创建 .env:
# Debug settings
OTUI_SHOW_STATS=false
SHOW_CONSOLE=false
# App settings
API_URL=https://api.example.comBun 会自动加载 .env 文件。通过 process.env 访问:
const apiUrl = process.env.API_URLReact DevTools
OpenTUI React 支持 React DevTools 进行调试。
设置
1. 将 DevTools 安装为开发依赖项(必须使用版本 7):
bun add react-devtools-core@7 -d2. 运行 DevTools 独立应用:
npx react-devtools@73. 使用 DEV=true 环境变量启动您的应用:
DEV=true bun run src/index.tsx重要:只有设置了 DEV=true 时才会自动连接到 DevTools。如果没有这个环境变量,DevTools 连接代码不会加载。
工作原理
OpenTUI 在启动时检查 process.env["DEV"] === "true"。如果为 true,它会动态导入 react-devtools-core 并连接到独立的 DevTools 应用。
测试配置
测试设置
// src/test-utils.tsx
import { createTestRenderer } from "@opentui/core/testing"
import { createRoot } from "@opentui/react"
export async function renderForTest(
element: React.ReactElement,
options = { width: 80, height: 24 }
) {
const testSetup = await createTestRenderer(options)
createRoot(testSetup.renderer).render(element)
return testSetup
}测试示例
// src/components/Counter.test.tsx
import { test, expect } from "bun:test"
import { renderForTest } from "../test-utils"
import { Counter } from "./Counter"
test("Counter renders initial value", async () => {
const { snapshot } = await renderForTest(<Counter initialValue={5} />)
expect(snapshot()).toContain("Count: 5")
})常见问题
JSX 类型不工作
确保设置了 jsxImportSource:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react"
}
}React 版本不匹配
确保使用 React 19+:
bun install react@19 @types/react@19模块解析错误
使用 moduleResolution: "bundler" 以兼容 Bun。
React 常见问题
关键问题
永远不要直接使用 process.exit()
这是最常见的错误。使用 process.exit() 会使终端处于损坏状态(光标隐藏、原始模式、备用屏幕)。
// 错误 - 终端处于损坏状态
process.exit(0)
// 正确 - 使用 renderer.destroy()
import { useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
const handleExit = () => {
renderer.destroy() // 正确清理并退出
}
}renderer.destroy() 在退出之前会恢复终端(退出备用屏幕、恢复光标等)。
JSX 配置
缺少 jsxImportSource
症状:JSX 元素类型错误,组件无法渲染
// Error: Property 'text' does not exist on type 'JSX.IntrinsicElements'修复:配置 tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react"
}
}HTML 元素 vs TUI 元素
OpenTUI 的 JSX 元素不是 HTML 元素:
// 错误 - 这些是 HTML 概念
<div>Not supported</div>
<button>Not supported</button>
<span>Only works inside <text></span>
// 正确 - OpenTUI 元素
<box>Container</box>
<text>Display text</text>
<text><span>Inline styled</span></text>组件问题
文本修饰符在文本之外
文本修饰符仅在 <text> 内部有效:
// 错误
<box>
<strong>This won't work</strong>
</box>
// 正确
<box>
<text>
<strong>This works</strong>
</text>
</box>焦点不工作
组件必须显式获得焦点:
// 错误 - 不会接收键盘输入
<input placeholder="Type here..." />
// 正确
<input placeholder="Type here..." focused />
// 或者管理焦点状态
const [isFocused, setIsFocused] = useState(true)
<input placeholder="Type here..." focused={isFocused} />Select 不响应
Select 需要焦点和正确的选项格式:
// 错误 - 缺少必需的属性
<select options={["a", "b", "c"]} />
// 正确
<select
options={[
{ name: "Option A", description: "First option", value: "a" },
{ name: "Option B", description: "Second option", value: "b" },
]}
onSelect={(index, option) => {
// 当按下 Enter 时调用
console.log("Selected:", option.name)
}}
focused
/>Select 事件混淆
记住:onSelect 在 Enter 时触发(选择确认),onChange 在导航时触发:
// 错误 - 期望 onChange 在 Enter 时触发
<select
options={options}
onChange={(i, opt) => submitForm(opt)} // 这在箭头键时触发!
/>
// 正确
<select
options={options}
onSelect={(i, opt) => submitForm(opt)} // 按下 Enter - 提交
onChange={(i, opt) => showPreview(opt)} // 箭头键 - 预览
/>Hook 问题
useKeyboard 不触发
多个 useKeyboard hooks 可能冲突:
// 两个处理程序都会触发 - 可能导致问题
function App() {
useKeyboard((key) => { /* 父处理程序 */ })
return <ChildWithKeyboard />
}
function ChildWithKeyboard() {
useKeyboard((key) => { /* 子处理程序 */ })
return <text>Child</text>
}解决方案:使用单个键盘处理程序或实现事件停止:
function App() {
const [handled, setHandled] = useState(false)
useKeyboard((key) => {
if (handled) {
setHandled(false)
return
}
// 在应用级别处理
})
return <Child onKeyHandled={() => setHandled(true)} />
}useEffect 清理
始终清理 intervals 和 listeners:
// 错误 - 内存泄漏
useEffect(() => {
setInterval(() => updateData(), 1000)
}, [])
// 正确
useEffect(() => {
const interval = setInterval(() => updateData(), 1000)
return () => clearInterval(interval) // 清理!
}, [])样式问题
颜色不应用
检查颜色格式:
// 正确格式
<text fg="#FF0000">Red</text>
<text fg="red">Red</text>
<box backgroundColor="#1a1a2e">Box</box>
// 错误
<text fg="FF0000">Missing #</text>
<text color="#FF0000">Wrong prop name (use fg)</text>布局不工作
确保父元素有尺寸:
// 错误 - 父元素没有高度
<box flexDirection="column">
<box flexGrow={1}>Won't grow</box>
</box>
// 正确
<box flexDirection="column" height="100%">
<box flexGrow={1}>Will grow</box>
</box>百分比宽度不工作
父元素必须有明确尺寸:
// 错误
<box>
<box width="50%">Won't work</box>
</box>
// 正确
<box width="100%">
<box width="50%">Works</box>
</box>性能问题
重渲染过多
避免 props 中的内联对象/函数:
// 错误 - 每次渲染都是新对象
<box style={{ padding: 2 }}>Content</box>
// 更好 - 使用直接 props
<box padding={2}>Content</box>
// 或记忆化样式对象
const style = useMemo(() => ({ padding: 2 }), [])
<box style={style}>Content</box>重型组件
对昂贵的组件使用 React.memo:
const ExpensiveList = React.memo(function ExpensiveList({
items
}: {
items: Item[]
}) {
return (
<box flexDirection="column">
{items.map(item => (
<text key={item.id}>{item.name}</text>
))}
</box>
)
})渲染期间的状态更新
不要在渲染期间更新状态:
// 错误
function Component({ value }: { value: number }) {
const [count, setCount] = useState(0)
// 这会导致无限循环!
if (value > 10) {
setCount(value)
}
return <text>{count}</text>
}
// 正确
function Component({ value }: { value: number }) {
const [count, setCount] = useState(0)
useEffect(() => {
if (value > 10) {
setCount(value)
}
}, [value])
return <text>{count}</text>
}调试
控制台不可见
OpenTUI 捕获控制台输出。显示覆盖层:
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
renderer.console.show()
console.log("Now you can see this!")
}, [renderer])
return <box>{/* ... */}</box>
}组件不渲染
检查组件是否在树中:
// 错误 - 条件返回空值
function MaybeComponent({ show }: { show: boolean }) {
if (!show) return // 返回 undefined!
return <text>Visible</text>
}
// 正确
function MaybeComponent({ show }: { show: boolean }) {
if (!show) return null // 显式返回 null
return <text>Visible</text>
}事件不触发
检查事件处理程序名称:
// 错误
<box onClick={() => {}}>Click</box> // TUI 中没有 onClick
// 正确
<box onMouseDown={() => {}}>Click</box>
<box onMouseUp={() => {}}>Click</box>运行时问题
使用 Bun,不是 Node
# 错误
node src/index.tsx
npm run start
# 正确
bun run src/index.tsx
bun run start异步顶层
Bun 支持顶层 await,但要小心:
// index.tsx - 这在 Bun 中有效
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
// 如果需要处理错误
try {
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
} catch (error) {
console.error("Failed to initialize:", error)
process.exit(1)
}常见错误消息
"Cannot read properties of undefined (reading 'root')"
Renderer 未初始化:
// 错误
const renderer = createCliRenderer() // 缺少 await!
createRoot(renderer).render(<App />)
// 正确
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)"Invalid hook call"
在组件外部调用 hooks:
// 错误
const dimensions = useTerminalDimensions() // 在组件外部!
function App() {
return <text>{dimensions.width}</text>
}
// 正确
function App() {
const dimensions = useTerminalDimensions()
return <text>{dimensions.width}</text>
}