首页
/ Cline 的 OpenTUI Solid 终端 UI 模式详解:从 Signals、Stores 到动画的完整实践指南

Cline 的 OpenTUI Solid 终端 UI 模式详解:从 Signals、Stores 到动画的完整实践指南

2026-09-06 11:55:53作者:彭桢灵Jeremy

本篇指南基于 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.tsxstatus-bar.tsxtoast.tsx 等)直接导入 OpenTUI 模块。本文讲解的 Solid 模式与 React 调和器共享同一套核心概念(信号式状态、Flexbox 布局、useKeyboarduseTimeline 等 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 的 BoxRenderableTextRenderable(映射清单见 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 相比框架整体重渲染的性能优势所在:totalformatted 形成依赖链,只有 quantityprice 变化时才沿链重算,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 中有专门章节):

  1. Effect 内必须"读到"信号——如果 effect 回调里没有调用 content(),它不会在内容变化时重新执行;
  2. 忘记 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 中强调的规则一致:不要直接变异 storestate.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>
  )
}

ForIndex 的选型规则(对象用 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 解码原始字节)、onResizeonFocus/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),flexGrowflexDirectiongappaddingX/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.titleprops.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-arm64bun-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 则把这套哲学带到了终端环境,通过 useKeyboarduseTerminalDimensionsuseTimeline 等 hooks 补齐终端特有的输入、布局与动画能力。对于 Cline CLI 这类交互密集型 TUI 应用(其 TUI 组件层即构建于 OpenTUI,见 apps/cli/src/tui/),掌握这套模式意味着能够以声明式、响应式的方式构建流畅的终端交互体验。

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