博客
首页归档关于搜索

关联站点

CodeRunCommon AuthNav2文件中转站搜索引擎ZBookSBTI 人格测试OSS对象存储在线翻译云笔记

鄂ICP备19019526号

© 2026 博客

  1. 首页
  2. OpenTUI + SolidJS 开发终端 TUI 应用:一份从入门到精通的完整指南

OpenTUI + SolidJS 开发终端 TUI 应用:一份从入门到精通的完整指南

2026年5月14日·约 17 分钟·5043 字·5 次阅读
技术前沿大模型技术前沿
OpenTUI + SolidJS 开发终端 TUI 应用:一份从入门到精通的完整指南

目录

  • 前言
  • 第一章:OpenTUI 核心架构解析
  • 1.1 为什么需要 OpenTUI?
  • 1.2 架构总览
  • 1.3 核心概念速查表
  • 第二章:环境准备与快速上手
  • 2.1 系统依赖
  • 2.2 项目初始化
  • 2.3 第一个 Hello World
  • 第三章:核心组件详解
  • 3.1 布局与容器组件
  • 3.2 文本修饰符
  • 3.3 输入组件
  • 3.4 代码与差异显示
  • 第四章:SolidJS 响应式与 OpenTUI 渲染的深度整合
  • 4.1 为什么 SolidJS 是 OpenTUI 的完美搭档
  • 4.2 Signals 与受控组件
  • 4.3 createEffect 与副作用同步
  • 4.4 控制流组件
  • 4.5 派生状态与 createMemo
  • 第五章:OpenTUI 核心 Hooks 详解
  • 5.1 useRenderer()
  • 5.2 useTerminalDimensions()
  • 5.3 useKeyboard() — 键盘事件处理
  • 5.4 usePaste() — 粘贴事件
  • 5.5 onResize() — 窗口大小变化
  • 5.6 useTimeline() — 动画时间线
  • 5.7 Portal — 传送门
  • 5.8 Dynamic — 动态组件
  • 第六章:实战项目——构建一个 AI 编程助手终端界面
  • 6.1 项目概述
  • 6.2 核心类型定义
  • 6.3 会话管理 Hook
  • 6.4 消息展示组件
  • 6.5 聊天输入组件
  • 6.6 主应用组装
  • 6.7 入口文件
  • 第七章:高级特性与性能优化
  • 7.1 extend() 自定义组件
  • 7.2 滚动性能优化
  • 7.3 测试策略
  • 第八章:构建与部署
  • Bun.build 打包
  • 第九章:常见问题与排查指南
  • Q1: 渲染空白/无输出
  • Q2: 键盘输入无响应
  • Q3: 状态更新后 UI 没变化
  • Q4: Zig 编译报错
  • 第十章:社区与生态
  • awesome-opentui
  • 参与贡献
  • 结语

OpenTUI + SolidJS 开发终端 TUI 应用:一份从入门到精通的完整指南

前言

如果你曾尝试为 AI 编码助手(如 OpenCode、Claude Code)构建终端界面,或者想用现代前端框架的思路开发一个功能丰富的终端应用,你大概率会遇到这样的困境:传统的 curses 库过于底层,手写状态管理复杂且容易出 bug;现有的 Node.js TUI 框架要么性能堪忧,要么 API 设计不够声明式,开发体验大打折扣。

OpenTUI 正是为解决这些问题而生的。它用 Zig 编写核心渲染引擎(保证极致性能),通过 TypeScript bindings 提供声明式 API,支持 SolidJS 和 React 两大前端生态。11000+ GitHub Stars、生产环境支撑 OpenCode 和 terminal.shop 的稳定运行——这不是一个实验性项目,而是真正经过实战检验的 TUI 基础设施。

本文将系统讲解 OpenTUI 的核心设计理念、@opentui/solid 的完整使用方式,通过大量实战代码演示从环境搭建、基础组件、布局系统、输入处理到高级特性的全链路开发流程。无论你是想构建一个 AI 编程助手的终端界面,还是想实现一个功能完备的文件管理器,本文都将为你提供所需的一切知识。


第一章:OpenTUI 核心架构解析

1.1 为什么需要 OpenTUI?

在深入技术细节之前,我们先理解 OpenTUI 解决的问题域和它独特的设计哲学。

