Skip to content

필사 모드: 终端 UI 开发指南 — Ink、OpenTUI、Bubble Tea、ratatui、Textual:什么时候用什么

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 —— shadcn 的方式来到了终端

termcn 上了 GeekNews。它的介绍一句话就讲完了 —— 面向终端应用的 shadcn/ui。这是一套复制进自己代码库、随意修改的 React 组件集合,只不过渲染后端不是浏览器,而是 InkOpenTUI

这条消息本身是件小事,却很好地概括了当下 TUI 生态里正在发生的事情。Web UI 的那一整套范式 —— 组件、flexbox 布局、主题 token、复制粘贴式的分发 —— 正原封不动地搬进终端。OpenTUI 用 Zig 写了原生核心,外面套上 TypeScript 绑定,内置 Yoga flexbox 布局引擎和 tree-sitter 语法高亮,同时提供 React 和 Solid 两种绑定。做终端应用正变得越来越像做网页应用。

但这里有个陷阱。浏览器会告诉你它支持什么,终端不会。而且渲染模型在各个框架之间有根本性的不同。本文就围绕这两点来梳理 TUI 开发。各工具的介绍与文化背景已经在《TUI 文艺复兴 2026》一篇里讲过,这里只专注于"该选什么、要小心什么"。

三种渲染模型

按语言给框架分类,对选型没什么帮助。真正该看的是刷新屏幕的方式,一共三种。

Elm 架构 —— 代表是 Bubble Tea。应用由三部分组成: Model(状态)、Update(接收消息、返回新状态)、View(把状态渲染成字符串)。状态变化只能通过消息发生,Update 是一个纯函数。副作用则表达成 Cmd,交给运行时去处理。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "q" {
            return m, tea.Quit
        }
    case tea.WindowSizeMsg:
        m.width, m.height = msg.Width, msg.Height
    }
    return m, nil
}

好处是状态流转集中在一处 —— 哪个按键改变了什么,只要读 Update 这一个函数就够了。缺点是应用一旦变大,Update 就会膨胀成一个巨大的 switch,组件嵌套时还要写更多胶水代码,把消息层层委托给子组件。顺带一提,Bubble Tea 在 2026 年 2 月发布了 v2,经历了第一次破坏性变更,据报告渲染器被整体替换,性能大幅提升。

立即模式 —— 代表是 ratatui。它不保留任何 widget 树。每次循环都往 Frame 上重新画一遍 widget,库会拿它跟上一次的缓冲区做对比,只把发生变化的单元格写入终端。

terminal.draw(|frame| {
    let area = frame.area();
    let block = Block::default().title("logs").borders(Borders::ALL);
    frame.render_widget(List::new(items).block(block), area);
})?;

好处是概念极少。widget 不持有状态,整套模型就是"看当前状态、画当前画面"这一句话。渲染和状态之间的同步 bug,从结构上就不会出现。缺点是事件循环、焦点管理、滚动位置全都得自己动手实现。自由,也就意味着活儿多。

React 式协调 —— Ink、OpenTUI,还有性格略有不同的 Textual,都属于这一类。你搭建一棵组件树,协调器(reconciler)会拿它跟上一棵树做对比,只重绘变化的部分。Ink 用 Yoga 的 flexbox 来计算布局,Textual 则用类似 CSS 的语法把样式分离出来。

好处是 Web 开发者的既有知识可以直接搬过来用 —— useState、useEffect、组件组合、列表 key,行为都完全一样。Claude Code 和 Gemini CLI 都跑在 Ink 之上,这个事实本身就是这套模型在实战中的验证。缺点是抽象层比较厚,性能问题不好追踪,也很难直觉地知道一次重渲染最终会变成多少次真正的终端写入。

框架地图

框架语言 / 运行时模型布局适合场景
Bubble TeaGoElm 架构Lip Gloss 样式字符串组合看重单一二进制分发的开发者工具
ratatuiRust立即模式基于约束的布局拆分需要性能与控制力的全屏应用
TextualPython协调 + CSS 样式表类 CSS 语法Python 生态、数据工具、Web 同步部署
InkNode.js / React协调Yoga flexbox给已有的 Node CLI 加一层 UI
OpenTUIZig 核心 + TS 绑定协调(React、Solid)Yoga flexbox需要性能的 TypeScript TUI
termcn构建在 Ink、OpenTUI 之上的组件集合跟随以上二者跟随以上二者需要快速搭出界面时
blessed 系Node(组件库) / Python(能力封装)保留式 widget 树绝对・百分比坐标遗留系统维护、底层能力查询

