pi-tui:差分渲染
从上游 vendored 而来的终端 UI 库:三种渲染策略、CSI 2026 同步输出、主屏与全屏两套渲染器、Terminal 抽象与粘贴处理。
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 的归纳:
1. First Render 输出所有行,不清除 scrollback
2. Width Changed or
Change Above Viewport 清屏 + 全量重绘
3. Normal Update 光标移到首个变化行,逐行清除并重绘变化的行实现在 src/tui-main-screen.ts 的 doRender()(:319):
// 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):
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(节选):
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):
if (pastedLines.length > 10 || totalChars > 1000) {超过 10 行或 1000 字符的粘贴显示为 [paste #1 +123 lines] 这样的标记,发送时再换回原文。paste-burst.ts 管的是另一种情况:终端不发括号粘贴标记时,连续至少 8 个字符、间隔都在 8ms 以内即判定为粘贴,随后短时间内的回车插入换行而不是提交。
图形与色彩
terminal-image.ts(657 行)——支持 Kitty 与 iTerm2 图形协议,在终端里直接显示图片。这对一个能处理图片与视频输入的 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 应用层。