解密openclaw底层pi-mono架构系列一:3.pi-tui
不谈玄学,只讲落地。
我是一名深耕算法工程化一线的实践者,擅长将 新技术、关键技术、AI/ML 技术从论文和 demo 转化为可规模化部署的生产系统。在这里,你看不到堆砌公式的理论空谈,只有真实项目中踩过的坑、趟过的路,每一篇文章都源自实战经验的提炼。我相信技术的价值在于解决真实问题,而不是制造焦虑。如果你也厌倦了"收藏即学会",渴望掌握让算法真正跑起来的硬核能力,那么这里就是你的技术补给站。

pi-tui:终端也能画出漂亮 UI ------ 终端界面框架详解
前两篇我们介绍了
pi-ai(架构)和pi-agent-core(智能体运行时)。AI 能力有了,但用户怎么跟它交互?本篇介绍pi-tui------一个专为终端打造的 UI 框架,让你在黑底白字的终端里也能构建不闪烁、可交互、有组件化架构的漂亮界面。
目录
- [为什么需要终端 UI 框架](#为什么需要终端 UI 框架)
- [pi-tui 核心架构](#pi-tui 核心架构)
- 差分渲染:不闪烁的秘密
- [5 分钟上手:Hello TUI](#5 分钟上手:Hello TUI)
- 组件系统:像搭积木一样构建界面
- 内置组件一览
- [实战:用 SelectList 构建交互菜单](#实战:用 SelectList 构建交互菜单)
- 中文支持:宽字符处理
- 总结与下一步
1. 为什么需要终端 UI 框架
直接用 console.log 不行吗?
简单场景当然可以。但当你要构建像 Claude Code 这样的交互式终端应用 时,console.log 面临三个致命问题:
| 问题 | 表现 | 影响 |
|---|---|---|
| 闪烁 | 清屏重绘时屏幕闪白 | 用户体验极差 |
| 无组件化 | 每次手动拼字符串 | 维护困难,代码混乱 |
| 无交互 | 键盘事件需要手动处理 raw mode | 容易出 bug |
pi-tui 的定位:给终端应用提供类似前端 React 的开发体验------组件化 + 声明式渲染 + 事件处理。
类比理解
console.log → 手工画海报(每次推倒重来)
pi-tui → PPT 软件(改哪里刷哪里,组件可复用)
2. pi-tui 核心架构
架构图

三层架构
┌─────────────────────────────────────┐
│ 应用层 (你的代码) │
│ HelloComponent / SelectList / ... │
├─────────────────────────────────────┤
│ TUI 引擎层 │
│ 组件树管理 │ 差分渲染 │ 事件分发 │
├─────────────────────────────────────┤
│ 终端抽象层 │
│ ProcessTerminal │ VirtualTerminal │
└─────────────────────────────────────┘
核心设计思想:
- 终端抽象层把真实终端和测试用虚拟终端统一,组件不关心运行环境
- TUI 引擎层负责差分渲染和键盘事件分发,组件只需关注"画什么"
- 应用层 通过实现
Component接口或使用内置组件来构建界面
源码目录结构
packages/tui/src/
├── terminal/
│ ├── terminal.ts # 终端抽象接口
│ ├── process-terminal.ts # 真实终端(Node.js stdout)
│ └── virtual-terminal.ts # 虚拟终端(测试用)
│
├── components/
│ ├── text.ts # 纯文本
│ ├── input.ts # 输入框
│ ├── select-list.ts # 可选择列表
│ ├── markdown.ts # Markdown 渲染
│ ├── container.ts # 滚动容器
│ ├── loader.ts # 加载动画
│ ├── image.ts # 图片渲染
│ └── ...
│
├── overlay/ # 悬浮弹窗
├── autocomplete/ # 自动补全
└── index.ts # 公共导出
3. 差分渲染:不闪烁的秘密
这是 pi-tui 最核心的技术点。
传统方式 vs 差分渲染

传统方式(清屏重绘):
每次更新:
1. 清除整个屏幕 ← 闪烁!用户看到一瞬间空白
2. 重新绘制所有内容 ← 大量无用 IO
3. 重复...
pi-tui 差分渲染:
每次更新:
1. 组件的 render() 生成新的行数组
2. 引擎逐行对比新旧内容
3. 只有变化的行才移动光标并写入 ← 最小化 IO
4. 结果:无闪烁,丝滑更新
直观例子

5 行中只需写入 3 行,IO 量减少 40%。界面越大、变化越少,收益越明显。
4. 5 分钟上手:Hello TUI
对应
examples/03-pi-tui/01-hello-tui.ts
完整代码
typescript
import { TUI, ProcessTerminal, visibleWidth, truncateToWidth, type Component } from "@mariozechner/pi-tui";
// ① 实现 Component 接口
class HelloComponent implements Component {
private count = 0;
private startTime = Date.now();
// render(width) 返回每一行的字符串数组
render(width: number): string[] {
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
const innerWidth = width - 2; // 去掉左右边框
function boxLine(content: string): string {
const truncated = truncateToWidth(content, innerWidth);
const vw = visibleWidth(truncated);
const pad = Math.max(0, innerWidth - vw);
return `│${truncated}${" ".repeat(pad)}│`;
}
const sep = "─".repeat(Math.max(0, width - 2));
return [
`┌${sep}┐`,
boxLine(" Hello, pi-tui!"),
boxLine(` 运行时间: ${elapsed}s`),
boxLine(` 刷新次数: ${this.count}`),
`└${sep}┘`,
"",
" 操作: [Q / Ctrl+C] 退出 [R] 重置计数",
];
}
handleInput(data: string): void {
if (data === "r" || data === "R") {
this.count = 0;
this.startTime = Date.now();
}
}
invalidate(): void {}
tick(): void { this.count++; }
}
// ② 创建终端和 TUI
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
const hello = new HelloComponent();
tui.addChild(hello);
tui.setFocus(hello);
// ③ 启动
tui.start();
// ④ 退出键
tui.addInputListener((data: string) => {
if (data === "q" || data === "Q" || data === "\x03") {
clearInterval(timer);
tui.stop();
terminal.stop();
process.exit(0);
}
return undefined;
});
// ⑤ 定时刷新(200ms)
const timer = setInterval(() => {
hello.tick();
tui.requestRender();
}, 200);
运行效果

关键步骤拆解

| 步骤 | 代码 | 作用 |
|---|---|---|
| ① 实现 Component | class HelloComponent implements Component |
定义"画什么"和"怎么响应输入" |
| ② 组装 TUI | tui.addChild(hello) |
把组件挂到 TUI 组件树上 |
| ③ 启动 | tui.start() |
进入 raw mode,开始渲染循环 |
| ④ 退出键 | tui.addInputListener(...) |
全局键盘监听,按 Q 退出 |
| ⑤ 定时刷新 | setInterval + requestRender |
触发重渲染,差分引擎自动处理 |
Component 接口三要素
typescript
interface Component {
render(width: number): string[]; // 画什么?返回行数组
handleInput(data: string): void; // 怎么响应键盘?
invalidate(): void; // 通知需要重绘
}
这就是 pi-tui 的"最小契约"。实现这三个方法,你的类就是一个可渲染、可交互的终端组件。
5. 组件系统:像搭积木一样构建界面
pi-tui 的组件系统遵循组合优于继承的原则。
组件组合模型
TUI(根节点)
└── Container(垂直排列)
├── Text("标题")
├── SelectList([选项1, 选项2, ...])
├── Text("") ← 空行做间距
└── Text("状态信息")
通过 Container 容器将多个组件垂直排列 ,就像 HTML 里的 <div> 一样自然。
焦点管理
同一时刻只有一个组件接收键盘输入:
typescript
tui.setFocus(list); // 方向键和回车都给 list 处理
未获得焦点的组件只负责渲染,不响应按键。
6. 内置组件一览
| 组件 | 用途 | 关键 API |
|---|---|---|
| Text | 显示纯文本 | new Text(text, paddingX, paddingY) / setText() |
| SelectList | 可上下选择的列表 | new SelectList(items, maxVisible, theme) / onSelect |
| Container | 垂直排列子组件 | addChild() |
| Input | 单行输入框 | onSubmit / onChange |
| Editor | 多行编辑器 | 代码编辑场景 |
| Markdown | Markdown 渲染 | 自动语法高亮 |
| Loader | 加载动画 | start() / stop() |
| Box | 带边框容器 | title / children |
| Image | 终端图片渲染 | Kitty / iTerm2 协议 |
| Overlay | 悬浮弹窗 | 模型选择等弹出场景 |
7. 实战:用 SelectList 构建交互菜单
对应
examples/03-pi-tui/02-list-component.ts
这个示例展示如何用内置组件快速搭建一个 AI Provider 选择菜单。
完整代码
typescript
import {
TUI, ProcessTerminal, SelectList, Text, Container,
type SelectItem, type SelectListTheme,
} from "@mariozechner/pi-tui";
// ① 列表数据
const items: SelectItem[] = [
{ label: "Anthropic Claude", value: "anthropic", description: "Claude Haiku / Sonnet / Opus" },
{ label: "Google Gemini", value: "google", description: "Gemini 2.0 Flash / Pro" },
{ label: "OpenAI GPT", value: "openai", description: "GPT-4o / GPT-4o Mini" },
{ label: "MiniMax", value: "minimax-cn", description: "MiniMax-M2 / M2.5" },
{ label: "DeepSeek", value: "deepseek", description: "DeepSeek-V3 / R1" },
];
// ② 主题(控制颜色样式)
const listTheme: SelectListTheme = {
selectedPrefix: (t) => t,
selectedText: (t) => `\x1b[32m${t}\x1b[0m`, // 绿色
description: (t) => `\x1b[90m${t}\x1b[0m`, // 灰色
scrollInfo: (t) => `\x1b[90m${t}\x1b[0m`,
noMatch: (t) => `\x1b[31m${t}\x1b[0m`, // 红色
};
// ③ 组合组件
const title = new Text(" 选择你的 AI Provider:", 0, 0);
const status = new Text(" 尚未选择,使用 ↑↓ 选择,Enter 确认,Q 退出", 0, 0);
const list = new SelectList(items, 8, listTheme);
const container = new Container();
container.addChild(title);
container.addChild(list);
container.addChild(new Text("", 0, 0)); // 空行
container.addChild(status);
// ④ 启动 TUI
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
tui.addChild(container);
tui.setFocus(list); // 焦点给列表,接收方向键
tui.start();
// ⑤ 选择事件
list.onSelect = (item: SelectItem) => {
status.setText(` 已选择: ${item.label} (${item.value}) --- 1 秒后退出`);
tui.requestRender();
setTimeout(() => {
tui.stop();
terminal.stop();
console.log(`\n你选择了: ${item.label} (${item.value})`);
process.exit(0);
}, 1000);
};
// ⑥ 全局退出键
tui.addInputListener((data: string) => {
if (data === "q" || data === "Q" || data === "\x03") {
tui.stop();
terminal.stop();
process.exit(0);
}
return undefined;
});
运行效果

自定义组件 vs 内置组件对比
| 对比项 | 01-hello-tui(自定义组件) | 02-list-component(内置组件) |
|---|---|---|
| 组件来源 | 自己实现 Component 接口 |
直接使用 SelectList / Text |
| 代码量 | ~70 行(含 render 逻辑) | ~50 行(声明式组合) |
| 键盘处理 | 手动在 handleInput 中判断 |
SelectList 内置方向键和回车 |
| 适用场景 | 完全定制化需求 | 标准交互模式 |
经验法则:能用内置组件就用内置组件,需要独特渲染逻辑时再自定义。
8. 中文支持:宽字符处理
终端中一个中文字符占 2 列宽度,而英文/数字只占 1 列。如果不处理这个差异,界面会对不齐。
pi-tui 提供两个关键工具函数:
typescript
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
visibleWidth("Hello"); // → 5(英文 5 字符 = 5 列)
visibleWidth("你好"); // → 4(中文 2 字符 = 4 列)
visibleWidth("Hi你好"); // → 6(2 + 4 = 6 列)
// 安全截断,不会切断一半汉字
truncateToWidth("你好世界", 5); // → "你好"(4 列,5 列放不下"世")
在 render 中使用
typescript
render(width: number): string[] {
const innerWidth = width - 2; // 减去边框
function boxLine(content: string): string {
const truncated = truncateToWidth(content, innerWidth); // 安全截断
const vw = visibleWidth(truncated); // 实际宽度
const pad = Math.max(0, innerWidth - vw); // 补空格
return `│${truncated}${" ".repeat(pad)}│`; // 对齐边框
}
return [boxLine(" 运行时间: 3.2s"), boxLine(" 你好世界!")];
}
规则 :render(width) 生成的每一行,其 visibleWidth 必须 <= width,否则终端会自动换行导致布局混乱。
9. 总结与下一步
pi-tui 的核心价值
| 能力 | 说明 |
|---|---|
| 差分渲染 | 只更新变化的行,终端 UI 不再闪烁 |
| 组件化 | Text / SelectList / Input 等开箱即用 |
| 组合式布局 | Container 垂直排列,像搭积木 |
| 键盘交互 | 焦点管理 + 全局监听,处理优雅 |
| 宽字符安全 | visibleWidth / truncateToWidth 解决中文对齐 |
| 可测试 | VirtualTerminal 让 TUI 可以无头测试 |
一句话记住
console.log 是往终端"倒文字"。
pi-tui 是在终端里"画界面"------有组件、有布局、有交互,而且不闪。
下一步
有了 pi-ai(LLM 调用)+ pi-agent-core(智能体运行时)+ pi-tui(终端 UI),下一篇我们将把它们组合起来,看看 pi-coding-agent 如何构建一个完整的交互式终端编程助手。