第四阶段: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 个方法)
    └── 增量渲染(只更新变化的行)