传统 TUI 开发的问题:

  • Curses 过于底层:需要手动管理屏幕刷新、坐标计算、光标位置,状态同步全靠开发者自觉。一个 1000 行的 TUI 应用,光是管理光标和重绘逻辑就能占去 40% 的代码量。
  • React Terminal 方案性能差:一些基于 ANSI 转义序列的 React 方案每次状态更新都全屏重绘,在复杂界面下输入延迟高达数百毫秒,根本无法用于生产。
  • 缺乏组件化抽象:缺乏声明式的布局系统,UI 层级关系全靠坐标堆叠,一旦要调整布局几乎是重写。

OpenTUI 的解决思路:

OpenTUI 的核心是一个 Zig 编写的原生渲染引擎(@opentui/core),它直接操作终端的帧缓冲区(framebuffer),实现了真正的增量渲染——只有变化的区域才会被重绘。这保证了亚毫秒级的响应速度和极其流畅的用户体验。

同时,OpenTUI 提供了多框架 reconciler:目前已有 @opentui/solid(SolidJS)和 @opentui/react(React)两个官方包。这意味着你可以用熟悉的响应式前端框架(SolidJS 的 signals、React 的 hooks)来构建 TUI 界面,而不需要学习任何新的状态管理范式。SolidJS 的细粒度响应式模型尤其适合 TUI 场景——组件的精确更新机制与 OpenTUI 的增量渲染高度契合。

OpenTUI 还暴露了 C ABI,理论上任何语言都可以绑定使用。当前已有 Zig、TypeScript/JavaScript 的成熟bindings,社区还出现了 Rust 的实验性移植(opentui_rust)。

1.2 架构总览

OpenTUI 的架构分为五层,从上到下分别是:

应用层(Your App):你的 SolidJS/React 代码,使用 JSX 编写 UI(<box>、<text>、<input>、<scrollbox> 等)。

Reconciler 层(@opentui/solid / @opentui/react):SolidJS/React reconciler,负责将组件树的变化同步到 OpenTUI 的 renderable 树。

Core 层(@opentui/core):TypeScript 核心包,提供 imperative API、renderable 树管理、事件系统。

Zig 核心层(OpenTUI Core):Zig 编写的原生渲染引擎,进行帧缓冲操作、增量渲染计算、C ABI 暴露。

终端层(Terminal):最终输出,支持 Kitty、iTerm2 等高级终端的 True Color、OSC 8 超链接等特性。

1.3 核心概念速查表

概念说明
RenderableOpenTUI 的基本绘制单元,对应一个 UI 元素(如一个 box、一段文本)
TextNode文本节点,用于表示文字内容,支持样式属性(颜色、粗体、斜体等)
CliRendererCLI 渲染器,负责初始化终端、计算布局、处理输入事件
Reconciler调和器,将 SolidJS/React 组件树的变化同步到 renderable 树
Slot插槽,用于组件间的内容分发,类似于 Web Components 的 slot
Portal传送门,将内容渲染到 renderable 树的另一个位置(如浮层、遮罩)
Framebuffer帧缓冲区,OpenTUI 在内部维护一个虚拟屏幕缓冲区,通过对比前后两帧的差异,只重绘变化的单元格

第二章:环境准备与快速上手

2.1 系统依赖

OpenTUI 的 Zig 核心在构建 TypeScript 包时需要 Zig 编译器。以下是完整的环境依赖清单:

