Cline 的 OpenTUI Solid 终端 UI 模式详解:从 Signals、Stores 到动画的完整实践指南
本篇指南基于 Cline 仓库中内置的 OpenTUI 平台技能文档 patterns.md,系统讲解 @opentui/solid(OpenTUI 的 SolidJS 调和器)的九大核心模式:响应式状态(Signals / createMemo / Effects)、Stores、控制流、焦点管理、键盘导航、响应式布局、异步数据、组件组合与动画。读完后,你可以在 OpenTUI 终端界面项目中写出细粒度响应、可组合、可维护的 Solid 组件代码。
背景:patterns.md 在 OpenTUI Solid 文档体系中的位置
Cline 仓库在 .agents/skills/opentui/ 目录下内置了一份完整的 OpenTUI 开发技能库,其入口 SKILL.md 定义了"五文件模式"的框架参考结构:
| 文件 | 用途 | 何时阅读 |
|---|---|---|
| REFERENCE.md | 概述、适用场景、快速上手 | 始终先读 |
| api.md | 运行时 API、组件、Hooks | 编写代码时 |
| configuration.md | 项目搭建、tsconfig、打包 | 配置项目时 |
| patterns.md | 通用模式、最佳实践 | 实现指导 |
| gotchas.md | 陷阱、限制、调试 | 排查问题时 |
本技能库的决策树给出了 Solid 调和器的定位:当"想要细粒度响应性、最优重渲染"(fine-grained reactivity, optimal re-renders)时选择 solid/;团队熟悉 React 时选 react/;追求完全控制或最小体积时选 core/ 命令式 API。
从源码结构看,这些模式并非纸上谈兵:Cline 的 CLI TUI 本身就构建在 OpenTUI 之上——apps/cli/package.json 中声明了 @opentui/core 与 @opentui/react(0.4.3 版本),而 apps/cli/src/tui/ 下的大量组件(如 input-bar.tsx、status-bar.tsx、toast.tsx 等)直接导入 OpenTUI 模块。本文讲解的 Solid 模式与 React 调和器共享同一套核心概念(信号式状态、Flexbox 布局、useKeyboard、useTimeline 等 hooks),理解了 patterns 即可在两种调和器之间迁移。
快速上手命令(来自 configuration.md):
bunx create-tui@latest -t solid my-app
cd my-app && bun install
CLI 会创建 my-app 目录,该目录必须不存在;可选参数有 --no-git(跳过 git 初始化)与 --no-install(跳过 bun install)。也可以手动搭建:bun install @opentui/solid @opentui/core solid-js。运行环境必须是 Bun(OpenTUI 使用 Bun 运行、Zig 做原生构建),而不是 node。
响应式状态模式(Reactive State)
Signals:基本响应式状态
Solid 用信号(signal)作为最小响应单元。一个典型的计数器组件:
import { createSignal } from "solid-js"
function Counter() {
const [count, setCount] = createSignal(0)
return (
<box flexDirection="row" gap={2}>
<text>Count: {count()}</text>
<box border onMouseDown={() => setCount(c => c - 1)}>
<text>-</text>
</box>
<box border onMouseDown={() => setCount(c => c + 1)}>
<text>+</text>
</box>
</box>
)
}
这里有两个关键点:一是 JSX 内建元素(intrinsic elements)<box>、<text> 会被映射为 OpenTUI 的 BoxRenderable、TextRenderable(映射清单见 REFERENCE.md 的"JSX Elements"一节);二是注意部分多词组件用下划线而非连字符,例如 <tab_select>、<ascii_font>、<line_number>——这是 Solid 调和器的命名约定,写成 React 风格的 <tab-select> 会直接报错。
createMemo:派生状态
从信号计算派生值时,用 createMemo,它只在依赖变化时重新计算:
import { createSignal, createMemo } from "solid-js"
function PriceCalculator() {
const [quantity, setQuantity] = createSignal(1)
const [price, setPrice] = createSignal(9.99)
// Derived value - only recalculates when dependencies change
const total = createMemo(() => quantity() * price())
const formatted = createMemo(() => `$${total().toFixed(2)}`)
return (
<box flexDirection="column">
<text>Quantity: {quantity()}</text>
<text>Price: ${price()}</text>
<text>Total: {formatted()}</text>
</box>
)
}
这正是 Solid 相比框架整体重渲染的性能优势所在:total 与 formatted 形成依赖链,只有 quantity 或 price 变化时才沿链重算,UI 中未受影响的节点完全不动。
createEffect + onCleanup:响应副作用
对状态变化做出反应(如防抖保存)时使用 createEffect,并务必用 onCleanup 清理上一轮的副作用:
import { createSignal, createEffect, onCleanup } from "solid-js"
function AutoSave() {
const [content, setContent] = createSignal("")
createEffect(() => {
const text = content()
// Debounced save
const timeout = setTimeout(() => {
saveToFile(text)
}, 1000)
// Cleanup on next run or disposal
onCleanup(() => clearTimeout(timeout))
})
return (
<textarea
value={content()}
onInput={setContent}
placeholder="Auto-saves after 1 second..."
/>
)
}
这个模式有两个易错点(在 gotchas.md 中有专门章节):
- Effect 内必须"读到"信号——如果 effect 回调里没有调用
content(),它不会在内容变化时重新执行; - 忘记
onCleanup会造成内存泄漏——比如setInterval不清理,组件卸载后定时器仍在跑。
Stores:复杂状态管理
createStore 管理嵌套状态
当状态是嵌套对象、数组时,createStore 提供按路径的精确更新,且读取仍是响应式的:
import { createStore } from "solid-js/store"
interface AppState {
user: { name: string; email: string } | null
items: Array<{ id: number; name: string; done: boolean }>
settings: { theme: "dark" | "light" }
}
function App() {
const [state, setState] = createStore<AppState>({
user: null,
items: [],
settings: { theme: "dark" },
})
const addItem = (name: string) => {
setState("items", items => [
...items,
{ id: Date.now(), name, done: false }
])
}
const toggleItem = (id: number) => {
setState("items", item => item.id === id, "done", done => !done)
}
const setTheme = (theme: "dark" | "light") => {
setState("settings", "theme", theme)
}
return (
<box backgroundColor={state.settings.theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
<For each={state.items}>
{(item) => (
<text
fg={item.done ? "#888" : "#fff"}
onMouseDown={() => toggleItem(item.id)}
>
{item.done ? "[x]" : "[ ]"} {item.name}
</text>
)}
</For>
</box>
)
}
注意 toggleItem 的用法:setState("items", item => item.id === id, "done", done => !done) 表示"在 items 中定位满足谓词的元素,再更新它的 done 字段"。这与 gotchas.md 中强调的规则一致:不要直接变异 store(state.items.push(...) 不触发更新),嵌套字段必须通过 setState 的逐层路径更新(setState("user", "profile", "name", "Jane"));另外 store 不是函数,读取写作 state.count 而不是 store().count。
Store + Context 跨组件共享状态
把 store 和一组动作封装进 Context,是 Solid 中共享应用级状态的标准做法:
import { createStore } from "solid-js/store"
import { createContext, useContext, ParentComponent } from "solid-js"
interface Store {
count: number
items: string[]
}
type StoreContextValue = [
Store,
{
increment: () => void
addItem: (item: string) => void
}
]
const StoreContext = createContext<StoreContextValue>()
const StoreProvider: ParentComponent = (props) => {
const [state, setState] = createStore<Store>({
count: 0,
items: [],
})
const actions = {
increment: () => setState("count", c => c + 1),
addItem: (item: string) => setState("items", i => [...i, item]),
}
return (
<StoreContext.Provider value={[state, actions]}>
{props.children}
</StoreContext.Provider>
)
}
function useStore() {
const context = useContext(StoreContext)
if (!context) throw new Error("useStore must be used within StoreProvider")
return context
}
// Usage
function Counter() {
const [state, { increment }] = useStore()
return (
<box onMouseDown={increment}>
<text>Count: {state.count}</text>
</box>
)
}
useStore 中抛错的防御性写法保证了误用(在 Provider 外调用)能立即暴露,而不是静默拿到 undefined。
控制流(Control Flow)
Show:条件渲染
import { Show, createSignal } from "solid-js"
function ToggleableContent() {
const [visible, setVisible] = createSignal(false)
return (
<box flexDirection="column">
<box border onMouseDown={() => setVisible(v => !v)}>
<text>Toggle</text>
</box>
<Show
when={visible()}
fallback={<text fg="#888">Content is hidden</text>}
>
<text fg="#0f0">Content is visible!</text>
</Show>
</box>
)
}
gotchas.md 特别建议总是提供显式 fallback,以避免无 fallback 时渲染行为不确定。
For:对象数组列表
import { For, createSignal } from "solid-js"
function TodoList() {
const [todos, setTodos] = createSignal([
{ id: 1, text: "Learn Solid", done: false },
{ id: 2, text: "Build TUI", done: false },
])
const toggle = (id: number) => {
setTodos(todos =>
todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
)
)
}
return (
<box flexDirection="column">
<For each={todos()}>
{(todo) => (
<box onMouseDown={() => toggle(todo.id)}>
<text fg={todo.done ? "#888" : "#fff"}>
{todo.done ? "[x]" : "[ ]"} {todo.text}
</text>
</box>
)}
</For>
</box>
)
}
Index:原始类型数组
数组元素是字符串等原始类型时,应使用 Index,此时插槽第一个参数是"该索引位置的响应式读取器" item():
import { Index, createSignal } from "solid-js"
function StringList() {
const [items, setItems] = createSignal(["apple", "banana", "cherry"])
return (
<box flexDirection="column">
<Index each={items()}>
{(item, index) => (
<text>{index}: {item()}</text>
)}
</Index>
</box>
)
}
For 与 Index 的选型规则(对象用 For、原始值用 Index)同样被列为 gotchas.md 的控制流要点之一。
Switch/Match:多状态分支
import { Switch, Match, createSignal } from "solid-js"
type Status = "idle" | "loading" | "success" | "error"
function StatusDisplay() {
const [status, setStatus] = createSignal<Status>("idle")
return (
<Switch>
<Match when={status() === "idle"}>
<text>Ready</text>
</Match>
<Match when={status() === "loading"}>
<text fg="#ff0">Loading...</text>
</Match>
<Match when={status() === "success"}>
<text fg="#0f0">Success!</text>
</Match>
<Match when={status() === "error"}>
<text fg="#f00">Error occurred</text>
</Match>
</Switch>
)
}
焦点管理(Focus Management)
终端应用没有浏览器的自动焦点系统,需要用信号显式驱动焦点位置。下面的表单用 useKeyboard 处理 Tab / Shift+Tab 循环切换:
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
function FocusableForm() {
const [focusIndex, setFocusIndex] = createSignal(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
if (key.shift && key.name === "tab") {
setFocusIndex(i => (i - 1 + fields.length) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
<Index each={fields}>
{(field, i) => (
<input
placeholder={`Enter ${field()}...`}
focused={i === focusIndex()}
/>
)}
</Index>
</box>
)
}
配合 gotchas.md 的提醒:终端组件需要显式 focused 属性才会接收键盘输入(<input placeholder="..." focused />);对于 <select>,onSelect 在按 Enter 确认选择时触发,onChange 在方向键导航时触发——两者语义不同,混用是常见 bug 来源。
键盘导航(Keyboard Navigation)
全局快捷键用 useKeyboard hook 注册,它接收一个键事件回调。完整示例涵盖退出、Ctrl+S 保存与 Vim 风格移动:
import { useKeyboard } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // Never use process.exit() directly!
}
if (key.ctrl && key.name === "s") {
save()
}
// Vim-style
if (key.name === "j") moveDown()
if (key.name === "k") moveUp()
})
return <box>{/* ... */}</box>
}
这里的 renderer.destroy() 注释是全库级别的铁律(SKILL.md 的 Critical Rules 第 3 条,gotchas.md 中标为 Critical):永远不要直接调用 process.exit()——它会跳过终端清理,留下光标隐藏、raw mode、备用屏幕(alternate screen)未恢复的坏状态;renderer.destroy() 会先恢复终端再退出。
api.md 补充了 useKeyboard 的进阶用法:传 { release: true } 选项可接收按键释放事件,用于实现"当前按住了哪些键"的集合跟踪;另有 usePaste(粘贴事件,配合 decodePasteBytes 解码原始字节)、onResize、onFocus/onBlur(终端窗口焦点事件,Solid 独有 hooks)、useSelectionHandler(鼠标选中结束事件,同样 Solid 独有)等。
响应式设计(Responsive Design)
终端尺寸是运行时才知道的变量,useTerminalDimensions 返回响应式的尺寸信号,布局可以随窗口尺寸实时切换:
import { useTerminalDimensions } from "@opentui/solid"
function ResponsiveLayout() {
const dims = useTerminalDimensions()
return (
<box flexDirection={dims().width > 80 ? "row" : "column"}>
<box flexGrow={1}>
<text>Panel 1</text>
</box>
<box flexGrow={1}>
<text>Panel 2</text>
</box>
</box>
)
}
这是"终端宽度大于 80 列时左右并排,否则上下堆叠"的经典断点式布局。OpenTUI 的布局体系是 Yoga/Flexbox 风格(flex 属性参考 layout/REFERENCE.md),flexGrow、flexDirection、gap、paddingX/Y 等属性在 api.md 的 Box 组件属性表中都有完整说明。
异步数据(Async Data)
createResource:数据获取
Solid 的 createResource 把异步加载管理为响应式资源,配合 Suspense 提供加载占位:
import { createResource, Suspense } from "solid-js"
async function fetchData() {
const response = await fetch("https://api.example.com/data")
return response.json()
}
function DataDisplay() {
const [data] = createResource(fetchData)
return (
<Suspense fallback={<text>Loading...</text>}>
<Show when={data()}>
{(items) => (
<For each={items()}>
{(item) => <text>{item.name}</text>}
</For>
)}
</Show>
</Suspense>
)
}
错误处理
资源自带的 loading / error 状态加上 ErrorBoundary,可以覆盖加载、成功、失败三种渲染路径:
import { createResource, Show, ErrorBoundary } from "solid-js"
function SafeDataDisplay() {
const [data] = createResource(fetchData)
return (
<ErrorBoundary fallback={(err) => <text fg="red">Error: {err.message}</text>}>
<Show
when={!data.loading}
fallback={<text>Loading...</text>}
>
<Show
when={!data.error}
fallback={<text fg="red">Failed to load</text>}
>
<For each={data()}>
{(item) => <text>{item.name}</text>}
</For>
</Show>
</Show>
</ErrorBoundary>
)
}
组件组合(Component Composition)
Props 与 Children
用 ParentComponent 类型声明带 children 的容器组件:
import { ParentComponent, JSX } from "solid-js"
interface PanelProps {
title: string
children: JSX.Element
}
const Panel: ParentComponent<{ title: string }> = (props) => {
return (
<box border padding={1} flexDirection="column">
<text fg="#0ff">{props.title}</text>
<box marginTop={1}>
{props.children}
</box>
</box>
)
}
// Usage
<Panel title="Settings">
<text>Panel content here</text>
</Panel>
注意示例中 props.title、props.children 均以属性访问方式读取——这是有意为之。
Spread Props 与 splitProps
封装底层组件(如按钮包一层 <box>)时,用 splitProps 把自有 props 与透传 props 分离,再把剩余部分展开到内建元素上:
import { splitProps } from "solid-js"
interface ButtonProps {
label: string
onClick: () => void
// ...rest goes to box
}
function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, ["label", "onClick"])
return (
<box border onMouseDown={local.onClick} {...rest}>
<text>{local.label}</text>
</box>
)
}
这里与 gotchas.md 的"解构会破坏响应性"规则形成呼应:直接 const { label } = props 只读取一次,props 变化后不会更新;而 splitProps 返回的信号化局部 props 保持响应性,是 Solid 处理 props 分离的标准工具。
动画(Animation)
基于 Timeline 的动画
OpenTUI 提供时间线动画系统,useTimeline 在 Solid 中创建一个可复用时间线,add() 声明起始值与目标值,onUpdate 回调把插值写回信号:
import { createSignal, onMount } from "solid-js"
import { useTimeline } from "@opentui/solid"
function AnimatedProgress() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
})
onMount(() => {
timeline.add(
{ value: 0 },
{
value: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].value))
},
}
)
})
return (
<box flexDirection="column" gap={1}>
<text>Progress: {width()}%</text>
<box width={50} height={1} backgroundColor="#333">
<box width={width()} height={1} backgroundColor="#0f0" />
</box>
</box>
)
}
这个例子实现了一个 2 秒、easeOutQuad 缓动的进度条动画:嵌套的 <box> 用背景色叠加模拟进度条轨道与填充。时间线的完整能力(循环动画、缓动函数、属性过渡)见 animation/REFERENCE.md。
基于 Interval 的动画
不依赖时间线系统时,setInterval + 信号 + onCleanup 是最简单的周期更新模式:
import { createSignal, onCleanup } from "solid-js"
function Clock() {
const [time, setTime] = createSignal(new Date())
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
onCleanup(() => clearInterval(interval))
return <text>{time().toLocaleTimeString()}</text>
}
onCleanup 在此不可或缺:它是 gotchas.md 列出的"忘记清理导致多个 interval 叠加运行"问题的标准解法。
配套要点:让模式真正可运行的配置与陷阱
上述模式要落地,项目配置必须正确(完整说明见 configuration.md):
- tsconfig.json 关键项:
"jsx": "preserve"(交给 Solid 编译器处理 JSX)与"jsxImportSource": "@opentui/solid"(JSX runtime 来自 OpenTUI Solid),模块体系推荐NodeNext。配错的症状是"JSX 被编译成 React 调用"。 - bunfig.toml 必需:
preload = ["@opentui/solid/preload"],在代码运行前加载 Solid JSX transform;缺失时会报SyntaxError: Unexpected token '<'。 - 构建必须带插件:
import solidPlugin from "@opentui/solid/bun-plugin"后传入Bun.build({ plugins: [solidPlugin] }),否则产物中残留未转换的 JSX;打包可执行文件时支持bun-darwin-arm64、bun-linux-x64等目标平台。 - render 入口:
render(() => <App />)一行即可创建渲染器,也可传入{ targetFPS: 60, exitOnCtrlC: false, consoleOptions: { ... } }等配置,或传入createCliRenderer()预创建的渲染器实例;测试场景用testRender(() => <App />, { width: 40, height: 10 })获得snapshot()等测试工具。
同时牢记 gotchas.md 中的高频陷阱速查:
| 陷阱 | 症状 | 正确做法 |
|---|---|---|
信号忘记加 () |
显示 [Function] 或值不更新 |
<text>{count()}</text> |
| props 解构 | props 失去响应性 | 保持 props.x 访问或用 splitProps |
| 直接变异 store | 不触发更新 | 一律走 setState 路径更新 |
process.exit() |
终端处于损坏状态 | renderer.destroy() |
| 组件命名用连字符 | JSX 解析错误 | <tab_select>、<ascii_font>、<line_number> |
输入组件未 focused |
收不到键盘输入 | 显式传 focused |
小结
patterns.md 覆盖的九大模式——Signals/derived/effect 三件套、Stores、控制流四组件(Show/For/Index/Switch)、焦点与键盘、响应式尺寸、资源加载、组合与 spread props、双轨动画——构成了 OpenTUI Solid 项目从单组件到完整应用的完整模式库。它的设计哲学与 SolidJS 本身一致:把响应性下沉到最小粒度,让 UI 更新只发生在真正变化的节点上;而 OpenTUI 则把这套哲学带到了终端环境,通过 useKeyboard、useTerminalDimensions、useTimeline 等 hooks 补齐终端特有的输入、布局与动画能力。对于 Cline CLI 这类交互密集型 TUI 应用(其 TUI 组件层即构建于 OpenTUI,见 apps/cli/src/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 StartedRust0623
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