关于 blessed,误解太多,这里专门澄清一下。Node.js 的 blessed 是一个很老的 widget 库,原始项目实际上已经停止维护,由 neo-blessed 之类的 fork 接手延续。而 Python 的 blessed 则完全是另一样东西 —— 它不是 widget 库,而是负责终端能力查询与输入处理的底层库,现在依然维护得很活跃,最新文档里甚至已经加入了 kitty 键盘协议的支持。名字相同不代表出自同一血统。

关于版本号,坦白说一句。ratatui 目前确认处于 0.30.x 系列,有报告称 v2 的工作正在推进,但这类信息会随时间变化而不同,直接去发布页面核实会比信一篇博客里的快照更准确。Textual 的发布节奏非常快,快到引用某个具体版本号几乎没有意义。Bubble Tea v2 和 Ink 6 都分别在 2026 年完成了各自的主版本迁移。

终端不会主动告诉你它的能力

浏览器有特性检测 API。终端没有。能用的只有几个约定俗成的环境变量,以及可能给你答案、也可能不给的转义序列查询。TUI 里"为什么我的屏幕上冒出了奇怪的字符",原因几乎总是出在这里。

实际开发中需要探测的东西有四类。

颜色。 TERM 不太能准确反映是否支持真彩色,terminfo 的 RGB 能力字段填得也不齐。实际中最可靠的信号是 COLORTERM 环境变量,值是 truecolor24bit 就能放心发送 24 位 SGR 序列。kitty、WezTerm、Ghostty、foot、iTerm2、Alacritty、Windows Terminal 都会自动设置它。反过来,如果设置了 NO_COLOR,惯例是把颜色彻底关掉。

输出对象是不是 TTY。 这是最常被漏掉的检查。如果标准输出被重定向到管道或文件,颜色、加载动画、光标移动就必须全部关掉。不然控制字符会原封不动地混进日志文件,没法再用 grep 过滤。

终端尺寸。 这不是从环境变量拿的,而是通过 ioctl(TIOCGWINSZ) 获取,尺寸变化则通过 SIGWINCH 信号通知。在 Node 里,process.stdout.columns 和 stdout 的 resize 事件就是对它的封装。

高级特性。 同步输出、kitty 键盘协议、鼠标报告,都可以用 DECRQM 查询去问。但有些终端根本不会应答,所以必须设置超时。一直卡在等待响应上、永远不动的 TUI,是很常见的 bug。

# 确认当前终端自称是什么
printf 'TERM=%s COLORTERM=%s TERM_PROGRAM=%s\n' \
  "$TERM" "$COLORTERM" "$TERM_PROGRAM"

# 查询是否支持同步输出(DEC 私有模式 2026) — DECRQM
# 响应格式: CSI ? 2026 ; <状态> $ y   (状态为 0 表示不支持)
printf '\033[?2026$p'; sleep 0.2; echo

# 查询一级设备属性 — 只要有响应,至少说明终端还活着
printf '\033[c'; sleep 0.2; echo

# 肉眼确认真彩色
printf '\033[38;2;255;100;0mtruecolor\033[0m\n'

还有一点。字符宽度的计算。 韩文、中日韩表意文字、emoji 占的都是两个单元格而不是一个,emoji 序列还可能是好几个码点拼成一个字形。如果用字符串长度当宽度来算,一旦混入韩文,边框立刻就会错位。框架通常会替你处理好这件事,但如果要自己写测量宽度的代码,就必须用 wcwidth 系列函数,或者按字形簇(grapheme cluster)为单位来计算。如果你要做一个显示韩语的 TUI,这就不是可选项。

缩放与闪烁

这是用户在 TUI 里最先注意到的两个毛病。

缩放,需要当作一个事件来处理。每次渲染都重新读取尺寸的做法,一旦尺寸在某一帧渲染过程中途发生变化,就会画出半张残缺的画面。正确的顺序是: 先接收缩放信号、更新状态,再让这次状态更新去触发渲染。Bubble Tea 用 tea.WindowSizeMsg 表达这一点,Ink 用 stdout 的 resize 事件,Textual 则用 on_resize

