首页
/ Cline 仓库中的 OpenTUI Solid 参考解析:用 @opentui/solid 构建细粒度响应式终端 TUI

Cline 仓库中的 OpenTUI Solid 参考解析:用 @opentui/solid 构建细粒度响应式终端 TUI

2026-09-05 13:16:32作者:魏侃纯Zoe

本文基于 Cline 仓库内置的 OpenTUI 平台技能文档(.agents/skills/opentui/references/solid/ 目录下的 REFERENCE.md 及其配套 api.mdconfiguration.mdpatterns.mdgotchas.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> 等标签直接对应终端渲染原语;
  • HooksuseKeyboarduseRendereruseTimeline 等终端专用钩子;
  • 细粒度响应性:只有实际变化的部分会重新渲染(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.3apps/cli/tsconfig.json 中为 "jsx": "react-jsx""jsxImportSource": "@opentui/react"。也就是说,Solid 参考文档是 Cline 面向开发者/Agent 提供的 OpenTUI 全平台技能的一部分,覆盖 core、react、solid 三套框架(见 SKILL.mdmetadata.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> 的属性注释也再次强调:不要使用 bolditalicunderline 作为 <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 &lt;box&gt; for containers</text>  // 显示: Use <box> for containers
<text>A &amp; 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 响应式),原始类型数组用 Indexitem() 响应式);
  • Show 缺少 fallback:建议始终提供显式 fallback:
<Show when={data()} fallback={<text>Loading...</text>}>
  <Component />
</Show>

9.7 清理与 Store 陷阱

  • 忘记 onCleanup:内存泄漏、多个 interval 并行。setInterval 必须在 onCleanup(() => clearInterval(interval)) 中清理;createEffect 内的订阅同理。
  • 直接变异 storestate.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 Silicon
  • bun-darwin-x64 —— macOS Intel
  • bun-linux-x64 —— Linux x64
  • bun-linux-arm64 —— Linux ARM64
  • bun-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.jsonapps/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” 章节链接,已转换为仓库根相对路径):

适用前提小结:以上所有内容以 cline 仓库当前 .agents/skills/opentui/references/solid/ 文档为准;运行时要求 Bun(OpenTUI 运行于 Bun,原生构建涉及 Zig 工具链,详见 core/gotchas.md 的运行时要求章节);Solid 调和器要求显式安装 @opentui/solid@opentui/coresolid-js 三个依赖,并在 bunfig.toml 中 preload 其 JSX 转换器。

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