依赖版本要求安装方式
Zig≥ 0.13brew install zig 或从 ziglang.org 下载
Bun≥ 1.0`curl -fsSL https://bun.sh/install
Node.js≥ 18(备选)若不使用 Bun 时

验证 Zig 是否安装成功:

zig version
# 预期输出:0.13.0 或更高版本

2.2 项目初始化

OpenTUI 官方提供了一个交互式脚手架工具 create-tui,可以一键创建包含完整配置的 SolidJS 或 React 项目:

# 使用 Bun 创建 SolidJS 项目
bun create tui

# 创建过程中会有交互式提示:
# ? Select a framework: (Use arrow keys)
#   > Solid.js
#     React

# ? Enter a project name: my-first-tui

# 然后 Bun 会自动:
# 1. 创建项目目录结构
# 2. 安装所有依赖
# 3. 配置好 tsconfig.json 的 jsxImportSource
# 4. 配置好 bunfig.toml 的 preload

如果想手动创建一个新项目(推荐熟悉后使用),步骤如下:

mkdir my-tui && cd my-tui
bun init -y
bun add solid-js @opentui/solid

tsconfig.json 必须配置 jsxImportSource:

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "@opentui/solid",
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true
  }
}

bunfig.toml 必须配置 preload(这是关键步骤!):

[install]
preload = ["@opentui/solid/preload"]

⚠️ 为什么需要 preload? OpenTUI 的渲染器需要通过一个 preload 脚本在 Bun 运行时初始化终端环境(设置原始模式、处理窗口大小变化信号等)。没有这个 preload,渲染器无法正常工作。

2.3 第一个 Hello World

创建 index.tsx:

import { render } from "@opentui/solid"

const App = () => <text>Hello, OpenTUI World!</text>

render(App)

运行:

bun index.tsx

你应该会看到终端中打印出了 "Hello, OpenTUI World!"。就这么简单——没有 HTML、没有 CSS、没有构建步骤,Bun 直接运行。


第三章:核心组件详解

OpenTUI 提供了一套丰富的内置组件,涵盖了布局、文本展示、表单输入、代码显示等 TUI 常见场景。这一章我们对每个组件进行逐一讲解。

3.1 布局与容器组件

3.1.1 <text> — 文本容器

<text> 是最基础的组件,用于包裹纯文本或带有样式的文本片段。所有文本修饰符(<span>、<b>、<i> 等)必须放在 <text> 内部。

基础用法:

const Greeting = () => (
  <text>Hello, OpenTUI!</text>
)

带样式的前景/背景色:

const StyledText = () => (
  <text style={{ fg: "cyan", bg: "black" }}>
    Cyan text on black background
  </text>
)

颜色支持 16 色关键字(red、green、blue、cyan、yellow、magenta、black、white 等)、Hex 格式(#ff0000)以及 256 色编号。

3.1.2 <box> — 布局容器

<box> 是 Flexbox 风格的布局容器,支持边框、内边距、Flex 方向、对齐等属性。这是你构建大多数 UI 布局的主力组件。

基础属性:

const BasicBox = () => (
  <box
    width={40}
    height={10}
    border="single"
    padding={1}
    flexDirection="row"
    justifyContent="center"
    alignItems="center"
  >
    <text>Hello from a box!</text>
  </box>
)

边框类型一览:

边框类型显示效果
single┌──────┐
double╔══════╗
round╭──────╮
bold┏━━━━━━┓
solid████████

Flex 布局实战:

const FlexLayout = () => (
  <box flexDirection="row" padding={1}>
    <box width={10} border="single">
      <text>Left</text>
    </box>
    <box flexGrow={1} border="double">
      <text>Center (grows)</text>
    </box>
    <box width={10} border="single">
      <text>Right</text>
    </box>
  </box>
)

flexGrow={1} 使该 box 占据所有剩余空间。

3.1.3 <scrollbox> — 滚动容器

当内容超出容器可视区域时,<scrollbox> 提供滚动功能,支持垂直和水平滚动。

const ScrollDemo = () => {
  const items = Array.from({ length: 50 }, (_, i) => `Item ${i + 1}`)

  return (
    <scrollbox width={30} height={15} border="single">
      {items.map((item) => (
        <text>{item}</text>
      ))}
    </scrollbox>
  )
}

3.1.4 <ascii_font> — ASCII 艺术字

渲染 ASCII 艺术风格的标题文字,非常适合 TUI 应用的欢迎页面。

const ASCIITitle = () => (
  <box border="double">
    <ascii_font value="OPEN" font="block" />
  </box>
)

3.2 文本修饰符

文本修饰符组件必须放在 <text> 内部使用:

const TextModifiersDemo = () => (
  <text>
    <b>Bold text</b>
    {" | "}
    <i>Italic text</i>
    {" | "}
    <u>Underlined text</u>
    <br />
    <a href="https://opentui.com">Clickable link (OSC 8)</a>
  </text>
)

3.3 输入组件

3.3.1 <input> — 单行输入框

const InputDemo = () => {
  const [value, setValue] = createSignal("")

  return (
    <box border="single" padding={1}>
      <text>Type something: {value()}</text>
      <input
        value={value()}
        onInput={(v) => setValue(v)}
        placeholder="Enter text..."
      />
    </box>
  )
}

关键属性:

属性类型说明
valuestring当前输入值(受控)
onInput(value: string) => void输入变化回调
focusedboolean是否自动获得焦点
placeholderstring占位提示文字
maskstring遮罩字符,用于密码输入

3.3.2 <textarea> — 多行文本输入

const TextareaDemo = () => {
  const [content, setContent] = createSignal("")

  return (
    <box border="single">
      <textarea
        value={content()}
        onInput={(v) => setContent(v)}
        width={50}
        height={10}
        placeholder="Multi-line input..."
      />
    </box>
  )
}

3.3.3 <select> — 列表选择器

const SelectDemo = () => {
  const options = ["Option A", "Option B", "Option C"]
  const [selected, setSelected] = createSignal(0)

  return (
    <box border="single" padding={1}>
      <text>Selected: {options[selected()]}</text>
      <select
        options={options}
        selectedIndex={selected()}
        onSelect={(index) => setSelected(index)}
      />
    </box>
  )
}

3.3.4 <tab_select> — 标签页选择器

const TabsDemo = () => {
  const tabs = ["Home", "Settings", "Profile"]
  const [activeTab, setActiveTab] = createSignal(0)

  return (
    <tab_select
      tabs={tabs}
      activeIndex={activeTab()}
      onSelect={(index) => setActiveTab(index)}
    />
  )
}

3.4 代码与差异显示

3.4.1 <code> — 语法高亮代码块

const CodeDemo = () => (
  <code
    language="typescript"
    code={`const greet = (name: string) => {
  return \`Hello, \${name}!\`
}`}
  />
)

