TUI 引擎

产品界面底下的渲染层是 pi-tui 0.84.2 的一份 fork:逐文件哈希与本地改动台账、主屏差量重绘与全屏视口、输入解析与宽字符,以及它和 third_party 里那份 pi-tui 的关系。

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

packages/tui/src/tui 有 258 个源文件、72134 行(口径同全书),占整个 packages/tui 的近七成。这一篇只看最底下一层:把组件渲染成一行行字符串、再用尽量少的字节写进终端的引擎,以及紧贴它的渲染器切换、平台探测与主题。控制器、转录区、斜杠命令与快捷键留给下一篇。

先说结论:这层引擎来自 pi 的 pi-tui,但不是 third_party/pi-mono 里那一份。

目录分工

目录文件 / 行数分工本书位置
engine41 / 17742pi-tui 的 fork:渲染器、终端、按键、布局、组件本篇
renderer2 / 328创建主屏或全屏渲染器,运行中切换本篇
rendering5 / 72对引擎文本工具的薄封装:组件释放、控制字符清洗、URL 脱敏、时长格式本篇
foundation1 / 149带边框的表格组件本篇
platform7 / 828终端能力探测、进程守护、终端通知、剪贴板、停止原因记录本篇
theme9 / 1098深浅色探测、调色板、语法与 shell 命令高亮本篇
controller50 / 18340各类 flow 与转录投影下一篇
features58 / 15580各个面板:会话、模型、权限、问卷、更新……下一篇
shell27 / 5452布局、输入框、状态栏、快捷键注册表下一篇
transcript21 / 4891转录单元的存储、投影与绘制下一篇
commands、widgets、state5 / 1192、7 / 1175、7 / 784斜杠命令目录、组件包装、reducer 式状态下一篇
interaction、automation、todo、dev2 / 225、3 / 387、1 / 6、1 / 81交互模型、自动化结果、Todo 类型、开发用的工具卡片画廊下一篇
agent-team、background-work3 / 873、1 / 388团队与后台任务面板子 Agent 与后台任务

根目录另有 7 个文件、2543 行:launcher.ts(进程级启动)、app.ts 与 app-composition.ts(组合根)等。

怎么用

  • 两种模式:regular 渲染在终端主屏,保留原生回滚、选择与复制;fullscreen 切到备用屏,在应用内滚动。两句说明的原文在 packages/tui/src/tui/features/settings/picker.ts:12。启动时用 --tui-mode <mode> 指定(packages/tui/src/cli/contract.ts:40);不指定就读数据目录下 tui 子目录里 tui-settings.json 的 tuiMode,读不到默认 regular(packages/tui/src/host/tui-settings.ts:12)。运行中在 /settings 里切换,选定后写回同一文件。
  • 全屏专属键:Ctrl+Shift+F 搜索当前滚动区,Enter 与 Shift+Enter 在匹配间前后跳;Ctrl+Shift+Up/Down 跳到上一条、下一条消息,产品另加了不带 Shift 的 Ctrl+Up/Down(packages/tui/src/tui/shell/keybindings.ts:535)。鼠标拖选默认松手即复制(copyOnSelect 缺省为真,packages/tui/src/tui/engine/tui-alt-screen.ts:255),点击链接用系统程序打开,右键粘贴。
  • 沿用 pi 名字的环境变量:PI_HARDWARE_CURSOR 设为 1 显示硬件光标,PI_CLEAR_ON_SHRINK 设为 1 在内容缩短时清掉空行(packages/tui/src/tui/engine/tui.ts:347),PI_DEBUG_REDRAW 设为 1 把每次整屏重绘的原因写进 pi-debug.log;PI_HYPERLINKS、PI_IMAGE_PROTOCOL、PI_TRUE_COLOR 强制指定超链接、图片协议与真彩色能力(packages/tui/src/tui/engine/terminal-image.ts:144)。
  • 颜色:设了 NO_COLOR,或 FORCE_COLOR 为 0,就不输出颜色;色深依次看 FORCE_COLOR、终端报告的色深、COLORTERM、TERM(packages/tui/src/tui/platform/terminal-capabilities.ts:68)。深浅主题自动探测,代码里没有找到手动切换的开关。

