首页
/ Cline CLI 的终端 UI 底座:OpenTUI React(@opentui/react)API 实战精讲

Cline CLI 的终端 UI 底座:OpenTUI React(@opentui/react)API 实战精讲

2026-09-04 11:13:16作者:蔡怀权

本篇以 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.tsxapps/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 生态的完整兼容(useStateuseEffect、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/reacttype 形式导入,便于编写类型安全的封装组件:

import type {
  // 组件 props
  TextProps,
  BoxProps,
  InputProps,
  SelectProps,

  // Hook 类型
  KeyEvent,

  // 来自 core
  CliRenderer,
} from "@opentui/react"

例如 KeyEventuseKeyboard 回调的参数类型,CliRendereruseRenderer 的返回类型。

五、Cline CLI 中的完整落地印证

把上文 API 放到真实项目中看,Cline CLI 的 TUI 子系统(apps/cli/src/tui/)几乎逐一使用了本文 API:

API Cline CLI 用法
createCliRenderer index.tsxexitOnCtrlC: 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 成对订阅/退订 selectionrenderer.on("destroy") 驱动退出 Promise
销毁时序 queueMicrotask 延迟 destroy,保证 stdin 批次解析完成、标题重置后渲染器才销毁

这组代码说明:文档中的最小示例(createCliRenderercreateRootrender)在生产应用中会扩展为"探测终端调色板 → 预设背景 → 渲染 → 监听 destroy → 微任务延迟销毁"的完整状态机,核心不变量只有一条——一切退出路径最终都汇到 renderer.destroy()

六、工程化要点速查

结合 configuration 参考gotchas,落地时请记住:

  1. 脚手架bunx create-tui@latest -t react my-app(注意选项必须放在项目名之前,且目录不能已存在);手动安装则 bun install @opentui/react @opentui/core react
  2. tsconfig 必配"jsx": "react-jsx" + "jsxImportSource": "@opentui/react",否则 <text> 等元素没有类型、无法渲染;lib 需包含 DOM(React 类型依赖)。
  3. React 版本:需要 React 19+(Cline CLI 固定 19.2.4)。
  4. 运行时:用 Bun 运行(bun run src/index.tsx),顶层 await 开箱可用。
  5. 退出renderer.destroy(),永远不要 process.exit();OpenTUI 对 SIGINT/SIGTERM/SIGQUIT/SIGHUP/SIGPIPE/SIGBREAK 等信号有内置清理,需自定义时设 exitOnCtrlC: false 但仍要走 destroy()
  6. 调试renderer.console.show() 打开被捕获的 console 叠加层;设置 DEV=true 可连接 react-devtools-core@7 的独立 DevTools 窗口检查组件树。

延伸阅读(均在当前仓库内)

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
docsdocs
暂无描述
Markdown
889
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341