# pi-tui：差分渲染

> 从上游 vendored 而来的终端 UI 库：三种渲染策略、CSI 2026 同步输出、主屏与全屏两套渲染器、Terminal 抽象与粘贴处理。

- 作者：David（道雾轩）
- 专栏：Kimi Code 源码解读（https://daiw.org/manual/kimi-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/kimi-code/pi-tui
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`packages/pi-tui` 是 Kimi Code 的终端 UI 框架——不用 Ink，不用 React，约 1.7 万行（v0.29.1 时约 1.25 万行）。README 的定位：

> Minimal terminal UI framework with **differential rendering** and **synchronized output** for flicker-free interactive CLI applications.

需要澄清一点：它并非从零自研。包内 `AGENTS.md` 写明这是上游 pi-mono 项目里 pi-tui 的 **vendored 副本**（基线为上游 0.80.2），本地修改直接改源码，并列出 8 处每次同步都必须保留的本地分歧——CJK 窄宽度下的递归保护、超宽行截断而非抛错、逐帧复用已处理的行等，每一处都有测试守着。

## 三种渲染策略

README 的归纳：

```text
1. First Render         输出所有行，不清除 scrollback
2. Width Changed or
   Change Above Viewport  清屏 + 全量重绘
3. Normal Update        光标移到首个变化行，逐行清除并重绘变化的行
```

实现在 `src/tui-main-screen.ts` 的 `doRender()`（`:319`）：

```ts
// First render - just output everything without clearing (assumes clean screen)
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
	logRedraw("first render");
	fullRender(false);
	return;
}

// Width changes always need a full re-render because wrapping changes.
if (widthChanged) {
	logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
	fullRender(true);
	return;
}
```

策略 1 的「不清除 scrollback」很重要：CLI 启动时若清屏，用户之前的终端历史就没了。策略 3 是常态路径：逐行比较新旧两帧的字符串，找出首个与末个变化行，只重绘这一段——不是虚拟 DOM 那种对象树 diff，而是**按行比对文本**，终端本来就是行式设备。实际触发全量重绘的条件比 README 多：宽度变了、高度变了（Termux 弹出软键盘除外）、内容缩短（可配置）、首个变化行落在视口上方（相对光标移动够不着）。

本地分歧之一让常态路径更省：一行的原始字符串若与上一帧**引用相同**，就直接复用上一帧处理好的输出，稳态帧只需做 O(行数) 的指针比较。

## CSI 2026 同步输出

同一文件的 `fullRender`（`:265`）：

```ts
const fullRender = (clear: boolean): void => {
	this.fullRedrawCount += 1;
	let buffer = "\x1b[?2026h"; // Begin synchronized output
	if (clear) {
		buffer += this.deleteKittyImages(this.previousKittyImageIds);
		buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
	}
```

`\x1b[?2026h` / `\x1b[?2026l` 是**同步输出**转义序列（DEC private mode 2026）：告诉终端「这中间的内容先别画，等我说结束再一次性刷新」。不用它，多行更新会被逐段渲染，出现撕裂与闪烁。增量路径同样如此：所有更新先拼进一个 `buffer` 字符串，最后一次 `terminal.write`——多次小 write 有系统调用开销，也可能被其他输出插入。

## 主屏与全屏两套渲染器

这是 v0.29 之后最大的结构变化：原来的 `TUI` 类拆成了 `TUI` 接口加两个实现。

| 类 | `mode` | 特点 |
| --- | --- | --- |
| `TuiMainScreen` | `regular` | 在主屏上差分渲染，保留终端 scrollback |
| `TuiAltScreen` | `fullscreen` | 切到备用屏自管视口：`ScrollView` 滚动、鼠标选择、滚动条、文本搜索 |

配套新增了 `VStack`、`HStack`、`ScrollView` 布局组件与 `layout.ts`。kimi-code 默认用主屏渲染器，设 `KIMI_CODE_TUI_FULL_SCREEN=1` 才启用实验性的全屏模式（v0.36.0 引入）。

## Terminal 接口

`src/terminal.ts:60`（节选）：

```ts
export interface Terminal {
	// Start the terminal with input and resize handlers
	start(onInput: (data: string) => void, onResize: () => void): void;

	// Stop the terminal and restore state
	stop(): void;
	// ...
	// Write output to terminal
	write(data: string): void;

	// Get terminal dimensions
	get columns(): number;
	get rows(): number;
	// ...
}
```

