首页
/ Cline 终端 TUI 技术栈:OpenTUI Solid API 全解(render、Hooks 与组件实战参考)

Cline 终端 TUI 技术栈:OpenTUI Solid API 全解(render、Hooks 与组件实战参考)

2026-09-06 11:48:16作者:侯霆垣

在 Cline 仓库中,CLI 的交互式界面(TUI)构建在 OpenTUI 之上——仓库锁定了 @opentui/core@opentui/react 0.4.3(见 apps/cli/package.jsonbun.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/dialogopentui-spinner 的 peerDependencies 都将其列为可选适配层(见 bun.lock)。本文讲解的 API 即为该 Solid 适配层的完整参考,与 React 版共享同一套 @opentui/core 底层,组件与布局能力一致,差异主要在响应式机制与 JSX 命名约定上。

配套的仓库内文档可作延伸阅读(路径以仓库根目录为起点):

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/testingcreateTestRenderer,返回的 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 支持 durationease(如 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>

注意:不要把 bolditalicunderline 作为 <text> 的 props 使用;应使用 <strong><em><u> 等嵌套修饰标签。

Solid 的 reconciler 会自动解码 JSX 文本内容中的 HTML 实体:&lt;&gt;&amp; 会渲染为字面字符(例如 <text>Use &lt;box&gt; for containers</text> 显示为 Use <box> for containers),适用于 text 节点、contenttext 两个 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/paddingYmarginX/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(元素响应式),原始类型数组用 Indexitem() 是响应式的 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 调用":

  1. tsconfig.json"jsx": "preserve"(交给 Solid 编译器处理)+ "jsxImportSource": "@opentui/solid"(JSX 运行时来源)+ "module"/"moduleResolution": "NodeNext"

  2. bunfig.toml(Bun 运行时的 Solid 编译器预加载,必须项):

    preload = ["@opentui/solid/preload"]
    
  3. 构建插件Bun.build 时挂载 @opentui/solid/bun-plugin,否则产物中会残留未转换的 JSX;bun build --compile 可进一步产出 bun-darwin-arm64bun-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_STATSSHOW_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/onBluruseSelectionHandler
props 传递 常规 React 模式 禁止解构 props(const { value } = props 会破坏响应式),需 props.valuesplitProps
测试入口 @opentui/react/test-utilstestRender @opentui/solid 直接导出 testRender

选型建议(来自 solid/REFERENCE.md):追求最优重渲染性能、偏好 signal 式响应式或已有 SolidJS 基础时选 Solid;团队熟悉 React 时选 @opentui/react;需要最大控制面、最小包体或构建框架/库本身时直接用 @opentui/core 命令式 API。

高频陷阱速查

结合 gotchas.md 与上文 API 语义,以下是最值得提前记住的几条:

  1. 永远不要 process.exit():会跳过备用屏幕退出、光标恢复等清理逻辑;正确做法是 renderer.destroy()
  2. signal 必须调用{count} 显示 [Function] 且永不更新,正确写法是 {count()};store 则相反——{store().count} 是错的,{store.count} 才对;
  3. 不要解构 props / 直接改 storestate.items.push(x) 不触发更新,必须 setState("items", items => [...items, x])
  4. 输入组件必须 focused<input><select><scrollbox> 键盘交互都依赖显式焦点;
  5. 清理用 onCleanupsetInterval、订阅等副作用必须在 onCleanup 中释放,否则终端应用长期运行时会出现重复计时器与泄漏;
  6. console 输出被渲染器接管:调试时用 renderer.console.show() 打开内置调试控制台,或在配置里设置 consoleOptions

小结

@opentui/solid 的 API 面可以概括为"一个渲染入口 + 一组 hooks + 一套终端组件":render/testRender 负责应用与测试的挂载,extend/getComponentCatalogue 提供扩展点;useRendereruseKeyboardusePasteonResizeuseTerminalDimensionsonFocus/onBluruseSelectionHandleruseTimeline 覆盖终端事件与动画的全部交互面;<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.mdpatterns.md(signals/stores/资源/异步数据)与 gotchas.md 三篇配套文档与本文构成完整的 Solid TUI 开发参考。

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