# TUI 引擎

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

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

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

先说结论：这层引擎来自 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 与转录投影 | [下一篇](https://daiw.net/manual/minimax-code/tui-app) |
| `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 与后台任务](https://daiw.net/manual/minimax-code/subagents) |

根目录另有 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

```mermaid
flowchart LR
  UP["上游 pi：packages/tui 0.84.2"] -.->|2026-08-18 复制| ENG["packages/tui/src/tui/engine"]
  PROD["controller、shell、features 等"] --> PUB["engine/public.ts"]
  PUB --> ENG
  RT["运行时与工具"] --> CA["third_party：pi-coding-agent"]
  CA --> PT["third_party：pi-tui 0.79.1"]
```

`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](https://daiw.net/manual/minimax-code/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`）：

```ts
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`）。这份画像会记进本地观测日志，见[遥测、更新与诊断](https://daiw.net/manual/minimax-code/telemetry-and-update)。
- **进程守护**：`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](https://daiw.net/manual/kimi-code) 也把 pi-tui 复制进仓库直接改。据[《Kimi Code 源码解读》的 pi-tui 一篇](https://daiw.net/manual/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 应用：控制器、功能与转录](https://daiw.net/manual/minimax-code/tui-app)——引擎之上的状态、转录区、斜杠命令与快捷键怎样组织。
