第四阶段:TUI -- 终端用户界面
对应源文件:
packages/tui/src/
概述
pi-tui 是一个从零构建的终端 UI 框架,不依赖 blessed、ink 等第三方库。它为 pi 提供了交互式终端界面,包括:
- 差异化渲染(只更新变化的行)
- 组件系统(Component 接口)
- 覆盖层系统(Overlay,用于对话框)
- 键盘输入处理(含 Kitty 协议支持)
- 自动补全
- 终端内图片显示(iTerm2/Kitty 协议)
核心概念
Component 接口
所有 UI 元素必须实现 Component 接口:
interface Component {
render(width: number): string[]; // 渲染为文本行数组
handleInput?(data: string): void; // 可选:处理键盘输入
invalidate(): void; // 清除缓存,强制重绘
}
这是一个极简的设计:
render()接收终端宽度,返回一个字符串数组(每个元素 = 一行)- 字符串包含 ANSI 转义序列(颜色、加粗等)
- 没有虚拟 DOM、没有状态树 -- 就是字符串
Container
Container 是最基本的容器组件,按顺序排列子组件:
class Container implements Component {
children: Component[] = [];
addChild(component: Component): void;
removeChild(component: Component): void;
render(width: number): string[] {
// 依次渲染每个子组件,合并行数组
return this.children.flatMap(child => child.render(width));
}
}
TUI 类
TUI 继承自 Container,是整个终端 UI 的根节点。它增加了:
| 功能 | 说明 |
|---|---|
| 差异化渲染 | 比较上一帧和当前帧,只重写变化的行 |
| 焦点管理 | 跟踪哪个组件接收键盘输入 |
| Overlay 系统 | 在主内容上显示模态对话框 |
| 硬件光标 | 通过 CURSOR_MARKER 定位光标(用于 IME 输入法) |
| 节流渲染 | 最小 16ms 间隔,避免过度刷新 |
渲染循环
组件状态变化 → requestRender()
↓ 节流(≥16ms 间隔)
scheduleRender()
↓
doRender()
1. 渲染所有子组件 → 得到 string[]
2. 渲染所有可见 Overlay → 合成到 string[]
3. 与上一帧比较
4. 只重写变化的行(光标定位 + 清除 + 写入)
5. 处理硬件光标位置(CURSOR_MARKER)
差异化渲染的关键:光标在终端中是有"位置"的,通过 ANSI 转义序列移动光标到变化的行,然后用清行+写入替换内容。这避免了全屏重绘的闪烁问题。
Overlay 系统
Overlay 是一个浮动在主内容上方的组件,用于:
- 选择对话框(
/model、/theme) - 确认对话框(bash 权限确认)
- 文本输入对话框
- 扩展的自定义 UI
// 显示 overlay
const handle = tui.showOverlay(component, {
width: "80%", // 宽度为终端宽度的 80%
maxHeight: "50%", // 最高占终端的一半
anchor: "center", // 居中
margin: 2, // 距边缘 2 行/列
});
// 控制 overlay
handle.setHidden(true); // 临时隐藏
handle.focus(); // 获取焦点
handle.hide(); // 永久关闭
Overlay 合成的原理:在主内容的 string[] 之上,按 row/col 位置"覆盖"overlay 的内容。实际上是字符串级别的拼接操作(按列裁剪、替换),而不是真正的图层叠加。
键盘输入处理
keys.ts(44KB)处理各种终端的键盘输入编码:
原始输入 → parseKey() → KeyId 标准化
键盘输入在不同终端中有不同编码:
- 普通终端:
\x1b[A(上箭头) - Kitty 协议:
\x1b[1;1A或 CSI u 序列 - xterm modifyOtherKeys:另一套编码
keys.ts 把所有这些统一为 KeyId(如 "up", "ctrl+c", "shift+enter")。
matchesKey() 函数用于在组件中检查按键:
handleInput(data: string) {
if (matchesKey(data, "ctrl+c")) {
this.abort();
} else if (matchesKey(data, "enter")) {
this.submit();
}
}
自动补全
autocomplete.ts 实现了多来源的自动补全:
输入 "/" → 触发命令补全
输入 "@" → 触发文件路径补全
输入 "model:" → 触发模型名补全
自动补全系统是可扩展的,扩展可以通过 ctx.ui.addAutocompleteProvider() 添加自己的补全源。
终端图片
terminal-image.ts 支持在终端中显示图片,使用两种协议:
- iTerm2 内联图片协议:base64 编码图片嵌入 OSC 序列
- Kitty 图形协议:更高级的图片传输
自动检测终端能力,回退到文字描述。
与 coding-agent 的集成
coding-agent 的 InteractiveMode 使用 TUI 组织界面布局:
┌───────────────────────────────────────────────┐
│ Header(模型信息、version) │
├───────────────────────────────────────────────┤
│ Chat 区域 │
│ user: 帮我读取 main.ts │
│ assistant: 好的,让我读取这个文件... │
│ ┌─ read main.ts ──────────────────────────┐ │
│ │ 1 import { readFile } from "fs"; │ │
│ │ 2 ... │ │
│ └─────────────────────────────────────────┘ │
│ assistant: 这个文件是... │
├───────────────────────────────────────────────┤
│ Widget 区域(扩展自定义) │
├───────────────────────────────────────────────┤
│ Editor(多行输入框 + 自动补全) │
├───────────────────────────────────────────────┤
│ Footer(模型名 | token 数 | 快捷键 | 状态) │
└───────────────────────────────────────────────┘
小结
TUI 包
├── 核心
│ ├── Component 接口 → render(width) → string[]
│ ├── Container → 垂直排列子组件
│ └── TUI 类 → 差异化渲染 + 焦点管理
│
├── Overlay 系统
│ ├── 浮动对话框
│ ├── 可配置位置和大小
│ └── 焦点栈管理
│
├── 键盘处理
│ ├── 多终端协议支持
│ ├── matchesKey() 统一检查
│ └── 可配置快捷键
│
├── 其他功能
│ ├── 自动补全(多来源)
│ ├── 终端图片(iTerm2/Kitty)
│ └── Undo/Redo 栈
│
└── 设计特点
├── 零依赖(不用 blessed/ink)
├── 极简接口(Component 只有 2 个方法)
└── 增量渲染(只更新变化的行)