pi-tui:差分渲染

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

作者 David更新于 第 24 篇(共 33 篇)

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特点
TuiMainScreenregular在主屏上差分渲染,保留终端 scrollback
TuiAltScreenfullscreen切到备用屏自管视口: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.tsEmacs 风格的 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 应用层。

本页目录