- 引言 —— shadcn 的方式来到了终端
- 三种渲染模型
- 框架地图
- 终端不会主动告诉你它的能力
- 缩放与闪烁
- 一个小例子 —— 用 Ink 做一个可滚动的日志查看器
- 该做 CLI 还是全屏应用
- 结语 —— 终端不是屏幕,而是协议
- 参考资料
引言 —— shadcn 的方式来到了终端
termcn 上了 GeekNews。它的介绍一句话就讲完了 —— 面向终端应用的 shadcn/ui。这是一套复制进自己代码库、随意修改的 React 组件集合,只不过渲染后端不是浏览器,而是 Ink 和 OpenTUI。
这条消息本身是件小事,却很好地概括了当下 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 Tea | Go | Elm 架构 | Lip Gloss 样式字符串组合 | 看重单一二进制分发的开发者工具 |
| ratatui | Rust | 立即模式 | 基于约束的布局拆分 | 需要性能与控制力的全屏应用 |
| Textual | Python | 协调 + CSS 样式表 | 类 CSS 语法 | Python 生态、数据工具、Web 同步部署 |
| Ink | Node.js / React | 协调 | Yoga flexbox | 给已有的 Node CLI 加一层 UI |
| OpenTUI | Zig 核心 + 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 环境变量,值是 truecolor 或 24bit 就能放心发送 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 发送出去的过程中,终端把中间状态也画到了屏幕上。应对方法有三种。
- 一帧只用一次 write。先把字符串整个拼好,再一次性写出去。
- 只重绘发生变化的单元格。把整个屏幕清空再重画(先 clear 再 redraw)必然会闪烁。与上一帧做 diff 才是标准做法。
- 使用同步输出。把 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 那套知识。这个选择对代码结构的影响,比语言本身还大。
- 能力靠探测,不要靠猜。
COLORTERM、NO_COLOR、是否为 TTY,这三项是最基本的,DECRQM 查询则一定要加上超时。 - 缩放通过事件来接收,滚动位置每次都要重新夹回合法范围。
- 一帧只用一次 write 发出去,条件允许的话就放在同步输出区间内发送。
- 一旦进入了备用屏幕,不管进程是怎么死的,都要退出来。恢复光标和解除 raw mode,是同一套流程里缺一不可的部分。
- 如果要显示韩语,请务必检查字符宽度的计算逻辑。字符串长度不等于宽度。
终端不只是一块黑屏幕,而是几十年转义序列约定层层沉积下来的地层。框架只是在上面又铺了一层好看的表皮,底下那套约定依然原封不动地活着。
参考资料
- termcn —— 基于 Ink、OpenTUI 的终端 UI 组件
- termcn 文档
- OpenTUI —— Zig 核心 + TypeScript 绑定
- Ink —— React for CLIs
- Bubble Tea —— Go 语言的 Elm 架构 TUI 框架
- ratatui —— Rust 的立即模式 TUI
- Textual —— Python TUI 与 CSS 样式
- blessed (Python) —— 终端能力查询与 kitty 键盘协议
- kitty —— 完整的键盘协议规范
- termstandard/colors —— 真彩色与 COLORTERM 约定
- NO_COLOR —— 关闭彩色输出的约定
- TUI 文艺复兴 2026 —— 各工具深度对比(相关文章)
현재 단락 (1/158)
[termcn](https://github.com/shadcn-labs/termcn) 上了 GeekNews。它的介绍一句话就讲完了 —— 面向终端应用的 shadcn/ui。这是一套复制进...