Cline 终端 TUI 技术栈:OpenTUI Solid API 全解(render、Hooks 与组件实战参考)
在 Cline 仓库中,CLI 的交互式界面(TUI)构建在 OpenTUI 之上——仓库锁定了 @opentui/core 与 @opentui/react 0.4.3(见 apps/cli/package.json 与 bun.lock),而仓库内置的 OpenTUI 技能文档则为 SolidJS 适配层 @opentui/solid 提供了一份完整 API 参考。本文以该 Solid API 参考文档为主体,逐节展开渲染入口、Hooks、内置组件、控制流与特殊组件的用法,并结合仓库中的实际 TUI 实现路径与配套配置文档,帮助读者掌握用 Solid 细粒度响应式模型构建高性能终端界面的完整方法。
项目背景:Cline 中的 OpenTUI 使用形态
从仓库结构看,CLI 应用的 TUI 代码集中在 apps/cli/src/tui/ 目录,包含 49 个组件(components/)、26 个 hooks(hooks/)、视图层(views/)等,其 package.json 声明了以下 OpenTUI 相关依赖:
@opentui/core:0.4.3—— 命令式渲染核心(Renderer、Renderable 树、键盘/鼠标事件、终端 I/O)@opentui/react:0.4.3—— React 19 适配层(当前 CLI 实际采用的 JSX 运行时)@opentui-ui/dialog:^0.1.2—— 对话框组件库(仓库还为其打了补丁 patches/@opentui-ui%2Fdialog@0.1.2.patch)opentui-spinner:^0.0.7—— 终端加载动画
@opentui/solid 虽未直接出现在 CLI 依赖中,但它是与 React 适配层对等的官方 Solid 入口:@opentui-ui/dialog 与 opentui-spinner 的 peerDependencies 都将其列为可选适配层(见 bun.lock)。本文讲解的 API 即为该 Solid 适配层的完整参考,与 React 版共享同一套 @opentui/core 底层,组件与布局能力一致,差异主要在响应式机制与 JSX 命名约定上。
配套的仓库内文档可作延伸阅读(路径以仓库根目录为起点):
- 总览与选型:.agents/skills/opentui/references/solid/REFERENCE.md
- 项目配置(tsconfig / bunfig / 构建):.agents/skills/opentui/references/solid/configuration.md
- 状态与组合模式:.agents/skills/opentui/references/solid/patterns.md
- 常见坑与调试:.agents/skills/opentui/references/solid/gotchas.md
- 键盘与选择 API 细节:.agents/skills/opentui/references/keyboard/REFERENCE.md
- 测试渲染器:.agents/skills/opentui/references/testing/REFERENCE.md
Rendering:渲染入口 API
render(node, rendererOrConfig?)
将 Solid 组件树渲染到 CLI 渲染器,是应用的唯一入口函数:
import { render } from "@opentui/solid"
// 简单用法 - 自动创建 renderer
render(() => <App />)
// 带配置
render(() => <App />, {
exitOnCtrlC: false,
targetFPS: 60,
})
// 复用已有 renderer
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
render(() => <App />, renderer)
注意第二个参数有两种形态:传配置对象时 render 内部会先创建 renderer;传 renderer 实例时(例如需要 await createCliRenderer() 拿到句柄后再挂组件),则直接挂载到该实例。当需要自行创建 renderer 时函数会走异步路径,Bun 支持顶层 await,因此入口脚本可以直接写 render(() => <App />) 而不必手动处理 Promise。
完整配置项(来自配套配置文档)还包括:
import { render } from "@opentui/solid"
import { ConsolePosition } from "@opentui/core"
render(() => <App />, {
// Rendering
targetFPS: 60,
// Behavior
exitOnCtrlC: true,
autoFocus: true, // 点击时自动聚焦元素(默认 true)
useMouse: true, // 启用鼠标支持(默认 true)
// Debug console
consoleOptions: {
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
// Cleanup
onDestroy: () => {
// 清理代码
},
})
testRender(node, options?)
创建用于快照与测试的测试渲染器:
import { testRender } from "@opentui/solid"
const testSetup = await testRender(() => <App />, {
width: 40,
height: 10,
})
// 访问测试工具
testSetup.snapshot() // 获取当前渲染
testSetup.renderer // 访问 renderer
testRender 是框架级的便捷封装:创建一个无头(headless)测试 renderer 并自动处理挂载/卸载。配套的 testing/REFERENCE.md 说明,底层对应 @opentui/core/testing 的 createTestRenderer,返回的 setup 对象提供 renderOnce()、captureCharFrame()、snapshot() 等方法,可以配合 Bun 测试运行器做字符帧级断言与 toMatchSnapshot() 快照测试:
// 测试工具封装(src/test-utils.tsx)
import { testRender } from "@opentui/solid"
export async function renderForTest(
Component: () => JSX.Element,
options = { width: 80, height: 24 }
) {
return await testRender(Component, options)
}
// 用法(bun:test)
import { test, expect } from "bun:test"
test("Counter renders initial value", async () => {
const { snapshot } = await renderForTest(() => <Counter initialValue={5} />)
expect(snapshot()).toContain("Count: 5")
})
extend(components)
把自定义 renderable 注册为 JSX 固有元素:
import { extend } from "@opentui/solid"
import { CustomRenderable } from "./custom"
extend({
custom: CustomRenderable,
})
// 之后即可在 JSX 中使用
<custom prop="value" />
getComponentCatalogue()
返回当前组件目录(component catalogue),可用于自省已注册的 JSX 元素:
import { getComponentCatalogue } from "@opentui/solid"
const catalogue = getComponentCatalogue()
console.log(Object.keys(catalogue))
Hooks:事件与运行时访问
useRenderer()
访问 OpenTUI renderer 实例,包括终端尺寸、内置调试 console 与主题模式:
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
function App() {
const renderer = useRenderer()
onMount(() => {
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
renderer.console.show()
// 访问主题模式(基于终端设置的 dark/light)
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
})
return <text>Hello</text>
}
// 监听主题模式变化
function ThemedApp() {
const renderer = useRenderer()
const [theme, setTheme] = createSignal(renderer.themeMode ?? "dark")
onMount(() => {
renderer.on("theme_mode", (mode: "dark" | "light") => setTheme(mode))
})
return (
<box backgroundColor={theme() === "dark" ? "#1a1a2e" : "#ffffff"}>
<text fg={theme() === "dark" ? "#fff" : "#000"}>
Current theme: {theme()}
</text>
</box>
)
}
renderer 上挂载了 theme_mode 事件,终端切换深色/浅色模式时会触发回调;Cline CLI 自己的 TUI 中也存在对应的主题同步逻辑(见 apps/cli/src/tui/components/dialog-theme-sync.tsx),属于同一套主题机制在 React 适配层的对应物。
useKeyboard(handler, options?)
处理键盘事件。退出应用时应调用 renderer.destroy() 而非 process.exit()——后者会跳过终端状态恢复(光标隐藏、raw 模式、备用屏幕),把终端留在损坏状态:
import { useKeyboard, useRenderer } from "@opentui/solid"
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>
}
// 带释放事件(release events)
function GameControls() {
const [pressed, setPressed] = createSignal(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>
}
options.release 打开后回调会同时收到按下与释放事件(通过 event.eventType 区分),适合实现"按住移动"式的组合键交互。
usePaste(handler)
处理粘贴事件,回调接收携带原始字节的 PasteEvent,需要用 decodePasteBytes 解码为文本:
import { usePaste } from "@opentui/solid"
import { decodePasteBytes } from "@opentui/core"
function PasteHandler() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
return <text>Paste something</text>
}
onResize(callback)
处理终端窗口尺寸变化:
import { onResize } from "@opentui/solid"
function App() {
onResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize the terminal</text>
}
useTerminalDimensions()
获取响应式终端尺寸,是编写响应式布局的首选——与 onResize 的回调式不同,它返回一个 signal,可以在 JSX 中直接驱动布局切换:
import { useTerminalDimensions } from "@opentui/solid"
function ResponsiveLayout() {
const dimensions = useTerminalDimensions()
return (
<box flexDirection={dimensions().width > 80 ? "row" : "column"}>
<text>Width: {dimensions().width}</text>
<text>Height: {dimensions().height}</text>
</box>
)
}
onFocus(callback) / onBlur(callback)
处理终端窗口(操作系统层面)的聚焦/失焦事件,这两个 hook 为 Solid 独有:
import { onFocus, onBlur } from "@opentui/solid"
function App() {
onFocus(() => {
console.log("Terminal window gained focus")
})
onBlur(() => {
console.log("Terminal window lost focus")
})
return <text>Focus/blur tracking</text>
}
它们在终端模拟器窗口获得或失去操作系统焦点时触发;renderer 内部会对事件去重,不会重复发出相同的焦点状态。
useSelectionHandler(handler)
处理文本选择事件,在用户完成鼠标选择(mouse-up)时触发,同样是 Solid 独有(React 版无对应 hook):
import { useSelectionHandler } from "@opentui/solid"
import type { Selection } from "@opentui/core"
function SelectableText() {
const [selected, setSelected] = createSignal("")
const renderer = useRenderer()
useSelectionHandler((selection: Selection) => {
const text = selection.getSelectedText()
if (text) {
setSelected(text)
renderer.copyToClipboardOSC52(text)
}
})
return (
<box flexDirection="column">
<text selectable>Select this text with your mouse</text>
<text fg="#888">Selected: {selected()}</text>
</box>
)
}
Selection 对象会聚合组件树中所有 selectable renderable 的被选中文本;选择 API 与遍历模型的完整细节见 keyboard/REFERENCE.md 的 selection 章节。
useTimeline(options?)
创建基于 timeline 系统的动画:
import { useTimeline } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
function AnimatedBox() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
onMount(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
})
return <box style={{ width: width(), height: 3, backgroundColor: "#6a5acd" }} />
}
timeline.add(from, config) 的 config 支持 duration、ease(如 easeOutQuad)与 onUpdate 回调;配合 signal 即可驱动进度条、展开/收起等动画,动画系统细节可参考 animation/REFERENCE.md。
内置组件
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>
注意:不要把
bold、italic、underline作为<text>的 props 使用;应使用<strong>、<em>、<u>等嵌套修饰标签。
Solid 的 reconciler 会自动解码 JSX 文本内容中的 HTML 实体:<、>、& 会渲染为字面字符(例如 <text>Use <box> for containers</text> 显示为 Use <box> for containers),适用于 text 节点、content 与 text 两个 prop。
Box 组件
<box
// 边框
border // 启用边框
borderStyle="single" // single | double | rounded | bold
borderColor="#FFFFFF"
title="Title"
titleAlignment="center" // left | center | right
// 颜色
backgroundColor="#1a1a2e"
// 布局
flexDirection="row"
justifyContent="center"
alignItems="center"
gap={2}
// 间距
padding={2}
paddingX={2} // 水平(左右)
paddingY={1} // 垂直(上下)
margin={1}
marginX={2} // 水平(左右)
marginY={1} // 垂直(上下)
// 尺寸
width={40}
height={10}
flexGrow={1}
// 焦点
focusable // 允许 box 接收焦点
focused={isFocused()} // 受控焦点状态
// 事件
onMouseDown={(e) => {}}
onMouseUp={(e) => {}}
>
{children}
</box>
Box 提供 Flexbox 式布局,paddingX/paddingY、marginX/marginY 是单轴糖衣写法,flexGrow 用于弹性分配剩余空间。
Scrollbox 组件
<scrollbox
focused // 启用键盘滚动
style={{
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
<For each={items()}>
{(item) => <text>{item}</text>}
</For>
</scrollbox>
Input / Textarea 组件
<input
value={value()}
onInput={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
width={30}
/>
<textarea
value={text()}
onInput={(newValue) => setText(newValue)}
placeholder="Enter multiple lines..."
focused
width={40}
height={10}
/>
两者都必须显式传 focused 才会接收键盘输入——这是终端 TUI 与 Web 表单的最大差异之一,也是最常见的"输入框没反应"的原因。
Select 组件
<select
options={[
{ name: "Option 1", description: "First", value: "1" },
{ name: "Option 2", description: "Second", value: "2" },
]}
onChange={(index, option) => setSelected(option)}
selectedIndex={0}
focused
/>
事件语义需要注意:onChange 在方向键导航时触发(适合做预览),onSelect 在按下 Enter 确认选择时触发(适合做提交),两者混淆是 Select 组件的典型坑。
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"
/>
Line Number 组件(注意:下划线命名)
<line_number
code={sourceCode}
language="typescript"
startLine={1}
highlightedLines={[5]}
/>
Diff 组件
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified" // unified | split
syncScroll // 分栏视图下同步两个窗格滚动
/>
Diff 组件底层基于 diff 库实现(@opentui/core 的依赖之一,见 bun.lock),mode="split" 配合 syncScroll 可构建类似 IDE 的并排 diff 查看器——对 Cline 这类编码 Agent 的 TUI 输出(补丁预览、编辑对比)而言是非常合适的展示载体。
控制流组件
Solid 的标准控制流组件在 OpenTUI 中直接可用,且语义与浏览器版 Solid 完全一致。
For
import { For } from "solid-js"
<For each={items()}>
{(item, index) => (
<box key={index()}>
<text>{item.name}</text>
</box>
)}
</For>
Show
import { Show } from "solid-js"
<Show when={isVisible()} fallback={<text>Hidden</text>}>
<text>Visible content</text>
</Show>
建议始终提供 fallback,避免状态切换时的布局空洞。
Switch/Match
import { Switch, Match } from "solid-js"
<Switch>
<Match when={status() === "loading"}>
<text>Loading...</text>
</Match>
<Match when={status() === "error"}>
<text fg="red">Error!</text>
</Match>
<Match when={status() === "success"}>
<text fg="green">Success!</text>
</Match>
</Switch>
Index
import { Index } from "solid-js"
<Index each={items()}>
{(item, index) => (
<text>{index}: {item().name}</text>
)}
</Index>
选型原则:对象数组用 For(元素响应式),原始类型数组用 Index(item() 是响应式的 signal 访问)。
特殊组件
Portal
将子树渲染到另一个挂载节点,常用于绝对定位的浮层:
import { Portal } from "@opentui/solid"
<Portal mount={targetNode}>
<box>Portal content</box>
</Portal>
典型用法是把 overlay 挂到 renderer.root,使其脱离父容器的布局约束:
function Overlay() {
return (
<Portal mount={renderer.root}>
<box position="absolute" left={10} top={5} border>
<text>Overlay content</text>
</box>
</Portal>
)
}
Dynamic
按响应式值动态切换组件类型:
import { Dynamic } from "@opentui/solid"
<Dynamic
component={isMultiline() ? "textarea" : "input"}
placeholder="Enter text..."
focused
/>
适合"按 Enter 后单行输入升级为多行编辑"这类输入形态动态切换的场景。
配置要点:让 JSX 真正跑起来
API 能否生效依赖三个必配项,缺失任何一项都会表现为"JSX 未转换/编译成 React 调用":
-
tsconfig.json:
"jsx": "preserve"(交给 Solid 编译器处理)+"jsxImportSource": "@opentui/solid"(JSX 运行时来源)+"module"/"moduleResolution": "NodeNext"; -
bunfig.toml(Bun 运行时的 Solid 编译器预加载,必须项):
preload = ["@opentui/solid/preload"] -
构建插件:
Bun.build时挂载@opentui/solid/bun-plugin,否则产物中会残留未转换的 JSX;bun build --compile可进一步产出bun-darwin-arm64、bun-linux-x64等平台的独立可执行文件。
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
plugins: [solidPlugin],
})
快速起步可用脚手架:bunx create-tui@latest -t solid my-app(目标目录不能已存在,--no-git/--no-install 可跳过 git 初始化与依赖安装);手动方式则是 bun install @opentui/solid @opentui/core solid-js 后按上述三项配置落地。完整的项目结构、package.json 与环境变量(OTUI_SHOW_STATS、SHOW_CONSOLE 等调试开关)说明见 configuration.md。
与 React 适配层的关键差异
从源码结构与文档对比看,Solid 与 React 两个适配层共享 @opentui/core 渲染核心,差异集中在三点:
| 维度 | React(@opentui/react) |
Solid(@opentui/solid) |
|---|---|---|
| 多词组件命名 | <tab-select>、<ascii-font>、<line-number> |
<tab_select>、<ascii_font>、<line_number>(下划线,Solid 约定,写错会报"无对应闭合标签") |
| 响应式机制 | React 19 渲染器(react-reconciler) | Solid signals:细粒度更新,仅变化部分重渲染 |
| 焦点/选择 hook | 无对应物 | 独有 onFocus/onBlur、useSelectionHandler |
| props 传递 | 常规 React 模式 | 禁止解构 props(const { value } = props 会破坏响应式),需 props.value 或 splitProps |
| 测试入口 | @opentui/react/test-utils 的 testRender |
@opentui/solid 直接导出 testRender |
选型建议(来自 solid/REFERENCE.md):追求最优重渲染性能、偏好 signal 式响应式或已有 SolidJS 基础时选 Solid;团队熟悉 React 时选 @opentui/react;需要最大控制面、最小包体或构建框架/库本身时直接用 @opentui/core 命令式 API。
高频陷阱速查
结合 gotchas.md 与上文 API 语义,以下是最值得提前记住的几条:
- 永远不要
process.exit():会跳过备用屏幕退出、光标恢复等清理逻辑;正确做法是renderer.destroy(); - signal 必须调用:
{count}显示[Function]且永不更新,正确写法是{count()};store 则相反——{store().count}是错的,{store.count}才对; - 不要解构 props / 直接改 store:
state.items.push(x)不触发更新,必须setState("items", items => [...items, x]); - 输入组件必须
focused:<input>、<select>、<scrollbox>键盘交互都依赖显式焦点; - 清理用
onCleanup:setInterval、订阅等副作用必须在onCleanup中释放,否则终端应用长期运行时会出现重复计时器与泄漏; - console 输出被渲染器接管:调试时用
renderer.console.show()打开内置调试控制台,或在配置里设置consoleOptions。
小结
@opentui/solid 的 API 面可以概括为"一个渲染入口 + 一组 hooks + 一套终端组件":render/testRender 负责应用与测试的挂载,extend/getComponentCatalogue 提供扩展点;useRenderer、useKeyboard、usePaste、onResize、useTerminalDimensions、onFocus/onBlur、useSelectionHandler、useTimeline 覆盖终端事件与动画的全部交互面;<text>、<box>、<scrollbox>、<input>、<textarea>、<select>、<tab_select>、<ascii_font>、<code>、<line_number>、<diff> 加上 Solid 的 For/Show/Switch/Index 控制流和 Portal/Dynamic 组合原语,足以支撑从简单状态展示到代码 diff 查看器的完整 TUI 应用。仓库内 solid/REFERENCE.md、patterns.md(signals/stores/资源/异步数据)与 gotchas.md 三篇配套文档与本文构成完整的 Solid TUI 开发参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00