日志目录有个容易忽略的细节:引擎的 pi-debug.log、pi-crash.log 写在构造时传入的目录,缺省是 PI_CODING_AGENT_DIR 或 ~/.pi/agent(engine/tui.ts:369)。产品会把 runtimeLogDirectory 传给渲染器(packages/tui/src/tui/app-composition.ts:138),但 launcher.ts 创建应用时没有给这个选项,所以从代码看,这两个文件实际落在 ~/.pi/agent 下,而不是 MCode 的数据目录。

两份 pi-tui

图表加载中…

third_party/pi-mono/packages/tui 是 v0.79.1(third_party/pi-mono/packages/tui/package.json:3),它留在工作区只因为 pi-coding-agent 依赖它(third_party/pi-mono/packages/coding-agent/package.json:28);packages/tui 的依赖表里没有 @earendil-works/pi-tui,第一方代码也没有 import 它(统计见内置的 pi-mono)。产品界面用的是引擎目录里那一份,UPSTREAM.md 记着它的来历(packages/tui/src/tui/engine/UPSTREAM.md:3):上游 earendil-works/pi 的 packages/tui/src,commit 836aee6d38f60428ab6bd2679f93dce43a55dab3,包版本 0.84.2,2026-08-18 导入,次日确立为 fork。同一文件把它定位成 MCode 自有的源码:

Pi is provenance and a future selective-sync input, not a runtime boundary.

几条可以核对的约束:

  • 逐文件哈希:BASELINE.json 记下 39 个上游文件导入前后的 SHA-256 与整棵树的摘要,状态标为 exact_node_next_adaptation(packages/tui/src/tui/engine/BASELINE.json:3);导入时唯一的改动,是把相对导入里的 .ts 后缀改写成 .js 以适配 NodeNext(BASELINE.json:14)。拿当前文件对照这份清单和 LOCAL_CHANGES.json 里的 currentSha256:16 个文件仍与导入时逐字节相同,23 个带有登记过的改动,另有 native-module-path.ts、public.ts 两个纯本地文件,全部对得上。
  • 唯一入口:产品代码只许 import public.ts(UPSTREAM.md:22)。在 packages/tui/src 里搜一遍,引擎目录以外对引擎的 import 全部指向 engine/public.js,没有例外。public.ts 本身只是在 index.ts 之外再导出几个按键、布局与文本工具(packages/tui/src/tui/engine/public.ts:5)。
  • 改动台账:LOCAL_CHANGES.md 按 L 编号逐条记改动,并要求以后的每一条写清 pi 满足不了的产品契约、最小改动、用户可见的影响、证据,以及在什么条件下可以删掉(packages/tui/src/tui/engine/LOCAL_CHANGES.md:48)。

台账按主题归一下类:

类别条目内容举例
导入与边界L001–L006导入后缀改写、public.ts 入口、哈希记录、严格模式下的类型修补
给产品留的钩子L007–L009、L022、L030、L032Markdown 紧凑代码块,Input 与 SelectList 的提示、掩码,Editor 状态快照与粘贴拦截,空输入占位,命令参数的连续补全
渲染行为L015、L017、L026、L027、L029、L033立即渲染与强制重置分开、分块写出、Markdown 稳定块缓存、缩放时先画可见尾部、链接样式隔离、剥掉 OSC 133 标记
全屏交互L011、L019、L020、L025、L031鼠标事件按布局分发、双击选中整段路径、复制开关、搜索索引复用、面板自管翻页
终端与输入L014、L023、L024、L028原生模块路径、OSC 11 与 DEC 2031 同块解析、传统终端的 Ctrl+/、焦点上报
回移上游修复L013、L017–L0210.84.2 之后的窄宽度与表格样式修复,0.84.4 的五个提交
进程韧性L016文件补全子进程的输出流出错时就地收住,不再冒成 uncaughtException