3.4.2 <diff> — 差异对比视图

const DiffDemo = () => (
  <diff
    language="typescript"
    oldCode={`const a = 1
const b = 2`}
    newCode={`const a = 1
const c = 3`}
    mode="unified"
  />
)

第四章:SolidJS 响应式与 OpenTUI 渲染的深度整合

4.1 为什么 SolidJS 是 OpenTUI 的完美搭档

理解 SolidJS 的响应式模型如何与 OpenTUI 的渲染系统协同工作,是写出高效 TUI 代码的关键。

SolidJS 的核心是细粒度响应式:当一个 createSignal() 创建的状态变化时,只有直接依赖该 signal 的 DOM/renderable 节点会更新,而不是整个组件重新执行。这与 OpenTUI 的增量渲染哲学高度一致——两者的结合意味着你修改一个输入框的值,OpenTUI 只会重绘该输入框对应的单元格,而不是全屏刷新。

对比 React:React 的 Virtual DOM diffing 每次状态变化都会重新计算整个组件子树(即便实际 DOM 没变),然后再由 reconciler 对比差异。对于 TUI 场景这是不必要的开销。

4.2 Signals 与受控组件

在 OpenTUI + SolidJS 中,所有输入组件都应该以受控模式使用,即组件的 value 由 signal 驱动,变化通过 onInput 等回调更新 signal:

