OpenTUI + SolidJS 开发终端 TUI 应用:一份从入门到精通的完整指南
约 17 分钟5043 字5 次阅读

OpenTUI + SolidJS 开发终端 TUI 应用:一份从入门到精通的完整指南
前言
如果你曾尝试为 AI 编码助手(如 OpenCode、Claude Code)构建终端界面,或者想用现代前端框架的思路开发一个功能丰富的终端应用,你大概率会遇到这样的困境:传统的 curses 库过于底层,手写状态管理复杂且容易出 bug;现有的 Node.js TUI 框架要么性能堪忧,要么 API 设计不够声明式,开发体验大打折扣。
OpenTUI 正是为解决这些问题而生的。它用 Zig 编写核心渲染引擎(保证极致性能),通过 TypeScript bindings 提供声明式 API,支持 SolidJS 和 React 两大前端生态。11000+ GitHub Stars、生产环境支撑 OpenCode 和 terminal.shop 的稳定运行——这不是一个实验性项目,而是真正经过实战检验的 TUI 基础设施。
本文将系统讲解 OpenTUI 的核心设计理念、@opentui/solid 的完整使用方式,通过大量实战代码演示从环境搭建、基础组件、布局系统、输入处理到高级特性的全链路开发流程。无论你是想构建一个 AI 编程助手的终端界面,还是想实现一个功能完备的文件管理器,本文都将为你提供所需的一切知识。
第一章:OpenTUI 核心架构解析
1.1 为什么需要 OpenTUI?
在深入技术细节之前,我们先理解 OpenTUI 解决的问题域和它独特的设计哲学。
传统 TUI 开发的问题:
- Curses 过于底层:需要手动管理屏幕刷新、坐标计算、光标位置,状态同步全靠开发者自觉。一个 1000 行的 TUI 应用,光是管理光标和重绘逻辑就能占去 40% 的代码量。
- React Terminal 方案性能差:一些基于 ANSI 转义序列的 React 方案每次状态更新都全屏重绘,在复杂界面下输入延迟高达数百毫秒,根本无法用于生产。
- 缺乏组件化抽象:缺乏声明式的布局系统,UI 层级关系全靠坐标堆叠,一旦要调整布局几乎是重写。
OpenTUI 的解决思路:
OpenTUI 的核心是一个 Zig 编写的原生渲染引擎(@opentui/core),它直接操作终端的帧缓冲区(framebuffer),实现了真正的增量渲染——只有变化的区域才会被重绘。这保证了亚毫秒级的响应速度和极其流畅的用户体验。
同时,OpenTUI 提供了多框架 reconciler:目前已有 @opentui/solid(SolidJS)和 @opentui/react(React)两个官方包。这意味着你可以用熟悉的响应式前端框架(SolidJS 的 signals、React 的 hooks)来构建 TUI 界面,而不需要学习任何新的状态管理范式。SolidJS 的细粒度响应式模型尤其适合 TUI 场景——组件的精确更新机制与 OpenTUI 的增量渲染高度契合。
OpenTUI 还暴露了 C ABI,理论上任何语言都可以绑定使用。当前已有 Zig、TypeScript/JavaScript 的成熟bindings,社区还出现了 Rust 的实验性移植(opentui_rust)。
1.2 架构总览
OpenTUI 的架构分为五层,从上到下分别是:
应用层(Your App):你的 SolidJS/React 代码,使用 JSX 编写 UI(<box>、<text>、<input>、<scrollbox> 等)。
Reconciler 层(@opentui/solid / @opentui/react):SolidJS/React reconciler,负责将组件树的变化同步到 OpenTUI 的 renderable 树。
Core 层(@opentui/core):TypeScript 核心包,提供 imperative API、renderable 树管理、事件系统。
Zig 核心层(OpenTUI Core):Zig 编写的原生渲染引擎,进行帧缓冲操作、增量渲染计算、C ABI 暴露。
终端层(Terminal):最终输出,支持 Kitty、iTerm2 等高级终端的 True Color、OSC 8 超链接等特性。
1.3 核心概念速查表
| 概念 | 说明 |
|---|---|
| Renderable | OpenTUI 的基本绘制单元,对应一个 UI 元素(如一个 box、一段文本) |
| TextNode | 文本节点,用于表示文字内容,支持样式属性(颜色、粗体、斜体等) |
| CliRenderer | CLI 渲染器,负责初始化终端、计算布局、处理输入事件 |
| Reconciler | 调和器,将 SolidJS/React 组件树的变化同步到 renderable 树 |
| Slot | 插槽,用于组件间的内容分发,类似于 Web Components 的 slot |
| Portal | 传送门,将内容渲染到 renderable 树的另一个位置(如浮层、遮罩) |
| Framebuffer | 帧缓冲区,OpenTUI 在内部维护一个虚拟屏幕缓冲区,通过对比前后两帧的差异,只重绘变化的单元格 |
第二章:环境准备与快速上手
2.1 系统依赖
OpenTUI 的 Zig 核心在构建 TypeScript 包时需要 Zig 编译器。以下是完整的环境依赖清单:
| 依赖 | 版本要求 | 安装方式 |
|---|---|---|
| Zig | ≥ 0.13 | brew install zig 或从 ziglang.org 下载 |
| Bun | ≥ 1.0 | `curl -fsSL https://bun.sh/install |
| Node.js | ≥ 18(备选) | 若不使用 Bun 时 |
验证 Zig 是否安装成功:
zig version
# 预期输出:0.13.0 或更高版本
2.2 项目初始化
OpenTUI 官方提供了一个交互式脚手架工具 create-tui,可以一键创建包含完整配置的 SolidJS 或 React 项目:
# 使用 Bun 创建 SolidJS 项目
bun create tui
# 创建过程中会有交互式提示:
# ? Select a framework: (Use arrow keys)
# > Solid.js
# React
# ? Enter a project name: my-first-tui
# 然后 Bun 会自动:
# 1. 创建项目目录结构
# 2. 安装所有依赖
# 3. 配置好 tsconfig.json 的 jsxImportSource
# 4. 配置好 bunfig.toml 的 preload
如果想手动创建一个新项目(推荐熟悉后使用),步骤如下:
mkdir my-tui && cd my-tui
bun init -y
bun add solid-js @opentui/solid
tsconfig.json 必须配置 jsxImportSource:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true
}
}
bunfig.toml 必须配置 preload(这是关键步骤!):
[install]
preload = ["@opentui/solid/preload"]
⚠️ 为什么需要 preload? OpenTUI 的渲染器需要通过一个 preload 脚本在 Bun 运行时初始化终端环境(设置原始模式、处理窗口大小变化信号等)。没有这个 preload,渲染器无法正常工作。
2.3 第一个 Hello World
创建 index.tsx:
import { render } from "@opentui/solid"
const App = () => <text>Hello, OpenTUI World!</text>
render(App)
运行:
bun index.tsx
你应该会看到终端中打印出了 "Hello, OpenTUI World!"。就这么简单——没有 HTML、没有 CSS、没有构建步骤,Bun 直接运行。
第三章:核心组件详解
OpenTUI 提供了一套丰富的内置组件,涵盖了布局、文本展示、表单输入、代码显示等 TUI 常见场景。这一章我们对每个组件进行逐一讲解。
3.1 布局与容器组件
3.1.1 <text> — 文本容器
<text> 是最基础的组件,用于包裹纯文本或带有样式的文本片段。所有文本修饰符(<span>、<b>、<i> 等)必须放在 <text> 内部。
基础用法:
const Greeting = () => (
<text>Hello, OpenTUI!</text>
)
带样式的前景/背景色:
const StyledText = () => (
<text style={{ fg: "cyan", bg: "black" }}>
Cyan text on black background
</text>
)
颜色支持 16 色关键字(red、green、blue、cyan、yellow、magenta、black、white 等)、Hex 格式(#ff0000)以及 256 色编号。
3.1.2 <box> — 布局容器
<box> 是 Flexbox 风格的布局容器,支持边框、内边距、Flex 方向、对齐等属性。这是你构建大多数 UI 布局的主力组件。
基础属性:
const BasicBox = () => (
<box
width={40}
height={10}
border="single"
padding={1}
flexDirection="row"
justifyContent="center"
alignItems="center"
>
<text>Hello from a box!</text>
</box>
)
边框类型一览:
| 边框类型 | 显示效果 |
|---|---|
single | ┌──────┐ |
double | ╔══════╗ |
round | ╭──────╮ |
bold | ┏━━━━━━┓ |
solid | ████████ |
Flex 布局实战:
const FlexLayout = () => (
<box flexDirection="row" padding={1}>
<box width={10} border="single">
<text>Left</text>
</box>
<box flexGrow={1} border="double">
<text>Center (grows)</text>
</box>
<box width={10} border="single">
<text>Right</text>
</box>
</box>
)
flexGrow={1} 使该 box 占据所有剩余空间。
3.1.3 <scrollbox> — 滚动容器
当内容超出容器可视区域时,<scrollbox> 提供滚动功能,支持垂直和水平滚动。
const ScrollDemo = () => {
const items = Array.from({ length: 50 }, (_, i) => `Item ${i + 1}`)
return (
<scrollbox width={30} height={15} border="single">
{items.map((item) => (
<text>{item}</text>
))}
</scrollbox>
)
}
3.1.4 <ascii_font> — ASCII 艺术字
渲染 ASCII 艺术风格的标题文字,非常适合 TUI 应用的欢迎页面。
const ASCIITitle = () => (
<box border="double">
<ascii_font value="OPEN" font="block" />
</box>
)
3.2 文本修饰符
文本修饰符组件必须放在 <text> 内部使用:
const TextModifiersDemo = () => (
<text>
<b>Bold text</b>
{" | "}
<i>Italic text</i>
{" | "}
<u>Underlined text</u>
<br />
<a href="https://opentui.com">Clickable link (OSC 8)</a>
</text>
)
3.3 输入组件
3.3.1 <input> — 单行输入框
const InputDemo = () => {
const [value, setValue] = createSignal("")
return (
<box border="single" padding={1}>
<text>Type something: {value()}</text>
<input
value={value()}
onInput={(v) => setValue(v)}
placeholder="Enter text..."
/>
</box>
)
}
关键属性:
| 属性 | 类型 | 说明 |
|---|---|---|
value | string | 当前输入值(受控) |
onInput | (value: string) => void | 输入变化回调 |
focused | boolean | 是否自动获得焦点 |
placeholder | string | 占位提示文字 |
mask | string | 遮罩字符,用于密码输入 |
3.3.2 <textarea> — 多行文本输入
const TextareaDemo = () => {
const [content, setContent] = createSignal("")
return (
<box border="single">
<textarea
value={content()}
onInput={(v) => setContent(v)}
width={50}
height={10}
placeholder="Multi-line input..."
/>
</box>
)
}
3.3.3 <select> — 列表选择器
const SelectDemo = () => {
const options = ["Option A", "Option B", "Option C"]
const [selected, setSelected] = createSignal(0)
return (
<box border="single" padding={1}>
<text>Selected: {options[selected()]}</text>
<select
options={options}
selectedIndex={selected()}
onSelect={(index) => setSelected(index)}
/>
</box>
)
}
3.3.4 <tab_select> — 标签页选择器
const TabsDemo = () => {
const tabs = ["Home", "Settings", "Profile"]
const [activeTab, setActiveTab] = createSignal(0)
return (
<tab_select
tabs={tabs}
activeIndex={activeTab()}
onSelect={(index) => setActiveTab(index)}
/>
)
}
3.4 代码与差异显示
3.4.1 <code> — 语法高亮代码块
const CodeDemo = () => (
<code
language="typescript"
code={`const greet = (name: string) => {
return \`Hello, \${name}!\`
}`}
/>
)
3.4.2 <diff> — 差异对比视图
const DiffDemo = () => (
<diff
language="typescript"
oldCode={`const a = 1
const b = 2`}
newCode={`const a = 1
const c = 3`}
mode="unified"
/>
)
第四章:SolidJS 响应式与 OpenTUI 渲染的深度整合
4.1 为什么 SolidJS 是 OpenTUI 的完美搭档
理解 SolidJS 的响应式模型如何与 OpenTUI 的渲染系统协同工作,是写出高效 TUI 代码的关键。
SolidJS 的核心是细粒度响应式:当一个 createSignal() 创建的状态变化时,只有直接依赖该 signal 的 DOM/renderable 节点会更新,而不是整个组件重新执行。这与 OpenTUI 的增量渲染哲学高度一致——两者的结合意味着你修改一个输入框的值,OpenTUI 只会重绘该输入框对应的单元格,而不是全屏刷新。
对比 React:React 的 Virtual DOM diffing 每次状态变化都会重新计算整个组件子树(即便实际 DOM 没变),然后再由 reconciler 对比差异。对于 TUI 场景这是不必要的开销。
4.2 Signals 与受控组件
在 OpenTUI + SolidJS 中,所有输入组件都应该以受控模式使用,即组件的 value 由 signal 驱动,变化通过 onInput 等回调更新 signal:
const ControlledInput = () => {
const [query, setQuery] = createSignal("")
const [results, setResults] = createSignal<string[]>([])
const handleSearch = () => {
const q = query()
setResults(
q.length > 0
? [\`Result for "\${q}" #1\`, \`Result for "\${q}" #2\`]
: []
)
}
return (
<box border="single" padding={1}>
<text>Search:</text>
<input
value={query()}
onInput={(v) => setQuery(v)}
onSubmit={handleSearch}
/>
<box marginTop={1}>
{results().map((r) => (
<text>> {r}</text>
))}
</box>
</box>
)
}
4.3 createEffect 与副作用同步
const WithEffect = () => {
const [count, setCount] = createSignal(0)
createEffect(() => {
console.log(\`Count changed to: \${count()}\`)
})
return (
<box border="single">
<text>Count: {count()}</text>
<button onClick={() => setCount((c) => c + 1)}>
<text>+1</text>
</button>
</box>
)
}
4.4 控制流组件
SolidJS 的 Show、For、Switch/Match 等控制流组件与 OpenTUI 的 reconciler 无缝集成:
const ControlFlowDemo = () => {
const [mode, setMode] = createSignal<"view" | "edit">("view")
const [data, setData] = createSignal({ name: "Alice", age: 30 })
return (
<box border="single" padding={1}>
<Show when={mode() === "view"}>
<box>
<text>Name: {data().name}</text>
<text>Age: {data().age}</text>
</box>
</Show>
<Show when={mode() === "edit"}>
<box>
<input
value={data().name}
onInput={(v) => setData({ ...data(), name: v })}
/>
</box>
</Show>
<button onClick={() => setMode(mode() === "view" ? "edit" : "view")}>
<text>{mode() === "view" ? "Edit" : "Save"}</text>
</button>
<For each={[1, 2, 3]}>
{(n) => <text>Item #{n}</text>}
</For>
</box>
)
}
4.5 派生状态与 createMemo
const MemoDemo = () => {
const [items, setItems] = createSignal<number[]>([1, 2, 3, 4, 5])
const [filter, setFilter] = createSignal("even")
const filteredItems = createMemo(() => {
return items().filter((n) =>
filter() === "even" ? n % 2 === 0 : n % 2 !== 0
)
})
return (
<box border="single">
<text>Filter: {filter()}</text>
<text>Numbers: {filteredItems().join(", ")}</text>
<button onClick={() => setFilter(filter() === "even" ? "odd" : "even")}>
<text>Toggle Filter</text>
</button>
</box>
)
}
第五章:OpenTUI 核心 Hooks 详解
5.1 useRenderer()
获取当前的 CliRenderer 实例,用于直接调用渲染器的高级 API:
import { useRenderer } from "@opentui/solid"
const Demo = () => {
const renderer = useRenderer()
renderer.setBackgroundColor("#1a1a2e")
return <box><text>Hello</text></box>
}
5.2 useTerminalDimensions()
获取当前终端的宽高(字符数),当终端窗口大小变化时自动更新:
import { useTerminalDimensions } from "@opentui/solid"
const FullScreenBox = () => {
const [width, height] = useTerminalDimensions()
return (
<box width={width()} height={height()} border="double">
<text>Terminal: {width()}x{height()}</text>
</box>
)
}
5.3 useKeyboard() — 键盘事件处理
import { useKeyboard } from "@opentui/solid"
const KeyboardDemo = () => {
const [log, setLog] = createSignal<string[]>([])
useKeyboard((event) => {
if (event.key === "q" && event.ctrl) {
process.exit(0)
}
setLog((prev) => [...prev.slice(-9), \`Key: \${event.key}\`])
})
return (
<box border="single" height={12}>
<For each={log()}>
{(entry) => <text>{entry}</text>}
</For>
</box>
)
}
event 对象的结构:
interface KeyboardEvent {
key: string // 按键标识:"a", "Enter", "ArrowUp", "Escape"
ctrl: boolean // Ctrl 键是否按下
shift: boolean // Shift 键是否按下
alt: boolean // Alt 键是否按下
meta: boolean // Meta (Cmd) 键是否按下
code: string // 物理键码
}
5.4 usePaste() — 粘贴事件
const PasteDemo = () => {
const [pasted, setPasted] = createSignal("")
usePaste((text) => {
setPasted(text)
})
return (
<box border="single">
<text>Pasted: {pasted()}</text>
<text>(Ctrl+Shift+V to paste)</text>
</box>
)
}
5.5 onResize() — 窗口大小变化
import { onResize, useTerminalDimensions } from "@opentui/solid"
const ResizeDemo = () => {
const [width, height] = useTerminalDimensions()
onResize((dims) => {
console.log(\`Resized to \${dims.width}x\${dims.height}\`)
})
return <text>Size: {width()}x{height()}</text>
}
5.6 useTimeline() — 动画时间线
OpenTUI 内置了基于帧的动画系统:
import { useTimeline } from "@opentui/solid"
const AnimationDemo = () => {
const [x, setX] = createSignal(0)
useTimeline(({ frame, deltaTime }) => {
setX((prev) => (prev + 1) % 40)
})
return (
<box height={3}>
<text>{" ".repeat(x())}●</text>
</box>
)
}
5.7 Portal — 传送门
Portal 允许你将内容渲染到 renderable 树的另一个位置,适合实现浮层(tooltips、modals、overlays):
import { Portal } from "@opentui/solid"
const Modal = () => {
const [visible, setVisible] = createSignal(false)
return (
<>
<button onClick={() => setVisible(true)}>
<text>Open Modal</text>
</button>
<Show when={visible()}>
<Portal mount={useRenderer().root}>
<box border="double" padding={2} style={{ bg: "white", fg: "black" }}>
<text>This is a modal overlay!</text>
<button onClick={() => setVisible(false)}>
<text>Close</text>
</button>
</box>
</Portal>
</Show>
</>
)
}
mount 参数指定渲染目标,useRenderer().root 即根渲染器。
5.8 Dynamic — 动态组件
Dynamic 用于运行时决定渲染哪个组件:
import { Dynamic } from "@opentui/solid"
const DynamicDemo = () => {
const [componentType, setComponentType] = createSignal("box")
return (
<Dynamic component={componentType()} border="single">
<text>Dynamic component content</text>
</Dynamic>
)
}
第六章:实战项目——构建一个 AI 编程助手终端界面
6.1 项目概述
我们要构建的 AI 编程助手 TUI 界面包含以下功能模块:
- 消息会话区:展示 AI 和用户的对话历史
- 输入区:用户输入命令行,支持多行输入和历史记录
- 状态栏:显示连接状态、Token 消耗、当前模式
- 侧边栏:会话列表管理
6.2 核心类型定义
export interface Message {
id: string
role: "user" | "assistant"
content: string
timestamp: number
}
export interface Session {
id: string
title: string
messages: Message[]
createdAt: number
}
6.3 会话管理 Hook
import { createSignal, createMemo } from "solid-js"
export function useChatSession() {
const [sessions, setSessions] = createSignal<Session[]>([
{ id: "1", title: "New Chat", messages: [], createdAt: Date.now() },
])
const [activeSessionId, setActiveSessionId] = createSignal("1")
const activeSession = createMemo(() =>
sessions().find((s) => s.id === activeSessionId()) ?? sessions()[0]
)
const addMessage = (role: "user" | "assistant", content: string) => {
const newMessage: Message = {
id: crypto.randomUUID(),
role,
content,
timestamp: Date.now(),
}
setSessions((prev) =>
prev.map((s) =>
s.id === activeSessionId()
? { ...s, messages: [...s.messages, newMessage] }
: s
)
)
}
const createSession = () => {
const newSession: Session = {
id: crypto.randomUUID(),
title: \`Chat \${sessions().length + 1}\`,
messages: [],
createdAt: Date.now(),
}
setSessions((prev) => [...prev, newSession])
setActiveSessionId(newSession.id)
}
return {
sessions,
activeSession,
activeSessionId,
setActiveSessionId,
addMessage,
createSession,
}
}
6.4 消息展示组件
import { TextAttributes } from "@opentui/core"
interface ChatMessageProps {
role: "user" | "assistant"
content: string
}
export function ChatMessage(props: ChatMessageProps) {
const isUser = () => props.role === "user"
return (
<box marginTop={1} paddingLeft={isUser() ? 0 : 2} flexDirection="row">
<text
style={{
fg: isUser() ? "cyan" : "green",
attributes: TextAttributes.BOLD,
}}
>
{isUser() ? "❯ " : "◀ "}
</text>
<box flexGrow={1}>
<text style={{ fg: isUser() ? "white" : "#e0e0e0" }}>
{props.content}
</text>
</box>
</box>
)
}
6.5 聊天输入组件
import { createSignal, Show } from "solid-js"
interface ChatInputProps {
onSend: (message: string) => void
disabled?: boolean
}
export function ChatInput(props: ChatInputProps) {
const [value, setValue] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const handleSubmit = () => {
const msg = value().trim()
if (!msg || props.disabled) return
props.onSend(msg)
setHistory((prev) => [msg, ...prev.slice(0, 49)])
setHistoryIndex(-1)
setValue("")
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
const nextIndex = Math.min(historyIndex() + 1, history().length - 1)
setHistoryIndex(nextIndex)
setValue(history()[nextIndex] ?? "")
} else if (event.key === "ArrowDown") {
const nextIndex = Math.max(historyIndex() - 1, -1)
setHistoryIndex(nextIndex)
setValue(nextIndex === -1 ? "" : history()[nextIndex] ?? "")
}
}
return (
<box border="single" padding={1} flexDirection="column">
<Show when={!props.disabled}>
<textarea
value={value()}
onInput={(v) => setValue(v)}
onKeyDown={handleKeyDown}
width={60}
height={3}
placeholder="Type your message..."
/>
<box marginTop={1} flexDirection="row" justifyContent="flex-end">
<text style={{ fg: "#888" }}>{value().length} chars</text>
<box width={2} />
<button onClick={handleSubmit} style={{ fg: value().trim() ? "green" : "#555" }}>
<text>[ Send ]</text>
</button>
</box>
</Show>
<Show when={props.disabled}>
<text style={{ fg: "#888" }}>Waiting for response...</text>
</Show>
</box>
)
}
6.6 主应用组装
import { createSignal, Show, For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useChatSession } from "./hooks/useChatSession"
import { ChatMessage } from "./components/ChatMessage"
import { ChatInput } from "./components/ChatInput"
export function App() {
const { sessions, activeSession, activeSessionId, setActiveSessionId, addMessage, createSession } = useChatSession()
const [connected, setConnected] = createSignal(true)
const [tokenCount, setTokenCount] = createSignal(0)
const [streaming, setStreaming] = createSignal(false)
const [model] = createSignal("claude-sonnet-4")
useKeyboard((event) => {
if (event.ctrl && event.key === "n") createSession()
})
const handleSend = async (message: string) => {
addMessage("user", message)
setStreaming(true)
const response = \`Simulated AI response to: "\${message}"\`
await new Promise((r) => setTimeout(r, 500))
addMessage("assistant", response)
setTokenCount((prev) => prev + message.length + response.length)
setStreaming(false)
}
return (
<box flexDirection="column" width={90} height={30}>
<box flexGrow={1} flexDirection="row">
{/* 侧边栏 */}
<box width={20} border="single" flexDirection="column">
<box paddingX={1} style={{ bg: "#2d2d44", fg: "white" }}>
<text>Sessions</text>
</box>
<box paddingX={1} paddingY={1} onClick={createSession}>
<text style={{ fg: "yellow" }}>+ New Chat</text>
</box>
<scrollbox flexGrow={1}>
<For each={sessions()}>
{(session) => (
<box
paddingX={1} paddingY={1}
style={{
fg: session.id === activeSessionId() ? "white" : "#888",
bg: session.id === activeSessionId() ? "#3d3d5c" : "transparent",
}}
onClick={() => setActiveSessionId(session.id)}
>
<text>{session.title.slice(0, 18)}</text>
</box>
)}
</For>
</scrollbox>
</box>
{/* 聊天区 */}
<box flexGrow={1} border="single" marginLeft={1} flexDirection="column">
<scrollbox flexGrow={1} padding={1}>
<Show when={activeSession().messages.length > 0} fallback={
<text style={{ fg: "#888" }}>No messages yet. Start a conversation!</text>
}>
<For each={activeSession().messages}>
{(msg) => <ChatMessage role={msg.role} content={msg.content} />}
</For>
</Show>
</scrollbox>
<ChatInput onSend={handleSend} disabled={streaming()} />
</box>
</box>
{/* 状态栏 */}
<box height={1} style={{ bg: "#1a1a2e", fg: "#888" }}>
<text>
<span style={{ fg: connected() ? "green" : "red" }}>{connected() ? "●" : "○"}</span>
{" "}{model()}{" | "}Tokens: {tokenCount().toLocaleString()}{" | "}
<span style={{ fg: streaming() ? "yellow" : "#555" }}>
{streaming() ? "◐ Streaming" : "● Ready"}
</span>
</text>
</box>
</box>
)
}
6.7 入口文件
import { render } from "@opentui/solid"
import { ConsolePosition } from "@opentui/core"
import { App } from "./App"
render(App, {
targetFps: 60,
exitOnCtrlC: true,
consoleOptions: {
position: ConsolePosition.BOTTOM,
maxStoredLogs: 1000,
sizePercent: 30,
},
})
第七章:高级特性与性能优化
7.1 extend() 自定义组件
extend() 允许你将自定义的 renderable 注册为 JSX 内在元素:
import { extend } from "@opentui/solid"
import { BoxRenderable } from "@opentui/core"
class ProgressBarRenderable extends BoxRenderable {
private _progress: number = 0
constructor(options: any) {
super(options)
this.updateProgress(options.progress ?? 0)
}
updateProgress(progress: number) {
this._progress = Math.max(0, Math.min(1, progress))
}
}
extend({ progressbar: ProgressBarRenderable })
const ProgressDemo = () => {
const [progress, setProgress] = createSignal(0)
return (
<box>
<text>Uploading... {Math.round(progress() * 100)}%</text>
<progressbar width={40} height={1} progress={progress()} />
</box>
)
}
7.2 滚动性能优化
在处理长列表时,使用虚拟滚动:
const VirtualizedList = () => {
const [scrollTop, setScrollTop] = createSignal(0)
const [itemHeight] = createSignal(3)
const allItems = createSignal(Array.from({ length: 1000 }, (_, i) => \`Item \${i}\`))
const visibleItems = createMemo(() => {
const start = Math.floor(scrollTop() / itemHeight())
return allItems().slice(start, start + 15)
})
return (
<scrollbox height={20} onScroll={(info) => setScrollTop(info.scrollTop)}>
<For each={visibleItems()}>
{(item) => <text>{item}</text>}
</For>
</scrollbox>
)
}
7.3 测试策略
OpenTUI 提供了 testRender 用于快照测试和交互测试:
import { testRender } from "@opentui/solid"
test("App renders correctly", async () => {
const { root } = await testRender(() => <App />, {
width: 80,
height: 24,
})
expect(root.children).toMatchSnapshot()
})
第八章:构建与部署
Bun.build 打包
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./index.tsx"],
target: "bun",
outdir: "./build",
plugins: [solidPlugin],
compile: {
target: "bun-darwin-arm64",
outfile: "my-tui-macos",
},
})
| 平台 | target |
|---|---|
| macOS ARM64 | bun-darwin-arm64 |
| macOS x64 | bun-darwin-x64 |
| Linux ARM64 | bun-linux-arm64 |
| Linux x64 | bun-linux-x64 |
第九章:常见问题与排查指南
Q1: 渲染空白/无输出
- 确认
bunfig.toml配置了正确的preload - 确认终端支持相关渲染特性
- 用
render(() => <text>TEST</text>)简化排查
Q2: 键盘输入无响应
- 确保有组件持有焦点(focus)
- 使用
useKeyboard处理全局按键
Q3: 状态更新后 UI 没变化
- 确认使用的是响应式 API(
createSignal) - JSX 中使用
count()调用 signal 才算依赖追踪
Q4: Zig 编译报错
- 确认 Zig 版本 ≥ 0.13
- 使用
zig version验证
第十章:社区与生态
awesome-opentui
awesome-opentui 收录了大量优秀项目:
| 项目 | 说明 |
|---|---|
| OpenCode | 最强的 AI 编程助手之一 |
| waha-tui | 终端 WhatsApp 客户端 |
| openmux | 终端多路复用器 |
| opentui-gameboy | 终端 Gameboy 模拟器 |
| herm | Hermes TUI |
参与贡献
git clone https://github.com/anomalyco/opentui.git
cd opentui
bun install
cd packages/solid && bun run build
bun test
结语
OpenTUI 代表了终端 UI 开发的一次范式转变。它将 Zig 的极致性能、SolidJS 的声明式响应式模型、以及 TypeScript 的类型安全完美融合,为开发者提供了一个前所未有的 TUI 开发体验。
通过本文,你应该已经掌握了:
- OpenTUI 的核心架构和设计哲学
- @opentui/solid 的完整组件生态
- SolidJS 响应式系统与 OpenTUI 渲染的深度整合方式
- 从基础组件到复杂 TUI 应用的完整开发能力
- 性能优化、测试和打包部署的最佳实践
OpenTUI 仍在快速迭代中,其插件系统、Three.js 渲染器(@opentui/three)等高级特性为更复杂的 TUI 应用打开了大门。强烈建议你 clone 官方 examples 包,动手运行每一个示例,这是掌握 OpenTUI 最有效的路径。
参考链接:
- OpenTUI GitHub: https://github.com/anomalyco/opentui
- 官方文档: https://opentui.com/docs/getting-started
- awesome-opentui: https://github.com/msmps/awesome-opentui
- create-tui 脚手架: https://github.com/msmps/create-tui
- @opentui/solid 文档: https://github.com/anomalyco/opentui/blob/main/packages/solid/README.md