TUI 引擎
产品界面底下的渲染层是 pi-tui 0.84.2 的一份 fork:逐文件哈希与本地改动台账、主屏差量重绘与全屏视口、输入解析与宽字符,以及它和 third_party 里那份 pi-tui 的关系。
packages/tui/src/tui 有 258 个源文件、72134 行(口径同全书),占整个 packages/tui 的近七成。这一篇只看最底下一层:把组件渲染成一行行字符串、再用尽量少的字节写进终端的引擎,以及紧贴它的渲染器切换、平台探测与主题。控制器、转录区、斜杠命令与快捷键留给下一篇。
先说结论:这层引擎来自 pi 的 pi-tui,但不是 third_party/pi-mono 里那一份。
目录分工
| 目录 | 文件 / 行数 | 分工 | 本书位置 |
|---|---|---|---|
engine | 41 / 17742 | pi-tui 的 fork:渲染器、终端、按键、布局、组件 | 本篇 |
renderer | 2 / 328 | 创建主屏或全屏渲染器,运行中切换 | 本篇 |
rendering | 5 / 72 | 对引擎文本工具的薄封装:组件释放、控制字符清洗、URL 脱敏、时长格式 | 本篇 |
foundation | 1 / 149 | 带边框的表格组件 | 本篇 |
platform | 7 / 828 | 终端能力探测、进程守护、终端通知、剪贴板、停止原因记录 | 本篇 |
theme | 9 / 1098 | 深浅色探测、调色板、语法与 shell 命令高亮 | 本篇 |
controller | 50 / 18340 | 各类 flow 与转录投影 | 下一篇 |
features | 58 / 15580 | 各个面板:会话、模型、权限、问卷、更新…… | 下一篇 |
shell | 27 / 5452 | 布局、输入框、状态栏、快捷键注册表 | 下一篇 |
transcript | 21 / 4891 | 转录单元的存储、投影与绘制 | 下一篇 |
commands、widgets、state | 5 / 1192、7 / 1175、7 / 784 | 斜杠命令目录、组件包装、reducer 式状态 | 下一篇 |
interaction、automation、todo、dev | 2 / 225、3 / 387、1 / 6、1 / 81 | 交互模型、自动化结果、Todo 类型、开发用的工具卡片画廊 | 下一篇 |
agent-team、background-work | 3 / 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、L032 | Markdown 紧凑代码块,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–L021 | 0.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;
}完整的判断链:
- 缩放后重放(L027):拖动窗口时立刻只画可见尾部,停手 150 毫秒后再把整份历史重放一遍(
tui-main-screen.ts:141);退出前若重放还没做,stop()先补一帧。 - 首帧直接输出,不清屏。
- 宽度变了:折行全变,清屏并清回滚(
\x1b[3J)后全量重画。 - 高度变了同样全量重画,Termux 除外,那里软键盘一弹高度就变。
- 内容缩短时的清理默认关闭,要
PI_CLEAR_ON_SHRINK设为1才开。 - 其余情况逐行比较新旧两帧,找出首个与末个变化行,再把 Kitty 图片占的行并进范围。
- 变化落在视口上方:原生回滚里的行改不了,只重画可见视口,而且不发清回滚的
\x1b[3J(tui-main-screen.ts:489)。这是 L034,对应中文更新日志 0.4.12 的“修复会话状态刷新触发重绘时清空终端滚动历史,或导致 Windows Terminal 跳到历史开头的问题。”(CHANGELOG.zh-CN.md:19)。 - 常态:光标移到首个变化行,逐行
\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 Code | MiniMax 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 应用:控制器、功能与转录——引擎之上的状态、转录区、斜杠命令与快捷键怎样组织。