编号有空洞:L010 已撤销,理由写在 LOCAL_CHANGES.md:38;L012 不存在;LOCAL_CHANGES.json:160 给 tui-main-screen.ts 记了一条 L034(历史内容变化时只重绘可见区域),Markdown 台账里却没有这一条。台账还透露了这份 fork 的前史(LOCAL_CHANGES.md:44):

The superseded custom Kernel, logical Main document, renderer lifecycle split, custom terminal, custom scheduler and Engine preview Host were removed during the Pi reset.

从这句话看,MCode 早先有过一套自研的内核、调度器与终端层,回归 pi 时整套删掉了;中文更新日志 0.2.0(2026-08-20)的第一条正是“重建终端渲染与输入基建,统一 regular/fullscreen、Markdown、Unicode、图片、键盘、鼠标、搜索和窗口 resize 行为。”(packages/tui/CHANGELOG.zh-CN.md:499)。另外,UPSTREAM.md:27 给出的基线校验脚本 verify-tui-engine-baseline.mjs 不在公开仓库里,台账引用的测试也只公开了 tui-engine-local-deltas.test.ts、tui-keybindings.test.ts 等少数几个。

两份拷贝的补丁互不相通。third_party/pi-mono/MINIMAX_CHANGES.md:113 记了 2026-08-10 把 OSC 8 超链接改成 BEL 结尾、好让 macOS 终端整段命中,这条改在 v0.79.1 那一份里;引擎的 hyperlink() 仍是上游的 ST 结尾写法(engine/terminal-image.ts:669),Markdown 里的链接走的就是它(engine/components/markdown.ts:734)。两边的原生辅助模块倒是同一批:packages/tui/native 下四个 .node 预编译文件与 third_party 里的逐字节相同。

许可证声明对得上号,但指向不全。packages/tui/THIRD_PARTY_NOTICES.md:5 只笼统地说终端界面有一部分派生自 Pi TUI;对照下来,派生的就是引擎目录里的 39 个上游文件,外加上面这四个原生模块。根目录的 THIRD_PARTY_NOTICES.md:3 说本地修改记在 third_party/pi-mono/MINIMAX_CHANGES.md,可引擎的改动记在引擎目录自己的台账里,那份文件对引擎一条也没提。

主屏:差量重绘

组件契约极简:render(width) 返回若干行字符串,invalidate() 清缓存,handleInput 可选(engine/tui.ts:22)。本地加的 handlesViewportKeys(L031)让获得焦点的面板自己处理翻页键。

调度:requestRender() 在 process.nextTick 里排队,两帧间隔至少 16 毫秒(MIN_RENDER_INTERVAL_MS,engine/tui.ts:346)。按键走另一条路,焦点组件处理完输入立刻 requestImmediateRender(),上游注释写明原因:

Keyboard input is latency-sensitive. Avoid the throttled timer path, where even setTimeout(0) can take a full 16 ms tick on Windows.

L015 把这条立即渲染的路径从 requestRender 的 force 重置里拆了出来:产品的交互能马上重画,又不会清空差量状态、抹掉原生回滚。

一帧怎么出:TuiMainScreen.doRender() 先让所有组件渲染出新行,剥掉行首的 OSC 133 区段标记(L033;这些标记只给全屏跳转用,写进主屏会让 iTerm2 这类终端逐段画出侧边标记,差量重绘时还会带偏光标),合成覆盖层,找到 CURSOR_MARKER 记下硬件光标位置,然后在几种重绘方式之间选一种(packages/tui/src/tui/engine/tui-main-screen.ts:360):

if (this.historyReplayPending) {
	const viewportOnly = this.resizeTimer !== undefined;
	fullRender(true, viewportOnly);
	if (!viewportOnly) this.historyReplayPending = false;
	return;
}

