Cline CLI 的终端 UI 底座:OpenTUI React(@opentui/react)API 实战精讲
本篇以 OpenTUI 官方 React 参考文档 .agents/skills/opentui/references/react/api.md 为主体,完整讲解 @opentui/react 的渲染入口 createRoot、核心 Hooks(useRenderer / useKeyboard / useOnResize / useTerminalDimensions / useTimeline)与全部内置组件,并结合 Cline CLI 的真实 TUI 源码(apps/cli/src/tui/index.tsx、apps/cli/src/tui/root.tsx)印证生产级用法。读完后你可以直接用 React 的 JSX + Hooks 模式编写终端 UI,理解渲染器生命周期管理(为何必须 renderer.destroy() 而不能用 process.exit()),并在需要时参考 Cline CLI 的工程化落地方式。
OpenTUI React 是一个面向终端的 React reconciler:它把 JSX 内在元素(<text>、<box>、<input> 等)映射为 OpenTUI 渲染对象,同时保持对 React 生态的完整兼容(useState、useEffect、Context 均可用)。Cline CLI 正是基于这一技术栈构建其交互式终端界面的——从 apps/cli/package.json 可以确认其依赖 @opentui/core 与 @opentui/react(均为 0.4.3)、react 19.2.4 以及 react-reconciler 0.33.0。
一、渲染入口:createRoot(renderer)
createRoot 是连接 OpenTUI 渲染器与 React 组件树的唯一入口。标准流程是:先从 @opentui/core 创建一个 CLI 渲染器(注意是异步的,必须 await),再交给 createRoot 渲染根组件:
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 />)
关键参数说明:
exitOnCtrlC: boolean—— 控制渲染器是否接管 Ctrl+C 直接退出进程。设为false后,应用需要自己监听 Escape/Ctrl+C 并调用renderer.destroy()来优雅退出(销毁渲染器、恢复终端状态)。autoFocus—— 点击元素时是否自动聚焦(Cline CLI 关闭了它,改由代码统一控制焦点,见下文)。enableMouseMovement—— 是否启用鼠标移动事件上报(点击之外还能感知光标移动)。
Cline CLI 的真实入口 renderOpenTui 完整展示了这条调用链,且比文档示例多出两处生产细节:
const renderer = await createCliRenderer({
exitOnCtrlC: false,
autoFocus: false,
enableMouseMovement: true,
})
// ...
root = createRoot(renderer)
root.render(<Root {...props} />)
此外它还会在首帧绘制前用 renderer.getPalette({ timeout: 150 }) 探测终端默认前景/背景色并调用 renderer.setBackgroundColor(...),避免启动瞬间闪出终端自身背景色;若 createRoot 抛出异常,则回滚 stdio 捕获并 renderer.destroy() 后再向上抛出——这是处理"渲染器已创建但挂载失败"这一中间态的范例。
渲染器生命周期:destroy 与 destroy 事件
Cline CLI 在 index.tsx 中还示范了完整的销毁时序:
renderer.on("destroy", () => {
unmountRoot()
restoreStdio()
resolveExit?.()
})
const destroy = () => {
// 1. 幂等保护
// 2. unmountRoot() 卸载 React 根
// 3. queueMicrotask:等 OpenTUI 解析完当前 stdin 批次再拆渲染器
// 期间先 renderer.setTerminalTitle("") 重置标题
// 4. renderer.destroy()
}
要点是:先 root.unmount() 卸载 React 树,再通过微任务延迟调用 renderer.destroy(),以保证销毁前 stdin 的最后一批按键(例如触发退出那次按键本身)被完整消费。这解释了为什么 API 文档反复强调绝不能在业务代码里直接 process.exit()——那会跳过终端清理,把终端留在"光标隐藏、raw mode、备用屏幕"的损坏状态;renderer.destroy() 会退出备用屏幕、恢复光标后再结束进程。
另一个与渲染器启动相关的工程细节在 apps/cli/src/tui/opentui-env.ts:OpenTUI 启动时会探测终端的 Kitty 图形支持能力,某些终端会把探测响应泄漏成可见文本(如 Gi=31337, s=1, ...),Cline CLI 因此通过设置 OPENTUI_GRAPHICS=0 仅关闭图形探测,而保留键盘、鼠标、颜色与常规渲染能力。
二、核心 Hooks
以下五个 Hook 均从 @opentui/react 导出,覆盖"拿到渲染器 → 处理输入 → 响应终端尺寸 → 做动画"的完整需求面。
2.1 useRenderer():访问渲染器实例
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
// 读取终端尺寸
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
// 显示调试控制台(OpenTUI 会捕获 console 输出,默认不可见)
renderer.console.show()
// 主题模式(依据终端设置探测 dark/light)
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
}, [renderer])
return <text>Hello</text>
}
useRenderer 返回的实例既是属性容器也是事件总线。文档示例演示了监听主题切换:
function ThemedApp() {
const renderer = useRenderer()
const [theme, setTheme] = useState(renderer.themeMode ?? "dark")
useEffect(() => {
const handler = (mode: "dark" | "light") => setTheme(mode)
renderer.on("theme_mode", handler)
return () => renderer.off("theme_mode", handler)
}, [renderer])
return (
<box backgroundColor={theme === "dark" ? "#1a1a2e" : "#ffffff"}>
<text fg={theme === "dark" ? "#fff" : "#000"}>
Current theme: {theme}
</text>
</box>
)
}
Cline CLI 的 root.tsx 是"事件总线用法"的最佳生产示例:它用 renderer.on("selection", ...) 监听文本选中事件,配合 renderer.copyToClipboardOSC52(text) 实现"选中即复制"(OSC 52 是终端间剪贴板协议),并在卸载时严格成对调用 dispose() 与 renderer.off(...):
useEffect(() => {
const { handleSelection, dispose } = createSelectionCopyHandler({
copyToClipboardOSC52: (text) => renderer.copyToClipboardOSC52(text),
showToast,
})
renderer.on("selection", handleSelection)
return () => {
dispose()
renderer.off("selection", handleSelection)
}
}, [renderer, showToast])
这提醒读者:任何 renderer.on(...) 订阅都必须在 effect 清理函数中 off 掉,否则渲染器跨会话复用或测试环境重建时会累积僵尸监听器。
2.2 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>
}
选项:
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 |
按键是否处于长按重复状态 |
需要按键释放事件时(典型场景是游戏控制、多键组合),传入 { release: true } 并用集合记录当前按住的键:
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 } // 包含 release 事件
)
return <text>Pressed: {Array.from(pressed).join(", ")}</text>
}
一个实战注意:多个组件同时挂 useKeyboard 时所有 handler 都会触发。Cline CLI 的做法是把全局按键逻辑收敛到单一 Hook useRootKeyboard 中统一分发(退出、模式切换、历史导航、命令面板快捷键等),避免父子 handler 互相踩踏——与参考文档 patterns 中"单一键盘处理器"的建议一致。
2.3 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>
}
回调参数为新的 (width, height),适合做日志、缓存失效或手动布局修正。
2.4 useTerminalDimensions():响应式终端尺寸
与 useOnResize(事件驱动)不同,这个 Hook 把尺寸提升为触发重新渲染的响应式状态:
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>
)
}
Cline CLI 在 root.tsx 中正是用它实现响应式布局:
const { height: termHeight, width: termWidth } = useTerminalDimensions()
随后把 termHeight 传给各对话框(maxHeight: termHeight - 2),并用 termWidth 计算命令面板宽度(Math.min(64, Math.max(48, Math.floor(termWidth * 0.58)), ...))。由于终端任何一次 resize 都会触发整个 App 重渲染,Cline 借此把"对话框不超出屏幕"这件事变成了纯声明式的。
2.5 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—— 默认时长(ms)loop?: boolean—— 是否循环播放autoplay?: boolean—— 自动开始(默认true)onComplete?: () => void—— 完成回调onPause?: () => void—— 暂停回调
Timeline 方法:
add(target, properties, startTime?)—— 追加动画(可给第三参指定起始时间实现编排)play()/pause()/restart()—— 播放 / 暂停 / 从头重放
动画的典型写法是"用 React state 承接插值结果":onUpdate 中读取 anim.targets[0].<属性名>,setState 驱动下一帧渲染。参考文档 patterns 中的进度条示例即采用此模式(linear 缓动、3 秒从 0 到 100,用两个嵌套 <box> 画进度条)。缓动函数与时间轴的完整说明见 animation/REFERENCE.md。
三、内置组件
以下组件均为 JSX 内在元素,不是 HTML 标签(<div>、<button> 在终端里不存在),其属性与 DOM 有相似之处但语义面向字符网格。
3.1 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这类布尔属性;加粗、斜体、下划线必须用<strong>、<em>、<u>嵌套标签。修饰标签也只能出现在<text>内部,放在<box>里不会生效(详见 gotchas)。
3.2 box:容器与 Flex 布局
<box> 是终端里的"div",属性面最大:
<box
// 边框
border
borderStyle="single" // single | double | rounded | bold
borderColor="#FFFFFF"
title="Title"
titleAlignment="center" // left | center | right
// 颜色
backgroundColor="#1a1a2e"
// 布局(Flex 模型)
flexDirection="row"
justifyContent="center"
alignItems="center"
gap={2}
// 间距
padding={2}
paddingTop={1}
paddingX={2} // 水平(左右)
paddingY={1} // 垂直(上下)
margin={1}
marginX={2}
marginY={1}
// 尺寸
width={40}
height={10}
flexGrow={1}
// 焦点
focusable // 允许 box 接收焦点
focused={isFocused} // 受控焦点状态
// 鼠标事件
onMouseDown={(e) => {}}
onMouseUp={(e) => {}}
onMouseMove={(e) => {}}
>
{children}
</box>
布局遵循 Flexbox(底层为 Yoga 布局引擎),完整规则见 layout/REFERENCE.md。两个高频坑:flexGrow 生效要求父容器有确定尺寸(如 height="100%");百分比宽度同样要求父级显式定宽。
3.3 scrollbox:可滚动容器
滚动条外观可以通过 style 里的多层选项精细定制(root / wrapper / viewport / content / scrollbar 各有独立配色):
<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>
3.4 input:单行输入框
<input
value={value}
onChange={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused // 初始聚焦(不加 focused 收不到键盘输入!)
width={30}
backgroundColor="#1a1a1a"
textColor="#FFFFFF"
cursorColor="#00FF00"
focusedBackgroundColor="#2a2a2a"
/>
终端组件没有默认焦点,focused 属性是"能否输入"的开关——这是与 Web 表单最直觉的差别。
3.5 textarea:多行文本
<textarea
value={text}
onChange={(newValue) => setText(newValue)}
placeholder="Enter multiple lines..."
focused
width={40}
height={10}
showLineNumbers
wrapText
/>
3.6 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}
/>
options 必须为 { name, description, value } 对象数组(裸字符串数组不合法)。事件语义要分清:onChange 在方向键导航时触发,onSelect 在按 Enter 确认时触发——提交逻辑应挂在 onSelect 上,预览逻辑才用 onChange。
3.7 tab-select:选项卡
<tab-select
options={[
{ name: "Home", description: "Dashboard" },
{ name: "Settings", description: "Configuration" },
]}
onChange={(index, option) => setTab(option)}
tabWidth={20}
focused
/>
3.8 ascii-font:ASCII 艺术字
<ascii-font
text="TITLE"
font="tiny" // tiny | block | slick | shade
color="#FFFFFF"
/>
3.9 code:语法高亮代码块
<code
code={sourceCode}
language="typescript"
showLineNumbers
highlightLines={[1, 5, 10]}
/>
3.10 line-number:行号 + 诊断信息
<line-number
code={sourceCode}
language="typescript"
startLine={1}
highlightedLines={[5]}
diagnostics={[
{ line: 3, severity: "error", message: "Syntax error" }
]}
/>
diagnostics 数组让行号栏能直接展示 LSP 风格的错误标记,是构建"代码审查类"终端界面的关键件。
3.11 diff:差异视图
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified" // unified | split
syncScroll // split 模式下两栏滚动同步
showLineNumbers
/>
以上 Code & Diff 类组件(code / line-number / diff 及 text-table、markdown 流式渲染)的分类索引见 components/code-diff.md。
四、类型导出
所有 Props 类型与事件类型都可从 @opentui/react 以 type 形式导入,便于编写类型安全的封装组件:
import type {
// 组件 props
TextProps,
BoxProps,
InputProps,
SelectProps,
// Hook 类型
KeyEvent,
// 来自 core
CliRenderer,
} from "@opentui/react"
例如 KeyEvent 即 useKeyboard 回调的参数类型,CliRenderer 即 useRenderer 的返回类型。
五、Cline CLI 中的完整落地印证
把上文 API 放到真实项目中看,Cline CLI 的 TUI 子系统(apps/cli/src/tui/)几乎逐一使用了本文 API:
| API | Cline CLI 用法 |
|---|---|
createCliRenderer |
index.tsx:exitOnCtrlC: false + autoFocus: false + enableMouseMovement: true,退出与焦点完全自管 |
createRoot / render / unmount |
同文件:root.render(<Root .../>),销毁时先 root.unmount() 再 renderer.destroy() |
useRenderer |
root.tsx:订阅 selection 事件实现选中复制、调用 copyToClipboardOSC52 |
useTerminalDimensions |
同文件:termHeight/termWidth 驱动对话框 maxHeight 与命令面板宽度自适应 |
renderer.on/off |
成对订阅/退订 selection;renderer.on("destroy") 驱动退出 Promise |
| 销毁时序 | queueMicrotask 延迟 destroy,保证 stdin 批次解析完成、标题重置后渲染器才销毁 |
这组代码说明:文档中的最小示例(createCliRenderer → createRoot → render)在生产应用中会扩展为"探测终端调色板 → 预设背景 → 渲染 → 监听 destroy → 微任务延迟销毁"的完整状态机,核心不变量只有一条——一切退出路径最终都汇到 renderer.destroy()。
六、工程化要点速查
结合 configuration 参考 与 gotchas,落地时请记住:
- 脚手架:
bunx create-tui@latest -t react my-app(注意选项必须放在项目名之前,且目录不能已存在);手动安装则bun install @opentui/react @opentui/core react。 - tsconfig 必配:
"jsx": "react-jsx"+"jsxImportSource": "@opentui/react",否则<text>等元素没有类型、无法渲染;lib需包含DOM(React 类型依赖)。 - React 版本:需要 React 19+(Cline CLI 固定 19.2.4)。
- 运行时:用 Bun 运行(
bun run src/index.tsx),顶层await开箱可用。 - 退出:
renderer.destroy(),永远不要process.exit();OpenTUI 对 SIGINT/SIGTERM/SIGQUIT/SIGHUP/SIGPIPE/SIGBREAK 等信号有内置清理,需自定义时设exitOnCtrlC: false但仍要走destroy()。 - 调试:
renderer.console.show()打开被捕获的 console 叠加层;设置DEV=true可连接react-devtools-core@7的独立 DevTools 窗口检查组件树。
延伸阅读(均在当前仓库内)
- api.md 原文档 —— 本文所基于的 API 参考
- React 框架总览 REFERENCE.md —— 适用场景、与 core/solid 的选择对比
- 配置参考 configuration.md —— create-tui 选项、tsconfig、Bun 打包与单文件可执行构建
- 模式参考 patterns.md —— 状态管理、焦点管理、表单、响应式布局、异步加载
- 常见坑 gotchas.md —— 信号处理、focus/select 事件语义、性能与调试
- Cline CLI TUI 入口 与 根组件 —— 生产级 createRoot/Hooks 用法实例
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 StartedRust0622
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