06 — 终端 UI (pi-tui)

问题

在终端中渲染一个交互式 Agent 界面面临几个挑战:

  • 终端是顺序输出的设备,如何实现”局部更新”(只刷新变化的部分)?
  • 如何处理复杂的键盘输入(组合键、修饰键、按键释放)?
  • 如何渲染 Markdown、代码高亮、diff、图片?
  • 如何让 UI 组件可组合且高效?

pi-tui 是一个独立的终端 UI 库,解决了这些问题。

渲染架构

差分渲染

pi-tui 的核心渲染策略是差分渲染(differential rendering):

1
2
3
4
5
6
7
8
9
10
Frame N (当前屏幕)     Frame N+1 (新屏幕)
┌──────────────┐ ┌──────────────┐
│ Hello World │ │ Hello Pi │
│ > _ │ │ > typing_ │
└──────────────┘ └──────────────┘

▼ 比较两帧
只输出变化的单元格
\033[1;7H Pi ← 移动光标到行1列7,输出 "Pi"
\033[2;4Htyping_ ← 移动光标到行2列4,输出 "typing_"

为什么不直接清屏重绘:终端清屏会闪烁,且大量输出消耗带宽。差分渲染只发送变化的字符,体验流畅。

Component 接口

每个 UI 组件实现 Component 接口:

1
2
3
4
interface Component {
render(): Line[]; // 返回行数组
// ... 生命周期、焦点、事件处理方法
}

render() 返回的 Line 是一行终端文本(包含内容和样式信息)。渲染引擎比较前后帧的 Line[],计算差异。

Container 组合

Container 是组合组件——它持有子组件列表,递归调用 render(),将子组件的输出按布局排列。

1
2
3
4
5
6
7
Container (垂直布局)
├─ HeaderComponent → render() → Line[]
├─ ScrollView → render() → Line[]
│ ├─ AssistantMessage → render() → Line[]
│ ├─ ToolExecution → render() → Line[]
│ └─ Editor → render() → Line[]
└─ FooterComponent → render() → Line[]

两种屏幕模式

pi-tui 提供两种屏幕实现:

TuiMainScreen — Inline 模式

packages/tui/src/tui-main-screen.ts(~24K)

与 shell 共享终端——Agent 输出在 shell 的正常输出流中。适合”对话式”交互:用户输入 prompt,Agent 回复,输出追加到终端历史。

特点:

  • 不使用 alternate screen buffer
  • 输出追加到终端滚动历史
  • 适合 pi -p 或简单交互

TuiAltScreen — 全屏模式

packages/tui/src/tui-alt-screen.ts(~62K)

使用终端的 alternate screen buffer——全屏接管,退出后恢复原屏幕。适合”应用式”交互:有固定布局(header、聊天区、编辑器、footer)。

特点:

  • 使用 alternate screen buffer
  • 固定布局,内容在区域内滚动
  • 退出后恢复原终端
  • 适合 pi 交互模式

组件模型

packages/tui/src/components/(18 个组件文件)

组件 文件 说明
Editor editor.ts(84K) 全功能文本编辑器:undo/redo、kill ring、词导航
Markdown markdown.ts(33K) Markdown 渲染 + 语法高亮
ScrollView scroll-view.ts 可滚动视图区域
Input input.ts 单行输入
SelectList select-list.ts 选择列表
Box box.ts 带边框容器
Text text.ts 纯文本
TruncatedText truncated-text.ts 截断文本
HStack/VStack 水平/垂直堆叠
Image image.ts 终端图片显示
Loader loader.ts 加载动画

Editor 组件

packages/tui/src/components/editor.ts(84K)— 仓库中最大的 TUI 文件:

  • Undo/Redo:完整的编辑历史栈
  • Kill Ring:Emacs 风格的剪切板环(多次 yank 循环粘贴)
  • 词导航:向前/向后跳词、跳到行首/行尾
  • 多行支持:光标跨行移动、选择、删除

这是 Pi 交互模式中用户输入 prompt 的编辑器——支持多行编辑、光标移动、历史导航。

Markdown 渲染

packages/tui/src/components/markdown.ts(33K):

  • 解析 Markdown AST
  • 语法高亮(代码块)
  • 终端颜色映射
  • 自适应宽度换行

输入处理

键盘解析

packages/tui/src/keys.ts(~45K):

支持 Kitty keyboard protocol——现代终端的增强键盘协议:

  • 修饰键组合(Ctrl+Shift+Alt+ 任意键)
  • 按键释放事件
  • 按键重复检测
  • 特殊键(F1-F12、方向键、Home/End/Page Up/Down)

旧版终端使用转义序列解析(\x1b[A = 上箭头等)。

输入缓冲

packages/tui/src/stdin-buffer.ts(~12K):

终端输入可能以不完整的块到达(特别是转义序列可能跨 chunk)。stdin-buffer 负责缓冲和分割输入,确保完整的按键事件被正确解析。

快捷键系统

packages/tui/src/keybindings.ts(~10K):

KeybindingsManager + TUI_KEYBINDINGS 管理快捷键映射。支持可配置的快捷键绑定,与 coding-agentKeybindingsManager 集成。

终端能力

packages/tui/src/terminal.ts(~19K)

Terminal 接口抽象终端能力:

1
2
3
4
5
6
7
8
9
10
11
12
interface Terminal {
// 原始模式(不缓冲、不回显)
setRawMode(enable: boolean): void;
// 光标控制
cursorTo(x, y): void;
cursorHide(): void;
cursorShow(): void;
// 颜色和样式
write(data: string): void;
// 尺寸
get size(): { width: number; height: number };
}

ProcessTerminal 是基于 Node.js process.stdout/process.stdin 的实现。

终端图片

packages/tui/src/terminal-image.ts(~22K):

支持三种终端图片协议:

  • Kitty graphics protocol:现代终端的图片渲染协议
  • iTerm2 inline images:iTerm2 的 base64 图片协议
  • Sixel:老式图形协议(检测支持)

用户可以粘贴图片到 Pi 的输入中,图片会通过这些协议渲染。

LaTeX 渲染

packages/tui/src/latex.ts(~33K):将 LaTeX 公式渲染为终端可显示的形式。

工具函数

packages/tui/src/utils.ts(~37K):

  • visibleWidth():计算字符串的可见宽度(排除 ANSI 转义码)
  • ANSI 处理:解析、生成、过滤 ANSI 转义序列
  • 文本换行:按可见宽度换行,保留 ANSI 样式

packages/tui/src/fuzzy.ts:模糊匹配,用于自动补全。

packages/tui/src/autocomplete.ts(~24K):AutocompleteProviderCombinedAutocompleteProvider——用于 slash 命令、文件路径、模型名称等的自动补全。

布局引擎

packages/tui/src/layout.ts(~16K):

Box model 布局引擎,计算每个组件的位置和大小:

  • 固定尺寸(width/height
  • 弹性尺寸(flex
  • 边距和填充(padding/margin
  • 对齐方式

与 Agent 的连接

pi-tui 是纯渲染库——它不知道 Agent 的存在。连接在 coding-agentInteractiveMode 中建立:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
AgentSession (coding-agent)

│ subscribe(eventHandler)


InteractiveMode (coding-agent)

├─ 事件 → TUI 组件更新
│ message_start → 创建 AssistantMessage 组件
│ message_update → 更新 Markdown 内容
│ tool_execution_start → 创建 ToolExecution 组件
│ tool_execution_end → 更新工具状态
│ turn_end → 触发重新渲染

├─ 用户输入 → AgentSession.prompt()
│ Editor 组件 → 获取文本 → prompt()

└─ TUI 渲染循环
Container.render() → Line[] → 差分渲染 → 终端输出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
┌─ TuiMainScreen / TuiAltScreen ──────────────────────┐
│ │
│ ┌─ Container (垂直) ──────────────────────────────┐ │
│ │ │ │
│ │ ┌─ Header ─────────────────────────────────┐ │ │
│ │ │ pi · claude-sonnet-4 · session #3 │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─ ScrollView (聊天历史) ─────────────────┐ │ │
│ │ │ │ │ │
│ │ │ User: 读取 README.md 并总结 │ │ │
│ │ │ │ │ │
│ │ │ ┌─ AssistantMessage ────────────────┐ │ │ │
│ │ │ │ ## README.md 总结 │ │ │ │
│ │ │ │ Pi 是一个 Agent harness 项目... │ │ │ │
│ │ │ └────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─ ToolExecution ───────────────────┐ │ │ │
│ │ │ │ ✓ read("README.md") │ │ │ │
│ │ │ └────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─ Editor (输入区) ───────────────────────┐ │ │
│ │ │ > _ │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─ Footer ────────────────────────────────┐ │ │
│ │ │ Ctrl+O: 模型 Ctrl+S: 会话 Esc: 中断 │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘

学习要点

  • 差分渲染是终端 UI 的核心优化:比较前后帧,只输出变化的字符,避免闪烁和带宽浪费
  • Component 接口的 render() → Line[] 设计:简单的返回值让差分比较容易实现,组件可自由组合
  • 两种屏幕模式对应不同交互模式:Inline(对话式)vs AltScreen(应用式),选择取决于产品定位
  • 键盘输入处理远比想象复杂:转义序列跨 chunk、修饰键组合、Kitty protocol、按键释放——需要专门的解析器和输入缓冲
  • TUI 与 Agent 完全解耦pi-tui 不知道 Agent 存在,连接在 InteractiveMode 中通过事件订阅建立。这让 TUI 库可以独立复用

上一篇:05-编码 Agent-pi-coding-agent
下一篇:07-分布式与可观测性