Cline 仓库中的 OpenTUI Solid 参考解析:用 @opentui/solid 构建细粒度响应式终端 TUI
本文基于 Cline 仓库内置的 OpenTUI 平台技能文档(.agents/skills/opentui/references/solid/ 目录下的 REFERENCE.md 及其配套 api.md、configuration.md、patterns.md、gotchas.md)展开。读完本文,你将掌握:如何选型并初始化一个基于 SolidJS 调和器(reconciler)的终端 UI 项目、@opentui/solid 的完整配置项(tsconfig / bunfig / 构建插件)、JSX 到 OpenTUI 渲染树的映射规则、全部组件与 Hooks API、常用响应式模式,以及官方文档中列出的高频陷阱与调试手段。
1. OpenTUI Solid 是什么,何时选用
OpenTUI Solid 是为终端用户界面(TUI)提供的 SolidJS 调和器,其官方定义是:A SolidJS reconciler for building terminal user interfaces with fine-grained reactivity。它提供五个核心能力:
- 自定义调和器(Custom reconciler):Solid 组件被渲染为 OpenTUI 的 renderable 对象,而非 DOM 节点;
- JSX 内置元素(JSX intrinsics):
<text>、<box>、<input>等标签直接对应终端渲染原语; - Hooks:
useKeyboard、useRenderer、useTimeline等终端专用钩子; - 细粒度响应性:只有实际变化的部分会重新渲染(Solid 的信号模型,而非虚拟 DOM diff);
- Portal 与 Dynamic:高级组合原语,支持跨挂载点渲染与动态组件切换。
1.1 选型决策
原文档给出了明确的“何时使用 / 何时不用”边界:
适合使用 Solid 调和器的场景:
- 追求最优的重渲染性能;
- 偏好基于信号(signal)的响应式模型;
- 需要对更新做细粒度控制;
- 构建性能关键型应用;
- 团队已经熟悉 SolidJS。
不适合的场景(原文档对比表,完整继承):
| 场景 | 应改用 |
|---|---|
| 团队熟悉 React 而非 Solid | @opentui/react |
| 需要最大控制力 | @opentui/core |
| 追求最小打包体积 | @opentui/core |
| 在 OpenTUI 之上构建框架/库 | @opentui/core |
这个决策与仓库中 SKILL.md 的“框架决策树”一致:core/(命令式 API,全控制、无框架开销)、react/(React 调和器)、solid/(细粒度响应性、最优重渲染)。
仓库佐证:Cline 自己的 CLI TUI(
apps/cli)实际采用的是 React 调和器而非 Solid——apps/cli/package.json 中锁定@opentui/core与@opentui/react均为0.4.3,apps/cli/tsconfig.json 中为"jsx": "react-jsx"、"jsxImportSource": "@opentui/react"。也就是说,Solid 参考文档是 Cline 面向开发者/Agent 提供的 OpenTUI 全平台技能的一部分,覆盖 core、react、solid 三套框架(见 SKILL.md 中metadata.references: core, react, solid),本文聚焦其中的 Solid 分支。
2. 项目初始化
2.1 使用 create-tui 脚手架(推荐)
bunx create-tui@latest -t solid my-app
cd my-app && bun install
注意事项(原文档与 SKILL.md 均强调):
my-app目录不能已经存在,CLI 会替你创建它;- 可选参数:
--no-git(跳过 git init)、--no-install(跳过 bun install); - 选项必须放在位置参数之前:
bunx create-tui -t solid my-app有效,bunx create-tui my-app -t solid无效——这是 SKILL.md 列出的 Critical Rule 第 2 条; - Agent 使用指引(原文档 Agent guidance):始终以自治模式加
-t <template>标志运行;绝不使用交互模式(bunx create-tui@latest my-app不带-t),因为交互模式会弹出用户提示,Agent 无法应答。
2.2 手动初始化
mkdir my-tui && cd my-tui
bun init
bun install @opentui/solid @opentui/core solid-js
最小可运行示例(原文档 Quick Start 代码,完整继承):
import { render } from "@opentui/solid"
import { createSignal } from "solid-js"
function App() {
const [count, setCount] = createSignal(0)
return (
<box border padding={2}>
<text>Count: {count()}</text>
<box
border
onMouseDown={() => setCount(c => c + 1)}
>
<text>Click me!</text>
</box>
</box>
)
}
render(() => <App />)
要点:<box> 上的 onMouseDown 说明 OpenTUI 的容器组件天然支持鼠标事件;{count()} 中的调用是响应式更新的关键(漏掉 () 是最高频错误之一,见第 9 节)。
3. 工程配置:tsconfig、bunfig 与打包
这部分完整继承自 configuration.md。Solid 的 JSX 转换由 Solid 编译器完成,因此工程配置有三个强制点,缺任何一个都会出现“JSX 未转换”类错误。
3.1 tsconfig.json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
关键设置解读(原文档 Critical settings):
jsx: "preserve"—— 让 Solid 编译器处理 JSX,TypeScript 只负责类型检查;jsxImportSource: "@opentui/solid"—— JSX runtime 必须指向 OpenTUI Solid 而非默认的solid-js,这样<text>、<box>等标签才会被映射到终端 renderable 而非 DOM 元素;module/moduleResolution: "NodeNext"—— 官方推荐,保证与 OpenTUI 的 ESM 包结构兼容。
对比佐证:Cline CLI 的 apps/cli/tsconfig.json 使用同一模式(
jsxImportSource指向@opentui/react),可见jsxImportSource指向哪个调和器是 OpenTUI 多框架体系的核心开关。
3.2 bunfig.toml(必需)
preload = ["@opentui/solid/preload"]
原文档明确标注 Required for the Solid compiler:该 preload 会在你的代码运行之前加载 Solid JSX transform。缺失时典型症状是 SyntaxError: Unexpected token '<'。
3.3 package.json 参考
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.tsx",
"dev": "bun --watch run src/index.tsx",
"test": "bun test",
"build": "bun run build.ts"
},
"dependencies": {
"@opentui/core": "latest",
"@opentui/solid": "latest",
"solid-js": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "latest"
}
}
3.4 推荐项目结构与入口
my-tui-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── MainContent.tsx
│ ├── stores/
│ │ └── appStore.ts
│ ├── App.tsx
│ └── index.tsx
├── bunfig.toml # Required!
├── package.json
└── tsconfig.json
入口 src/index.tsx:
import { render } from "@opentui/solid"
import { App } from "./App"
render(() => <App />)
App 组件示例(原文档中的经典三栏布局):
export function App() {
return (
<box flexDirection="column" width="100%" height="100%">
<Header />
<box flexDirection="row" flexGrow={1}>
<Sidebar />
<MainContent />
</box>
</box>
)
}
3.5 环境变量
Bun 自动加载 .env 文件,OpenTUI 相关的调试开关包括:
# Debug settings
OTUI_SHOW_STATS=false
SHOW_CONSOLE=false
# App settings
API_URL=https://api.example.com
const apiUrl = process.env.API_URL
4. 核心概念:信号、JSX 元素映射与文本修饰符
4.1 Signals(信号)
Solid 使用信号作为响应式状态单位,createEffect 会在依赖信号变化时自动重跑(原文档 Core Concepts 示例,完整继承):
import { createSignal, createEffect } from "solid-js"
function Counter() {
const [count, setCount] = createSignal(0)
// Effect runs when count changes
createEffect(() => {
console.log("Count is now:", count())
})
return <text>Count: {count()}</text>
}
注意 effect 内部必须实际读取 count() 才能建立依赖(详见 9.3 的“Effects Not Running”陷阱)。
4.2 JSX 元素到 OpenTUI renderable 的映射
Solid 调和器把 JSX 内置标签一一映射到 OpenTUI 的 renderable 类。特别注意部分标签使用下划线命名(Solid 约定,不是拼写错误)(原文档示例,完整继承):
<text>Hello</text> // TextRenderable
<box border>Content</box> // BoxRenderable
<input placeholder="..." /> // InputRenderable
<select options={[...]} /> // SelectRenderable
<tab_select /> // TabSelectRenderable (underscore!)
<ascii_font /> // ASCIIFontRenderable (underscore!)
<line_number /> // LineNumberRenderable (underscore!)
React 风格写法 <tab-select> / <ascii-font> / <line-number> 会直接报错,对照表:
| 概念 | React 调和器 | Solid 调和器 |
|---|---|---|
| Tab 选择 | <tab-select> |
<tab_select> |
| ASCII 字体 | <ascii-font> |
<ascii_font> |
| 行号代码 | <line-number> |
<line_number> |
4.3 文本修饰符(Text Modifiers)
在 <text> 内部使用修饰元素表达样式(原文档示例,完整继承):
<text>
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
<span fg="red">Colored text</span>
<br />
New line with <a href="https://example.com">link</a>
</text>
这与 SKILL.md 的 Critical Rule 第 4 条对应:文本样式必须用嵌套修饰标签,不要用 props。API 文档中 <text> 的属性注释也再次强调:不要使用 bold、italic、underline 作为 <text> 的 props,而应使用 <strong>、<em>、<u> 嵌套标签。
5. 组件体系
5.1 组件总览(原文档 Available Components 分类,完整继承)
布局与展示:
<text>—— 带样式的文本内容;<box>—— 带边框与布局的容器;<scrollbox>—— 可滚动容器;<ascii_font>—— ASCII 艺术字(注意下划线命名)。
输入:
<input>—— 单行文本输入;<textarea>—— 多行文本输入;<select>—— 列表选择;<tab_select>—— 基于 Tab 的选择(注意下划线)。
代码与 Diff:
<code>—— 语法高亮代码;<line_number>—— 带行号的代码(注意下划线);<diff>—— 统一/分栏 diff 查看器。
文本修饰符(仅用于 <text> 内部):<span>(内联样式)、<strong>/<b>(粗体)、<em>/<i>(斜体)、<u>(下划线)、<br>(换行)、<a>(链接)。
5.2 关键组件属性详解(来自 api.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>
Box(属性最全的容器):
<box
// 边框
border // 启用边框
borderStyle="single" // single | double | rounded | bold
borderColor="#FFFFFF"
title="Title"
titleAlignment="center" // left | center | right
// 颜色
backgroundColor="#1a1a2e"
// 布局(Flexbox)
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>
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 是显式属性——未声明的输入组件不会响应键盘(见 9.5 焦点陷阱)。
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
/>
tab_select(Tab 页签选择):
<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 / line_number / diff:
<code
code={sourceCode}
language="typescript"
/>
<line_number
code={sourceCode}
language="typescript"
startLine={1}
highlightedLines={[5]}
/>
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified" // unified | split
syncScroll // split 视图双栏同步滚动
/>
6. 运行时 API 与 Hooks
本节内容来自 api.md,是 Solid 参考的运行时能力全集。
6.1 渲染入口
render(node, rendererOrConfig?) —— 把 Solid 组件树渲染进 CLI renderer:
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() 的完整配置项(原文档 render() Options,完整继承):
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?) —— 用于快照与测试的测试 renderer:
import { testRender } from "@opentui/solid"
const testSetup = await testRender(() => <App />, {
width: 40,
height: 10,
})
testSetup.snapshot() // 获取当前渲染结果
testSetup.renderer // 访问 renderer
extend(components) —— 注册自定义 renderable 为 JSX 内置元素:
import { extend } from "@opentui/solid"
import { CustomRenderable } from "./custom"
extend({
custom: CustomRenderable,
})
// 之后即可在 JSX 中使用
<custom prop="value" />
getComponentCatalogue() —— 返回当前组件目录,可用于运行时枚举可用组件。
6.2 Hooks 清单
useRenderer() —— 访问 renderer 实例,含主题模式监听:
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>
)
}
useKeyboard(handler, options?) —— 键盘事件处理,支持按键释放事件:
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 事件
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>
}
usePaste(handler) —— 粘贴事件,接收携带原始字节的 PasteEvent:
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() —— 响应式终端尺寸(注意 Solid 独有钩子):
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) —— 终端窗口级焦点/失焦(Solid 独有钩子),renderer 会去重,不会重复派发同一焦点状态:
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>
}
useSelectionHandler(handler) —— 文本选择完成(mouse-up)时触发,Solid 独有(React 调和器没有此钩子):
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 的选中文本。
useTimeline(options?) —— 基于 timeline 系统的动画(示例见 8.4)。
7. Portal 与 Dynamic:高级组合原语
原文档 Special Components 章节的两个原语(示例完整继承):
7.1 Portal
把子节点渲染到另一个挂载点(典型用途:悬浮层、对话框脱离主布局流):
import { Portal } from "@opentui/solid"
function Overlay() {
return (
<Portal mount={renderer.root}>
<box position="absolute" left={10} top={5} border>
<text>Overlay content</text>
</box>
</Portal>
)
}
通用形式:
<Portal mount={targetNode}>
<box>Portal content</box>
</Portal>
7.2 Dynamic
根据状态动态切换要渲染的组件(注意切换的是不同组件类型而非条件渲染同一组件):
import { Dynamic } from "@opentui/solid"
function DynamicInput(props: { multiline: boolean }) {
return (
<Dynamic
component={props.multiline ? "textarea" : "input"}
placeholder="Enter text..."
/>
)
}
<Dynamic
component={isMultiline() ? "textarea" : "input"}
placeholder="Enter text..."
focused
/>
8. 常用模式(来自 patterns.md)
8.1 响应式状态
信号计数器(含鼠标交互的完整组件):
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>
)
}
派生状态(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>
)
}
Effect 与清理(防抖自动保存,注意 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..."
/>
)
}
8.2 Stores:复杂状态
createStore 管理嵌套结构(Todo 示例):
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)
}
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>
)
}
注意 setState 支持多路径定位 + 条件匹配(setState("items", item => item.id === id, "done", done => !done)),这是 Solid store 相对 signal 的核心优势。
Store + Context 跨组件共享(完整模式):
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
}
function Counter() {
const [state, { increment }] = useStore()
return (
<box onMouseDown={increment}>
<text>Count: {state.count}</text>
</box>
)
}
8.3 控制流
Solid 原生控制流组件与 OpenTUI 完全兼容:
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>
)
}
For(对象数组):
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(原始类型数组,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>
)
}
Switch/Match(多分支状态机):
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>
)
}
8.4 焦点管理、键盘导航与响应式布局
手动焦点状态(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>
)
}
全局快捷键(含 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>
}
基于终端宽度的响应式布局:
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>
)
}
8.5 异步数据与错误处理
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>
)
}
ErrorBoundary + 显式 loading/error 分支:
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>
)
}
8.6 组件组合与 Spread Props
ParentComponent 封装面板:
import { ParentComponent, JSX } from "solid-js"
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>
)
}
// 使用
<Panel title="Settings">
<text>Panel content here</text>
</Panel>
splitProps 转发剩余属性(Solid 保持 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>
)
}
8.7 动画
Timeline 驱动的进度条:
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>
)
}
setInterval 时钟(务必清理):
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>
}
9. 高频陷阱(Gotchas)与调试
本节完整继承 gotchas.md,按严重程度组织,每一条都有症状与修复。
9.1 致命级:绝不要直接调用 process.exit()
原文档定性为“最常见的错误”。直接 process.exit() 会把终端留在损坏状态(光标隐藏、raw mode、备用屏幕未退出):
// WRONG - Terminal left in broken state
process.exit(0)
// CORRECT - Use renderer.destroy()
import { useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
const handleExit = () => {
renderer.destroy() // Cleans up and exits properly
}
}
renderer.destroy() 会在退出前恢复终端(退出备用屏幕、恢复光标等)。这一规则同时是 SKILL.md 的 Critical Rule 第 3 条,对 core / react / solid 三套框架通用。
9.2 配置类陷阱
- 缺少 bunfig.toml:症状
SyntaxError: Unexpected token '<',组件不渲染。修复:项目根目录创建bunfig.toml并写入preload = ["@opentui/solid/preload"]。 - JSX 配置错误:症状是 JSX 被编译成 React 调用、报 “React not found”。修复:tsconfig 中确保
"jsx": "preserve"且"jsxImportSource": "@opentui/solid"。 - 构建未加插件:症状是产物里存在未转换的原始 JSX。修复:
Bun.build时加入plugins: [solidPlugin](import solidPlugin from "@opentui/solid/bun-plugin")。
9.3 响应性类陷阱
忘记调用信号 —— 值永远不更新、显示 [Function]:
// WRONG - Missing ()
const [count, setCount] = createSignal(0)
<text>Count: {count}</text> // Shows [Function]
// CORRECT
<text>Count: {count()}</text>
解构 props 破坏响应性 —— 解构是“一次性快照”:
// WRONG - Breaks reactivity
function Component(props: { value: number }) {
const { value } = props // Destructured once, never updates!
return <text>{value}</text>
}
// CORRECT - Keep props reactive
function Component(props: { value: number }) {
return <text>{props.value}</text>
}
// OR use splitProps
function Component(props: { value: number; other: string }) {
const [local, rest] = splitProps(props, ["value"])
return <text>{local.value}</text>
}
createEffect 不触发 —— effect 内未实际读取信号,依赖未建立:
// WRONG - Signal not accessed in effect
createEffect(() => {
console.log("Count changed") // Never runs after initial!
})
// CORRECT - Access the signal
createEffect(() => {
console.log("Count:", count()) // Runs when count changes
})
9.4 HTML 实体自动解码
Solid 调和器会自动解码 JSX 文本内容中的 HTML 实体(适用于文本节点、content prop 和 text prop):
<text>Use <box> for containers</text> // 显示: Use <box> for containers
<text>A & B</text> // 显示: A & B
9.5 焦点与输入类陷阱
- 焦点不生效:输入组件必须显式声明
focused:
// WRONG
<input placeholder="Type here..." />
// CORRECT
<input placeholder="Type here..." focused />
- select 无响应 / 事件混淆:
onSelect在回车确认时触发,onChange在方向键导航时触发——把提交逻辑放错事件是经典 bug:
// WRONG - expecting onChange to fire on Enter
<select
options={options()}
onChange={(i, opt) => submitForm(opt)} // This fires on arrow keys!
/>
// CORRECT
<select
options={options()}
onSelect={(i, opt) => submitForm(opt)} // Enter pressed - submit
onChange={(i, opt) => showPreview(opt)} // Arrow keys - preview
/>
9.6 控制流陷阱
- For vs Index 选错:对象数组用
For(item 响应式),原始类型数组用Index(item()响应式); - Show 缺少 fallback:建议始终提供显式 fallback:
<Show when={data()} fallback={<text>Loading...</text>}>
<Component />
</Show>
9.7 清理与 Store 陷阱
- 忘记 onCleanup:内存泄漏、多个 interval 并行。
setInterval必须在onCleanup(() => clearInterval(interval))中清理;createEffect内的订阅同理。 - 直接变异 store:
state.items.push(newItem)不会触发更新,必须setState("items", items => [...items, newItem]); - 嵌套路径更新:
state.user.profile.name = "Jane"无效,必须setState("user", "profile", "name", "Jane"); - Store 不是函数:
{store().count}报 “store is not a function”,正确写法是{store.count}。
9.8 运行时与常见报错
- 必须用 Bun 运行:
node src/index.tsx/npm run start是错误的,正确是bun run src/index.tsx/bun run start。 - render() 是异步的:自动创建 renderer 时可直接
render(() => <App />)(Bun 支持顶层 await);需要自行管理 renderer 时先await createCliRenderer()再传入。 - 常见报错速查:
| 报错 | 常见原因 |
|---|---|
Cannot read properties of undefined |
信号忘记加 (),或 props 被错误解构 |
JSX element has no corresponding closing tag |
组件命名用了连字符(<tab-select> 应为 <tab_select>) |
store is not a function |
store 用属性访问(store.count),不是函数调用 |
调试手段:OpenTUI 会捕获 console 输出,可用 renderer.console.show()(onMount 中调用)打开内嵌调试控制台;用 createEffect 包裹状态读取来追踪响应链路:
createEffect(() => {
console.log("State:", {
count: count(),
items: items(),
})
})
10. 构建与分发
10.1 构建脚本
// build.ts
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
plugins: [solidPlugin],
})
console.log("Build complete!")
运行 bun run build.ts。
10.2 编译为独立可执行文件
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./src/index.tsx"],
target: "bun",
plugins: [solidPlugin],
compile: {
target: "bun-darwin-arm64", // or bun-linux-x64, etc.
outfile: "my-app",
},
})
官方文档列出的可用编译目标:
bun-darwin-arm64—— macOS Apple Siliconbun-darwin-x64—— macOS Intelbun-linux-x64—— Linux x64bun-linux-arm64—— Linux ARM64bun-windows-x64—— Windows x64
10.3 测试配置
// 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)
}
// src/components/Counter.test.tsx
import { test, expect } from "bun:test"
import { renderForTest } from "../test-utils"
import { Counter } from "./Counter"
test("Counter renders initial value", async () => {
const { snapshot } = await renderForTest(() => <Counter initialValue={5} />)
expect(snapshot()).toContain("Count: 5")
})
测试的详细模式(快照、交互测试)可继续查阅 testing/REFERENCE.md。
11. 仓库佐证与延伸阅读
Cline 仓库中的实际使用情况:
- Cline CLI 的 TUI 实现位于
apps/cli/src/tui/,约 49 个组件文件(对话框、命令面板、模型选择器、主题选择器等)。从 apps/cli/package.json 与 apps/cli/tsconfig.json 可见,CLI 当前固定使用@opentui/core@0.4.3+@opentui/react@0.4.3的组合,即 React 调和器路线;@opentui/solid并未出现在 bun.lock 的依赖解析中。因此本文的 Solid 内容应视为 Cline 随仓库分发的 OpenTUI 全平台开发技能(.agents/skills/opentui/)的一部分,供你在自研 TUI 项目或为 Cline 贡献 TUI 代码时按需选用。 - 从锁文件看,
@opentui/core@0.4.3通过平台化的可选依赖分发原生二进制(如@opentui/core-linux-x64、@opentui/core-darwin-arm64等 8 个平台包),并依赖web-tree-sitter做代码解析——这解释了为何布局系统能做高性能 Flexbox、<code>/<diff>能做真实语法高亮。
Solid 参考的配套文档(“In This Reference” 章节链接,已转换为仓库根相对路径):
- 配置与构建 —— 项目搭建、tsconfig、bunfig、打包;
- API 参考 —— 组件、hooks、render 函数全量签名;
- 模式库 —— 信号、store、控制流、组合;
- 陷阱清单 —— 常见问题、调试、限制。
跨切面概念(“See Also” 章节链接,已转换为仓库根相对路径):
- Core 参考 —— 底层命令式 API;
- React 参考 —— 另一套声明式方案;
- 组件分类参考 —— 按类别的组件文档;
- 布局系统 —— Flexbox 布局;
- 键盘输入 —— 输入处理与快捷键、选择/剪贴板(OSC 52);
- 测试 —— 测试 renderer 与快照。
适用前提小结:以上所有内容以 cline 仓库当前 .agents/skills/opentui/references/solid/ 文档为准;运行时要求 Bun(OpenTUI 运行于 Bun,原生构建涉及 Zig 工具链,详见 core/gotchas.md 的运行时要求章节);Solid 调和器要求显式安装 @opentui/solid、@opentui/core 与 solid-js 三个依赖,并在 bunfig.toml 中 preload 其 JSX 转换器。
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