const ControlledInput = () => {
  const [query, setQuery] = createSignal("")
  const [results, setResults] = createSignal<string[]>([])

  const handleSearch = () => {
    const q = query()
    setResults(
      q.length > 0
        ? [\`Result for "\${q}" #1\`, \`Result for "\${q}" #2\`]
        : []
    )
  }

  return (
    <box border="single" padding={1}>
      <text>Search:</text>
      <input
        value={query()}
        onInput={(v) => setQuery(v)}
        onSubmit={handleSearch}
      />
      <box marginTop={1}>
        {results().map((r) => (
          <text>&gt; {r}</text>
        ))}
      </box>
    </box>
  )
}

4.3 createEffect 与副作用同步

const WithEffect = () => {
  const [count, setCount] = createSignal(0)

  createEffect(() => {
    console.log(\`Count changed to: \${count()}\`)
  })

  return (
    <box border="single">
      <text>Count: {count()}</text>
      <button onClick={() => setCount((c) => c + 1)}>
        <text>+1</text>
      </button>
    </box>
  )
}

4.4 控制流组件

SolidJS 的 Show、For、Switch/Match 等控制流组件与 OpenTUI 的 reconciler 无缝集成:

const ControlFlowDemo = () => {
  const [mode, setMode] = createSignal<"view" | "edit">("view")
  const [data, setData] = createSignal({ name: "Alice", age: 30 })

  return (
    <box border="single" padding={1}>
      <Show when={mode() === "view"}>
        <box>
          <text>Name: {data().name}</text>
          <text>Age: {data().age}</text>
        </box>
      </Show>

      <Show when={mode() === "edit"}>
        <box>
          <input
            value={data().name}
            onInput={(v) => setData({ ...data(), name: v })}
          />
        </box>
      </Show>

      <button onClick={() => setMode(mode() === "view" ? "edit" : "view")}>
        <text>{mode() === "view" ? "Edit" : "Save"}</text>
      </button>

      <For each={[1, 2, 3]}>
        {(n) => <text>Item #{n}</text>}
      </For>
    </box>
  )
}

4.5 派生状态与 createMemo

const MemoDemo = () => {
  const [items, setItems] = createSignal<number[]>([1, 2, 3, 4, 5])
  const [filter, setFilter] = createSignal("even")

  const filteredItems = createMemo(() => {
    return items().filter((n) =>
      filter() === "even" ? n % 2 === 0 : n % 2 !== 0
    )
  })

  return (
    <box border="single">
      <text>Filter: {filter()}</text>
      <text>Numbers: {filteredItems().join(", ")}</text>
      <button onClick={() => setFilter(filter() === "even" ? "odd" : "even")}>
        <text>Toggle Filter</text>
      </button>
    </box>
  )
}

第五章:OpenTUI 核心 Hooks 详解

5.1 useRenderer()

获取当前的 CliRenderer 实例,用于直接调用渲染器的高级 API:

import { useRenderer } from "@opentui/solid"

const Demo = () => {
  const renderer = useRenderer()
  renderer.setBackgroundColor("#1a1a2e")

  return <box><text>Hello</text></box>
}

5.2 useTerminalDimensions()

获取当前终端的宽高(字符数),当终端窗口大小变化时自动更新:

import { useTerminalDimensions } from "@opentui/solid"

const FullScreenBox = () => {
  const [width, height] = useTerminalDimensions()

  return (
    <box width={width()} height={height()} border="double">
      <text>Terminal: {width()}x{height()}</text>
    </box>
  )
}

5.3 useKeyboard() — 键盘事件处理

import { useKeyboard } from "@opentui/solid"

const KeyboardDemo = () => {
  const [log, setLog] = createSignal<string[]>([])

  useKeyboard((event) => {
    if (event.key === "q" && event.ctrl) {
      process.exit(0)
    }
    setLog((prev) => [...prev.slice(-9), \`Key: \${event.key}\`])
  })

  return (
    <box border="single" height={12}>
      <For each={log()}>
        {(entry) => <text>{entry}</text>}
      </For>
    </box>
  )
}

event 对象的结构:

interface KeyboardEvent {
  key: string          // 按键标识:"a", "Enter", "ArrowUp", "Escape"
  ctrl: boolean        // Ctrl 键是否按下
  shift: boolean       // Shift 键是否按下
  alt: boolean         // Alt 键是否按下
  meta: boolean        // Meta (Cmd) 键是否按下
  code: string         // 物理键码
}

5.4 usePaste() — 粘贴事件

const PasteDemo = () => {
  const [pasted, setPasted] = createSignal("")

  usePaste((text) => {
    setPasted(text)
  })

  return (
    <box border="single">
      <text>Pasted: {pasted()}</text>
      <text>(Ctrl+Shift+V to paste)</text>
    </box>
  )
}

5.5 onResize() — 窗口大小变化

import { onResize, useTerminalDimensions } from "@opentui/solid"

const ResizeDemo = () => {
  const [width, height] = useTerminalDimensions()

  onResize((dims) => {
    console.log(\`Resized to \${dims.width}x\${dims.height}\`)
  })

  return <text>Size: {width()}x{height()}</text>
}

5.6 useTimeline() — 动画时间线

OpenTUI 内置了基于帧的动画系统:

import { useTimeline } from "@opentui/solid"

const AnimationDemo = () => {
  const [x, setX] = createSignal(0)

  useTimeline(({ frame, deltaTime }) => {
    setX((prev) => (prev + 1) % 40)
  })

  return (
    <box height={3}>
      <text>{" ".repeat(x())}●</text>
    </box>
  )
}

5.7 Portal — 传送门

Portal 允许你将内容渲染到 renderable 树的另一个位置,适合实现浮层(tooltips、modals、overlays):

import { Portal } from "@opentui/solid"

const Modal = () => {
  const [visible, setVisible] = createSignal(false)

  return (
    <>
      <button onClick={() => setVisible(true)}>
        <text>Open Modal</text>
      </button>

      <Show when={visible()}>
        <Portal mount={useRenderer().root}>
          <box border="double" padding={2} style={{ bg: "white", fg: "black" }}>
            <text>This is a modal overlay!</text>
            <button onClick={() => setVisible(false)}>
              <text>Close</text>
            </button>
          </box>
        </Portal>
      </Show>
    </>
  )
}

mount 参数指定渲染目标,useRenderer().root 即根渲染器。

5.8 Dynamic — 动态组件

Dynamic 用于运行时决定渲染哪个组件:

import { Dynamic } from "@opentui/solid"

const DynamicDemo = () => {
  const [componentType, setComponentType] = createSignal("box")

  return (
    <Dynamic component={componentType()} border="single">
      <text>Dynamic component content</text>
    </Dynamic>
  )
}

第六章:实战项目——构建一个 AI 编程助手终端界面

6.1 项目概述

我们要构建的 AI 编程助手 TUI 界面包含以下功能模块:

  1. 消息会话区:展示 AI 和用户的对话历史
  2. 输入区:用户输入命令行,支持多行输入和历史记录
  3. 状态栏:显示连接状态、Token 消耗、当前模式
  4. 侧边栏:会话列表管理

6.2 核心类型定义

export interface Message {
  id: string
  role: "user" | "assistant"
  content: string
  timestamp: number
}

export interface Session {
  id: string
  title: string
  messages: Message[]
  createdAt: number
}

6.3 会话管理 Hook

import { createSignal, createMemo } from "solid-js"

export function useChatSession() {
  const [sessions, setSessions] = createSignal<Session[]>([
    { id: "1", title: "New Chat", messages: [], createdAt: Date.now() },
  ])
  const [activeSessionId, setActiveSessionId] = createSignal("1")

  const activeSession = createMemo(() =>
    sessions().find((s) => s.id === activeSessionId()) ?? sessions()[0]
  )

  const addMessage = (role: "user" | "assistant", content: string) => {
    const newMessage: Message = {
      id: crypto.randomUUID(),
      role,
      content,
      timestamp: Date.now(),
    }
    setSessions((prev) =>
      prev.map((s) =>
        s.id === activeSessionId()
          ? { ...s, messages: [...s.messages, newMessage] }
          : s
      )
    )
  }

  const createSession = () => {
    const newSession: Session = {
      id: crypto.randomUUID(),
      title: \`Chat \${sessions().length + 1}\`,
      messages: [],
      createdAt: Date.now(),
    }
    setSessions((prev) => [...prev, newSession])
    setActiveSessionId(newSession.id)
  }

  return {
    sessions,
    activeSession,
    activeSessionId,
    setActiveSessionId,
    addMessage,
    createSession,
  }
}

6.4 消息展示组件

import { TextAttributes } from "@opentui/core"

interface ChatMessageProps {
  role: "user" | "assistant"
  content: string
}

export function ChatMessage(props: ChatMessageProps) {
  const isUser = () => props.role === "user"

  return (
    <box marginTop={1} paddingLeft={isUser() ? 0 : 2} flexDirection="row">
      <text
        style={{
          fg: isUser() ? "cyan" : "green",
          attributes: TextAttributes.BOLD,
        }}
      >
        {isUser() ? "❯ " : "◀ "}
      </text>
      <box flexGrow={1}>
        <text style={{ fg: isUser() ? "white" : "#e0e0e0" }}>
          {props.content}
        </text>
      </box>
    </box>
  )
}

6.5 聊天输入组件

import { createSignal, Show } from "solid-js"

interface ChatInputProps {
  onSend: (message: string) => void
  disabled?: boolean
}

export function ChatInput(props: ChatInputProps) {
  const [value, setValue] = createSignal("")
  const [history, setHistory] = createSignal<string[]>([])
  const [historyIndex, setHistoryIndex] = createSignal(-1)

  const handleSubmit = () => {
    const msg = value().trim()
    if (!msg || props.disabled) return
    props.onSend(msg)
    setHistory((prev) => [msg, ...prev.slice(0, 49)])
    setHistoryIndex(-1)
    setValue("")
  }

  const handleKeyDown = (event: KeyboardEvent) => {
    if (event.key === "ArrowUp") {
      const nextIndex = Math.min(historyIndex() + 1, history().length - 1)
      setHistoryIndex(nextIndex)
      setValue(history()[nextIndex] ?? "")
    } else if (event.key === "ArrowDown") {
      const nextIndex = Math.max(historyIndex() - 1, -1)
      setHistoryIndex(nextIndex)
      setValue(nextIndex === -1 ? "" : history()[nextIndex] ?? "")
    }
  }

  return (
    <box border="single" padding={1} flexDirection="column">
      <Show when={!props.disabled}>
        <textarea
          value={value()}
          onInput={(v) => setValue(v)}
          onKeyDown={handleKeyDown}
          width={60}
          height={3}
          placeholder="Type your message..."
        />
        <box marginTop={1} flexDirection="row" justifyContent="flex-end">
          <text style={{ fg: "#888" }}>{value().length} chars</text>
          <box width={2} />
          <button onClick={handleSubmit} style={{ fg: value().trim() ? "green" : "#555" }}>
            <text>[ Send ]</text>
          </button>
        </box>
      </Show>

      <Show when={props.disabled}>
        <text style={{ fg: "#888" }}>Waiting for response...</text>
      </Show>
    </box>
  )
}

6.6 主应用组装

import { createSignal, Show, For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useChatSession } from "./hooks/useChatSession"
import { ChatMessage } from "./components/ChatMessage"
import { ChatInput } from "./components/ChatInput"

export function App() {
  const { sessions, activeSession, activeSessionId, setActiveSessionId, addMessage, createSession } = useChatSession()
  const [connected, setConnected] = createSignal(true)
  const [tokenCount, setTokenCount] = createSignal(0)
  const [streaming, setStreaming] = createSignal(false)
  const [model] = createSignal("claude-sonnet-4")

  useKeyboard((event) => {
    if (event.ctrl && event.key === "n") createSession()
  })

  const handleSend = async (message: string) => {
    addMessage("user", message)
    setStreaming(true)
    const response = \`Simulated AI response to: "\${message}"\`
    await new Promise((r) => setTimeout(r, 500))
    addMessage("assistant", response)
    setTokenCount((prev) => prev + message.length + response.length)
    setStreaming(false)
  }

  return (
    <box flexDirection="column" width={90} height={30}>
      <box flexGrow={1} flexDirection="row">
        {/* 侧边栏 */}
        <box width={20} border="single" flexDirection="column">
          <box paddingX={1} style={{ bg: "#2d2d44", fg: "white" }}>
            <text>Sessions</text>
          </box>
          <box paddingX={1} paddingY={1} onClick={createSession}>
            <text style={{ fg: "yellow" }}>+ New Chat</text>
          </box>
          <scrollbox flexGrow={1}>
            <For each={sessions()}>
              {(session) => (
                <box
                  paddingX={1} paddingY={1}
                  style={{
                    fg: session.id === activeSessionId() ? "white" : "#888",
                    bg: session.id === activeSessionId() ? "#3d3d5c" : "transparent",
                  }}
                  onClick={() => setActiveSessionId(session.id)}
                >
                  <text>{session.title.slice(0, 18)}</text>
                </box>
              )}
            </For>
          </scrollbox>
        </box>

        {/* 聊天区 */}
        <box flexGrow={1} border="single" marginLeft={1} flexDirection="column">
          <scrollbox flexGrow={1} padding={1}>
            <Show when={activeSession().messages.length > 0} fallback={
              <text style={{ fg: "#888" }}>No messages yet. Start a conversation!</text>
            }>
              <For each={activeSession().messages}>
                {(msg) => <ChatMessage role={msg.role} content={msg.content} />}
              </For>
            </Show>
          </scrollbox>
          <ChatInput onSend={handleSend} disabled={streaming()} />
        </box>
      </box>

      {/* 状态栏 */}
      <box height={1} style={{ bg: "#1a1a2e", fg: "#888" }}>
        <text>
          <span style={{ fg: connected() ? "green" : "red" }}>{connected() ? "●" : "○"}</span>
          {" "}{model()}{" | "}Tokens: {tokenCount().toLocaleString()}{" | "}
          <span style={{ fg: streaming() ? "yellow" : "#555" }}>
            {streaming() ? "◐ Streaming" : "● Ready"}
          </span>
        </text>
      </box>
    </box>
  )
}

6.7 入口文件

import { render } from "@opentui/solid"
import { ConsolePosition } from "@opentui/core"
import { App } from "./App"

render(App, {
  targetFps: 60,
  exitOnCtrlC: true,
  consoleOptions: {
    position: ConsolePosition.BOTTOM,
    maxStoredLogs: 1000,
    sizePercent: 30,
  },
})

第七章:高级特性与性能优化

7.1 extend() 自定义组件

extend() 允许你将自定义的 renderable 注册为 JSX 内在元素:

import { extend } from "@opentui/solid"
import { BoxRenderable } from "@opentui/core"

class ProgressBarRenderable extends BoxRenderable {
  private _progress: number = 0
  constructor(options: any) {
    super(options)
    this.updateProgress(options.progress ?? 0)
  }
  updateProgress(progress: number) {
    this._progress = Math.max(0, Math.min(1, progress))
  }
}

extend({ progressbar: ProgressBarRenderable })

const ProgressDemo = () => {
  const [progress, setProgress] = createSignal(0)
  return (
    <box>
      <text>Uploading... {Math.round(progress() * 100)}%</text>
      <progressbar width={40} height={1} progress={progress()} />
    </box>
  )
}

7.2 滚动性能优化

在处理长列表时,使用虚拟滚动:

const VirtualizedList = () => {
  const [scrollTop, setScrollTop] = createSignal(0)
  const [itemHeight] = createSignal(3)
  const allItems = createSignal(Array.from({ length: 1000 }, (_, i) => \`Item \${i}\`))

  const visibleItems = createMemo(() => {
    const start = Math.floor(scrollTop() / itemHeight())
    return allItems().slice(start, start + 15)
  })

  return (
    <scrollbox height={20} onScroll={(info) => setScrollTop(info.scrollTop)}>
      <For each={visibleItems()}>
        {(item) => <text>{item}</text>}
      </For>
    </scrollbox>
  )
}

7.3 测试策略

OpenTUI 提供了 testRender 用于快照测试和交互测试:

import { testRender } from "@opentui/solid"

test("App renders correctly", async () => {
  const { root } = await testRender(() => <App />, {
    width: 80,
    height: 24,
  })

  expect(root.children).toMatchSnapshot()
})

第八章:构建与部署

Bun.build 打包

import solidPlugin from "@opentui/solid/bun-plugin"

await Bun.build({
  entrypoints: ["./index.tsx"],
  target: "bun",
  outdir: "./build",
  plugins: [solidPlugin],
  compile: {
    target: "bun-darwin-arm64",
    outfile: "my-tui-macos",
  },
})
平台target
macOS ARM64bun-darwin-arm64
macOS x64bun-darwin-x64
Linux ARM64bun-linux-arm64
Linux x64bun-linux-x64

第九章:常见问题与排查指南

Q1: 渲染空白/无输出

  • 确认 bunfig.toml 配置了正确的 preload
  • 确认终端支持相关渲染特性
  • 用 render(() => <text>TEST</text>) 简化排查

Q2: 键盘输入无响应

  • 确保有组件持有焦点(focus)
  • 使用 useKeyboard 处理全局按键

Q3: 状态更新后 UI 没变化

  • 确认使用的是响应式 API(createSignal)
  • JSX 中使用 count() 调用 signal 才算依赖追踪

Q4: Zig 编译报错

  • 确认 Zig 版本 ≥ 0.13
  • 使用 zig version 验证

第十章:社区与生态

awesome-opentui

awesome-opentui 收录了大量优秀项目:

项目说明
OpenCode最强的 AI 编程助手之一
waha-tui终端 WhatsApp 客户端
openmux终端多路复用器
opentui-gameboy终端 Gameboy 模拟器
hermHermes TUI

参与贡献

git clone https://github.com/anomalyco/opentui.git
cd opentui
bun install
cd packages/solid && bun run build
bun test

结语

OpenTUI 代表了终端 UI 开发的一次范式转变。它将 Zig 的极致性能、SolidJS 的声明式响应式模型、以及 TypeScript 的类型安全完美融合,为开发者提供了一个前所未有的 TUI 开发体验。

通过本文,你应该已经掌握了:

  • OpenTUI 的核心架构和设计哲学
  • @opentui/solid 的完整组件生态
  • SolidJS 响应式系统与 OpenTUI 渲染的深度整合方式
  • 从基础组件到复杂 TUI 应用的完整开发能力
  • 性能优化、测试和打包部署的最佳实践

OpenTUI 仍在快速迭代中,其插件系统、Three.js 渲染器(@opentui/three)等高级特性为更复杂的 TUI 应用打开了大门。强烈建议你 clone 官方 examples 包,动手运行每一个示例,这是掌握 OpenTUI 最有效的路径。

参考链接:

  • OpenTUI GitHub: https://github.com/anomalyco/opentui
  • 官方文档: https://opentui.com/docs/getting-started
  • awesome-opentui: https://github.com/msmps/awesome-opentui
  • create-tui 脚手架: https://github.com/msmps/create-tui
  • @opentui/solid 文档: https://github.com/anomalyco/opentui/blob/main/packages/solid/README.md

相关文章

  • Hermes Agent 完全使用指南:日常技巧与常见用法详解5月14日
  • 【AI 日报】2026年05月14日 AI 最新动态5月14日
  • 【工具推荐】Understand-Anything:用知识图谱读懂任意代码库5月13日

评论

加载评论中…

发表评论

返回首页