处理缩放时经常被忽略的一点,是滚动位置的校正。窗口一旦变小,当前可见的范围就可能超出内容末尾,所以每次都得把位置重新夹回合法范围内。

闪烁与画面撕裂,原因只有一个: 一帧被拆成好几次 write 发送出去的过程中,终端把中间状态也画到了屏幕上。应对方法有三种。

  1. 一帧只用一次 write。先把字符串整个拼好,再一次性写出去。
  2. 只重绘发生变化的单元格。把整个屏幕清空再重画(先 clear 再 redraw)必然会闪烁。与上一帧做 diff 才是标准做法。
  3. 使用同步输出。把 DEC 私有模式 2026 开、关一下,终端就会把这段区间当作一个原子操作来处理。据报告,Ink 6.7 以上版本与 Bubble Tea v2 都已经采用了这个协议。
# 同步输出的基本形态 —— 告知一帧的开始与结束
printf '\033[?2026h'   # begin synchronized update
#   ... 在这里输出整个帧 ...
printf '\033[?2026l'   # end synchronized update

另外,如果是全屏应用,就必须使用备用屏幕缓冲区(alternate screen buffer)。进入时发送 CSI ? 1049 h,退出时发送 CSI ? 1049 l,应用结束后用户的 shell 画面和回滚缓冲(scrollback)就会原样恢复。不这么做,用户的终端历史就会被应用画面永久覆盖掉 —— 这是 TUI 礼仪里最基本的一条。退出处理同样重要。哪怕是因为 panic 或信号而意外终止,也必须退出备用屏幕、让光标重新可见、并解除 raw mode。不然用户就会被留在一个连自己输入都看不见的 shell 里。

一个小例子 —— 用 Ink 做一个可滚动的日志查看器

这是一个把缩放、按键输入、范围校正、退出处理全都包含在内的最小示例,可以直接照抄运行。

mkdir tui-demo && cd tui-demo
npm init -y && npm pkg set type=module
npm i ink react
npm i -D tsx typescript @types/react
// viewer.tsx — 运行: npx tsx viewer.tsx
import React, { useEffect, useState } from 'react'
import { render, Box, Text, useApp, useInput, useStdout } from 'ink'

const LINES = Array.from(
  { length: 500 },
  (_, i) => `[${String(i).padStart(4, '0')}] worker-${i % 4} processed batch ${i}`
)

function Viewer({ lines }: { lines: string[] }) {
  const { stdout } = useStdout()
  const { exit } = useApp()
  const [size, setSize] = useState({
    cols: stdout.columns ?? 80,
    rows: stdout.rows ?? 24,
  })
  const [top, setTop] = useState(0)

  // 尺寸不在渲染过程中读取,而是通过事件拿到后放进 state
  useEffect(() => {
    const onResize = () =>
      setSize({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 })
    stdout.on('resize', onResize)
    return () => {
      stdout.off('resize', onResize)
    }
  }, [stdout])

  const body = Math.max(size.rows - 2, 1)
  const maxTop = Math.max(lines.length - body, 0)

  // 窗口变小时,当前位置可能超出范围 — 每次都重新夹紧到合法范围
  useEffect(() => {
    setTop((t) => Math.min(t, maxTop))
  }, [maxTop])

  useInput((input, key) => {
    if (input === 'q') exit()
    if (input === 'j' || key.downArrow) setTop((t) => Math.min(t + 1, maxTop))
    if (input === 'k' || key.upArrow) setTop((t) => Math.max(t - 1, 0))
    if (key.pageDown) setTop((t) => Math.min(t + body, maxTop))
    if (key.pageUp) setTop((t) => Math.max(t - body, 0))
  })

  const view = lines.slice(top, top + body)

  return (
    <Box flexDirection="column" width={size.cols}>
      <Box borderStyle="round" borderColor="cyan" paddingX={1}>
        <Text color="cyan">
          {`${top + 1}-${top + view.length} / ${lines.length}`}
        </Text>
        <Text dimColor>{'   j·k 移动   PgUp·PgDn 翻页   q 退出'}</Text>
      </Box>
      {view.map((line, i) => (
        <Text key={top + i} wrap="truncate-end">
          {line}
        </Text>
      ))}
    </Box>
  )
}