// 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. 缩放后重放(L027):拖动窗口时立刻只画可见尾部,停手 150 毫秒后再把整份历史重放一遍(tui-main-screen.ts:141);退出前若重放还没做,stop() 先补一帧。
  2. 首帧直接输出,不清屏。
  3. 宽度变了:折行全变,清屏并清回滚(\x1b[3J)后全量重画。
  4. 高度变了同样全量重画,Termux 除外,那里软键盘一弹高度就变。
  5. 内容缩短时的清理默认关闭,要 PI_CLEAR_ON_SHRINK 设为 1 才开。
  6. 其余情况逐行比较新旧两帧,找出首个与末个变化行,再把 Kitty 图片占的行并进范围。
  7. 变化落在视口上方:原生回滚里的行改不了,只重画可见视口,而且不发清回滚的 \x1b[3J(tui-main-screen.ts:489)。这是 L034,对应中文更新日志 0.4.12 的“修复会话状态刷新触发重绘时清空终端滚动历史,或导致 Windows Terminal 跳到历史开头的问题。”(CHANGELOG.zh-CN.md:19)。
  8. 常态:光标移到首个变化行,逐行 \x1b[2K 清掉再写,多出来的旧行补清。

所有输出都包在同步输出 \x1b[?2026h 与 \x1b[?2026l 之间,由 BoundedTerminalWriter 按 1024 * 1024 个字符一块写出(MAX_RENDER_WRITE_CHARS,tui-main-screen.ts:8),切块时不拆 UTF-16 代理对。这是 L017 从 pi 0.84.4 回移的:长会话整帧重画时,拼成一个字符串会超出 V8 的字符串长度上限。

超宽行两种模式处理不同。全屏渲染器每帧按宽度截断(tui-alt-screen.ts:1358);主屏的差量路径沿用上游做法,把所有行写进 pi-crash.log,停掉 TUI 后抛错(tui-main-screen.ts:553),错误信息提醒组件作者用 visibleWidth() 量、truncateToWidth() 截。

全屏与模式切换

TuiAltScreen 进入备用屏(\x1b[?1049h),打开 SGR 鼠标上报(tui-alt-screen.ts:57),用 VStack、HStack、ScrollView 组成布局树。栈布局的条目带 basis、grow、shrink、minSize、maxSize,是一套简化的弹性布局(packages/tui/src/tui/engine/layout-node.ts:10);L011 让鼠标的按下、拖动、松开先按布局命中最深的组件,计划审阅这类全屏面板因此能用鼠标操作。

两个渲染器由产品层的 McodeInteractiveRenderer 管着,对外暴露的 ui 是一个 Proxy,每次调用都转给当前那个渲染器(packages/tui/src/tui/renderer/interactive-renderer.ts:26)。switchMode() 的步骤(interactive-renderer.ts:137):有覆盖层时拒绝切换;离开主屏前保存差量状态(上一帧的行、光标行、视口顶),回到主屏时恢复,免得整份历史再画一遍;旧渲染器以 preserveScreen 停下,子组件与焦点搬到新渲染器再启动。任何一步抛错都回滚到原渲染器,并记一条 cli_render_error 事故(interactive-renderer.ts:258)。退出时若还在全屏,prepareTranscriptExit() 会先切回主屏再画一帧,让对话内容落回终端的正常输出里。

输入与宽字符

ProcessTerminal.start()(packages/tui/src/tui/engine/terminal.ts:168)依次:切 raw 模式,开括号粘贴(\x1b[?2004h)与焦点上报(\x1b[?1004h);非 Windows 平台给自己发一个 SIGWINCH,补上挂起期间错过的尺寸变化;Windows 上加载原生模块打开 VT 输入,否则 Shift+Tab 到达时只剩 Tab;最后探测 Kitty 键盘协议,请求标志 7,终端若先回了设备属性而没有回 Kitty 标志,就退回 xterm 的 modifyOtherKeys(\x1b[>4;2m,engine/terminal.ts:364)。

输入先进 StdinBuffer,把被拆成多次 data 事件的转义序列拼回完整,文件头注明它基于 OpenTUI 的代码(packages/tui/src/tui/engine/stdin-buffer.ts:16)。keys.ts 负责识别 Kitty、CSI u、modifyOtherKeys 与传统序列。L024 在这里补了一条:传统终端把 Ctrl+/ 与 Ctrl+_ 都报成 0x1F,所以 Ctrl+/ 也要能匹配这个字节,/btw 旁路会话的切换键靠它。Apple Terminal 与 Windows 上,回车到达时还会用原生模块查一下 Shift 是否按着,按着就改写成 \x1b[13;2u(engine/terminal.ts:352),Shift+Enter 换行因此在这两处也能用。

按键到动作的映射由 KeybindingsManager 管。引擎自带一组 id,例如 tui.input.newLine 默认是 shift+enter 与 ctrl+j(packages/tui/src/tui/engine/keybindings.ts:143);产品把自己的 id 合并进同一个管理器,见下一篇。

宽度计算在 visibleWidth()(packages/tui/src/tui/engine/utils.ts:251):可打印 ASCII 直接返回长度;其余先查 512 项的缓存,再剥掉 CSI、OSC、APC 序列,用 Intl.Segmenter 切成字素簇,逐簇交给 graphemeWidth()。后者用 get-east-asian-width 判断全角,RGI emoji 与区域指示符一律算 2 列,制表符算 3 列(utils.ts:185)。折行时汉字、假名、谚文、注音每个字都是断点(cjkBreakRegex,utils.ts:65)。输入框获得焦点时在光标处输出零宽的 APC 标记 CURSOR_MARKER,渲染器找到它就把硬件光标挪过去,输入法的候选框才能跟着光标走(engine/tui.ts:81)。

平台与主题

  • 终端画像:detectTerminalCapabilities() 从环境变量认出 Windows Terminal、Apple Terminal、Ghostty、iTerm2、Kitty、VS Code、WezTerm,判断是否经 SSH、是否在 tmux 或 screen 里,算出 0–3 级色深(terminal-capabilities.ts:32)。这份画像会记进本地观测日志,见遥测、更新与诊断。
  • 进程守护:installTuiProcessGuards() 接管 SIGINT、SIGTERM、SIGHUP、SIGBREAK、挂起与恢复,以及未捕获的异常与拒绝,统一经应用的 stop() 收尾;异常路径还会记一条事故、把退出码设为 1(packages/tui/src/tui/platform/process-guards.ts:172)。
  • 剪贴板:经 SSH 时 /copy 改用 OSC 52 写本地剪贴板,Base64 之后超过 100000 个字符就拒绝(packages/tui/src/tui/platform/terminal-clipboard.ts:10)。
  • 主题:先按 COLORFGBG 猜,再用 OSC 11 查询终端背景色,最多等 250 毫秒(packages/tui/src/tui/theme/controller.ts:48),并订阅 DEC 2031 的配色变化推送;相对亮度不低于 0.5 算浅色(packages/tui/src/tui/theme/detection.ts:57),在深浅两套调色板之间切换。L023 让这两类回报夹在普通按键中间到达时也能被抠出来,而不是整块丢掉。

与 Kimi Code 对照

Kimi Code 也把 pi-tui 复制进仓库直接改。据《Kimi Code 源码解读》的 pi-tui 一篇,两家的做法对比如下:

Kimi CodeMiniMax Code
位置独立的 workspace 包 packages/pi-tui产品包里的一个目录,随产品一起编译
基线上游 0.80.2上游 0.84.2,另挑 0.84.4 的五个修复
分歧记录包内 AGENTS.md 列出 8 处LOCAL_CHANGES.md 与 .json 按 L 编号登记,BASELINE.json 存逐文件哈希
超宽行本地改成截断主屏保留上游的抛错,全屏截断
全屏模式用环境变量开启的实验功能--tui-mode 或 /settings 选择,运行中可以来回切
对产品的边界可以单独取用产品只能经 public.ts 进入

两家引擎的运行时依赖都只有 get-east-asian-width 与 marked(packages/tui/package.json:35)。差别在治理方式:Kimi Code 把 pi-tui 当成一个可以独立演进的库;MiniMax Code 把它收进产品包,用哈希与台账盯住每一处偏离,给每条偏离写好删除条件,为以后逐个提交地挑选同步上游留出路径。

下一篇:TUI 应用:控制器、功能与转录——引擎之上的状态、转录区、斜杠命令与快捷键怎样组织。

本页目录