完整接口还有光标移动与显隐、清行清屏、`drainInput()`（退出前排空 stdin，防止 Kitty 按键释放事件漏到父 shell）、`setTitle()` 与 `setProgress()`（OSC 9;4 进度指示）。渲染器只依赖这个接口，`ProcessTerminal` 是基于 `process.stdin/stdout` 的实现；测试用 `test/virtual-terminal.ts`（基于 `@xterm/headless`）断言屏幕内容。**测试无需真终端**——1.7 万行的 TUI 库能被有效测试，靠的就是这一层。

## 组件

内置组件：`Text`、`TruncatedText`、`Input`、`Editor`、`Markdown`、`Loader`、`CancellableLoader`、`SelectList`、`SettingsList`、`Spacer`、`Image`、`Box`、`Container`，以及新增的 `VStack`、`HStack`、`ScrollView`。

组件接口极简（`src/tui.ts:23`）：必须实现的只有 `render(width)`（返回若干行字符串）与 `invalidate()`，`handleInput()` 可选。没有生命周期、没有 hooks、没有响应式系统——终端 UI 的更新频率与复杂度都远低于 Web，引入框架反而是负担。

`components/editor.ts` 2527 行，仍是全包最大文件——多行编辑器要处理光标移动、选择、撤销、自动补全、粘贴、软换行、视口滚动。`markdown.ts`（1102 行）现在还会调用新增的 `latex.ts`（1373 行），把公式渲染成 Unicode 文本。

## 输入处理

| 文件 | 解决什么 |
| --- | --- |
| `keys.ts`（1401 行） | 按键序列解析：Kitty 键盘协议、CSI u、xterm modifyOtherKeys |
| `keybindings.ts` | 可配置的键位绑定 |
| `stdin-buffer.ts`（444 行） | 把被拆成多次 data 事件的转义序列拼回完整 |
| `paste-burst.ts` | 终端不发粘贴标记时的**粘贴突发**识别 |
| `native-modifiers.ts` | 经原生辅助模块查询修饰键状态 |
| `word-navigation.ts` | 按词移动 |
| `kill-ring.ts` | **Emacs 风格的 kill ring**（kill/yank） |
| `undo-stack.ts` | 撤销栈 |

`keys.ts` 的体量说明了终端输入解析的真实复杂度：不同终端模拟器与 tmux 对同一个组合键发出的字节序列不同，还要兼容多套扩展协议。

**Bracketed Paste Mode**：`ProcessTerminal` 启动时发 `\x1b[?2004h`，终端把粘贴内容包在 `\x1b[200~` 与 `\x1b[201~` 之间，避免每一行被当作回车提交。编辑器还会把大段粘贴折叠成占位标记（`editor.ts:1343`）：

```ts
if (pastedLines.length > 10 || totalChars > 1000) {
```

超过 10 行或 1000 字符的粘贴显示为 `[paste #1 +123 lines]` 这样的标记，发送时再换回原文。`paste-burst.ts` 管的是另一种情况：终端不发括号粘贴标记时，连续至少 8 个字符、间隔都在 8ms 以内即判定为粘贴，随后短时间内的回车插入换行而不是提交。

## 图形与色彩

- **`terminal-image.ts`**（657 行）——支持 **Kitty** 与 **iTerm2** 图形协议，在终端里直接显示图片。这对一个能处理[图片与视频输入](https://daiw.org/manual/kimi-code/file-tools)的 agent 是必需的。
- **`terminal-colors.ts`**——解析终端对 OSC 11（背景色）与配色方案查询的回报，kimi-code 据此自动选深色或浅色主题。
- **主题**：组件接受 theme 接口，配合内置 skill `custom-theme` 让用户能对话式定制。

## 为什么选它

| 方案 | 问题 |
| --- | --- |
| Ink（React for CLI） | 引入 React 运行时与 reconciler，启动慢、内存大 |
| blessed | 老旧，维护不活跃 |
| 直接写转义序列 | 没有组件复用与差分渲染 |

README 里 Kimi Code 的卖点之一：

> **Blazing-fast startup.** The TUI is ready in milliseconds, so starting a session never feels heavy.

启动速度是 CLI 工具的核心体验——每天要开几十次的东西，多 300ms 都很烦人。选一个只做必要事情的库，再按自己的需要直接改源码，换来毫秒级启动，这笔账是划算的。`pi-tui` 的运行时依赖只有 `get-east-asian-width` 与 `marked`，**不依赖仓库里任何其他包**，可以被单独取用。

下一篇：[TUI 应用层](https://daiw.org/manual/kimi-code/tui-app)。