// 如果输出不是 TTY,就不启动 UI,直接把文本流式打印出来
if (!process.stdout.isTTY) {
  for (const line of LINES) console.log(line)
} else {
  render(<Viewer lines={LINES} />)
}

这四十来行代码,把前面说的规则全都用上了: 尺寸通过事件接收,滚动位置被夹在合法范围内,wrap="truncate-end" 让过长的行不会撑坏布局,如果不是 TTY,就干脆放弃整个 UI。最后这一条尤其关键 —— 正是这一行代码,让 node viewer.tsx | grep worker-2 能够正常工作。

Ink 默认画在普通屏幕缓冲区上。要把它做成全屏应用,就得自己动手接入备用屏幕的进入与退出逻辑。

// 要用作全屏,进入与退出必须成对管理
const enter = () => process.stdout.write('[?1049h')
const leave = () => process.stdout.write('[?1049l[?25h')

enter()
process.on('exit', leave)
process.on('SIGINT', () => {
  leave()
  process.exit(130)
})

有了 process.on('exit', leave),哪怕进程因为异常而死掉,用户的屏幕也能被恢复。漏掉这一步处理的 TUI,其实相当多。

该做 CLI 还是全屏应用

这个放在最后讨论的判断,其实才是最应该最先做出的判断。这是两种截然不同的东西。

流式 CLI,会把内容按行写到标准输出,运行结束后结果留在回滚缓冲区里,也可以用管道传递给别的命令。哪怕带了进度条或加载动画,本质也是一样的。该选择这条路的信号是这些。

  • 结果有可能被传给其他命令,或者存成文件
  • 会在 CI 里运行
  • 用户的使用方式是: 执行命令、读结果、然后离开
  • 单次运行几秒钟之内就能结束

全屏应用,则会占用备用屏幕、独占输入,退出后什么也不留下。这条路的信号如下。

  • 用户会在同一个画面里在多个操作之间来回切换(导航、筛选、选择、执行)
  • 状态在持续不断地更新(日志追踪、资源监控)
  • 需要用到好几个键盘快捷键
  • 会话会持续一分钟以上

拿不准的时候,先从流式 CLI 开始。全屏应用一旦做了就很难回头,选它意味着一次性放弃无障碍支持、自动化、管道兼容这三样东西。实际上,很多做得好的工具都会同时提供两种模式 —— 带参数运行就一次性输出结果,不带参数直接运行就打开交互式界面。

框架的选择放在这之后再考虑。如果分发形式必须是单一二进制文件,就用 Go 或 Rust(Bubble Tea、ratatui); 如果手上已经有一个 Node CLI,就用 Ink; 如果是 Python 数据工具,就用 Textual; 想用 TypeScript 写又需要性能,就用 OpenTUI。termcn 不是框架,而是搭建在这些框架之上的组件集合,所以它的作用是在你已经选定 Ink 或 OpenTUI 之后,帮你缩短搭界面的时间。

结语 —— 终端不是屏幕,而是协议

TUI 框架已经变得很省心了。用 flexbox 排版,用主题 token 换颜色,把组件复制粘贴过来就能用。正因如此,底下那一层反而更容易被忘记。

  • 先选渲染模型。Elm 把状态流转集中在一处,立即模式让概念更少,协调则可以复用 Web 那套知识。这个选择对代码结构的影响,比语言本身还大。
  • 能力靠探测,不要靠猜。COLORTERMNO_COLOR、是否为 TTY,这三项是最基本的,DECRQM 查询则一定要加上超时。
  • 缩放通过事件来接收,滚动位置每次都要重新夹回合法范围。
  • 一帧只用一次 write 发出去,条件允许的话就放在同步输出区间内发送。
  • 一旦进入了备用屏幕,不管进程是怎么死的,都要退出来。恢复光标和解除 raw mode,是同一套流程里缺一不可的部分。
  • 如果要显示韩语,请务必检查字符宽度的计算逻辑。字符串长度不等于宽度。

终端不只是一块黑屏幕,而是几十年转义序列约定层层沉积下来的地层。框架只是在上面又铺了一层好看的表皮,底下那套约定依然原封不动地活着。

参考资料

현재 단락 (1/158)

[termcn](https://github.com/shadcn-labs/termcn) 上了 GeekNews。它的介绍一句话就讲完了 —— 面向终端应用的 shadcn/ui。这是一套复制进...

작성 글자: 0원문 글자: 8,727작성 단락: 0/158