# TUI 应用：控制器、功能与转录

> 引擎之上的产品层：组合根怎样接起运行时端口、三层状态与十几个 flow，流式事件怎样投影成转录单元再画成终端行，以及斜杠命令、快捷键、@ 引用与审批卡片的实现。

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

[上一篇](https://daiw.net/manual/minimax-code/tui-engine)的引擎只认“组件把自己渲染成若干行”。把一场对话变成这些行的，是 `packages/tui/src/tui` 里引擎以外的五万多行：`controller` 50 个文件 18340 行，`features` 58 个文件 15580 行，加上 `shell`、`transcript`、`commands`、`state` 等目录（分工表见上一篇）。这一篇沿着“按键、运行时、转录区”这条路走一遍。团队面板与后台任务（`agent-team`、`background-work`）放在[子 Agent 与后台任务](https://daiw.net/manual/minimax-code/subagents)里讲。

## 怎么用

### README 的七个快捷键

`README_ZH.md` 列了七个键，逐个找到绑定：

| 键 | 绑定 | 说明 |
| --- | --- | --- |
| `Enter` | 空闲时是引擎的 `tui.input.submit`；回合进行中先被 `run.submit-guidance` 截住（`packages/tui/src/tui/shell/keybindings.ts:352`） | 进行中的回车不开新回合，而是以 `steer` 方式送进当前回合（`packages/tui/src/tui/controller/interaction/input-flow.ts:371`） |
| `Alt+Enter` | `run.queue-draft`，只在回合进行中生效（`shell/keybindings.ts:360`） | 排进队列，本轮结束后再发 |
| `Shift+Enter` | 引擎的 `tui.input.newLine`，另有 `ctrl+j`（`packages/tui/src/tui/engine/keybindings.ts:143`） | 终端分不出 Shift 时，Apple Terminal 与 Windows 靠原生模块补救 |
| `@` | 补全提供者的触发字符（`packages/tui/src/tui/controller/run/active-run-flow.ts:557`） | 见下文“@ 引用” |
| `Shift+Tab` | `composer.toggle-plan`（`shell/keybindings.ts:405`） | 有待处理的审批或提问时拒绝切换（`input-flow.ts:406`） |
| `Alt+M` | `composer.cycle-permission`（`shell/keybindings.ts:413`） | Ask、Auto、Full access 三档循环（`packages/tui/src/application/permission-mode.ts:1`） |
| `Esc` | `app.interrupt`（`shell/keybindings.ts:274`） | 依次尝试：取消正在跑的 `!` 命令，取消正在进行的消息编辑，中断回合；输入框为空时，500 毫秒内连按两次进入 `/edit` 改上一条消息（`input-flow.ts:453`） |

README 说 `Esc` 关闭面板，这要看面板自己怎么处理：审批卡片里 `Esc` 等于拒绝，下文细说。

其他常用键都登记在同一张表里（`shell/keybindings.ts:264` 起）：Ctrl+C 清空输入框（连按两次退出），Ctrl+D 在输入框为空时退出，Ctrl+Z 挂起回 shell，Ctrl+- 恢复刚清掉的草稿（Windows 上没有挂起，恢复草稿改用 Ctrl+Z），Ctrl+O 切换详细模式，Ctrl+T 展开完整 Todo，Ctrl+R 搜索历史输入，Ctrl+G 用外部编辑器改草稿（依次看 `MCODE_EDITOR`、`VISUAL`、`EDITOR`，`packages/tui/src/host/external-editor.ts:49`），Ctrl+V 粘贴剪贴板里的图片或视频文件（Windows 与 WSL 上另有 Alt+V，macOS 上另有 Cmd+V），Ctrl+/ 在主会话与 `/btw` 旁路会话之间切换，Alt+Up 或 Shift+Left 把最近排队的消息拉回输入框。欢迎页若发现同一目录 10 分钟内更新过的 Codex 会话（`packages/tui/src/host/recent-codex-session.ts:7`），Ctrl+U 会调内置技能 `resume-codex` 接着做。

改键用 `/hotkeys`，或直接编辑数据目录下 `tui` 子目录里的 `keybindings.json`（`packages/tui/src/host/tui-keybindings.ts:15`），格式是“绑定 id 到一个或一组按键”的扁平映射，与 pi 的写法一致；`/reload` 重读时若发现两个绑定在重叠的作用域里抢同一个键，会拒绝加载并列出冲突。

输入框里以 `!` 开头会在本机直接跑 shell 命令，输出作为一个临时单元显示在转录区；命令与输出（最多保留 65536 个字符，`packages/tui/src/tui/controller/product/bash-flow.ts:6`）会附在下一条消息前面发给模型，写成 `!!` 就只看不附。回合进行中或输入框里有附件时不能跑。

### 斜杠命令

目录在 `packages/tui/src/tui/commands/catalog.ts:122`，共 51 条，其中 43 条面向用户：

| 类别 | 命令 |
| --- | --- |
| 会话 | `/new`（别名 `/clear`）、`/sessions`（`/resume`）、`/goal`、`/plan`、`/btw`（`/side`）、`/parent`、`/history`、`/fork`、`/rewind`、`/edit`、`/retry`、`/rename`、`/compact` |
| 运行时 | `/status`、`/tasks`、`/permission`、`/login`、`/logout`、`/doctor`、`/context`、`/steer`、`/model`、`/provider` |
| 能力 | `/review`、`/plugins`、`/skills`、`/mcp` |
| 输入 | `/add-dir`、`/queue` |
| 转录 | `/usage`、`/export`、`/transcript`、`/copy` |
| 应用 | `/help`、`/update`、`/changelog`、`/feedback`、`/checkin`、`/settings`、`/statusline`、`/hotkeys`、`/reload`、`/quit`（`/exit`） |

敲 `/` 弹出的菜单并不全列：`/update`、`/doctor`、`/feedback`、`/skills`、`/mcp`、`/add-dir` 标为 `search-only`，要接着打字搜索才出现；另有 18 条按上下文显示，例如 `/login` 只在未登录时出现，`/queue` 只在有回合运行或有消息排队时出现。剩下 8 条是内部命令：`/allow`、`/always`、`/deny`、`/permissions`、`/decision`、`/stop`、`/archive`、`/config`，不进菜单，其中 `/config` 在命令处理表里没有对应的处理函数，输入它会被当成普通消息发出去。

运行时 `listSkills` 返回的每个启用的技能都会生成一条 `/<技能名> [instructions]`，描述前缀 `[Skill]`，与内置命令重名的丢弃，在菜单里排在内置命令后面（`active-run-flow.ts:82`）。技能命令不在 TUI 里执行，原文作为消息发出；运行时准备回合输入时认出开头的 `/技能名`，追加一条提醒让模型用 `skill` 工具加载它（`packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts:831`）。

### @ 引用与界面配置

`@` 后面跟的是工作区相对路径：绝对路径和含 `..` 的路径直接不给候选（`active-run-flow.ts:815`）；`@` 后为空或以 `/` 结尾时列目录，否则交给运行时搜索，最多 20 个候选（`active-run-flow.ts:752`）；路径含空格时，补全结果用双引号包起来（`active-run-flow.ts:838`）；`/add-dir` 加进来的其他根目录在候选后面标上 `· 目录名`。选中普通文件只是把 `@路径` 写进输入框，发出去的就是这段文本，TUI 不读文件内容；只有图片扩展名会在选中时排进附件（`IMAGE_MENTION_EXTENSION`，`input-flow.ts:553`）。

界面相关的配置在 `config.yaml` 的 `tui` 段（`packages/config/src/tui-config.ts:34`）：`showTips` 控制空闲时的提示，`statusLine` 是状态栏条目的有序列表（可选 15 项，`packages/tui/src/tui/shell/status-line-items.ts:22`，也可用 `/statusline` 交互调整），`notifications.when`（`unfocused`、`always`、`never`）与 `notifications.method`（`auto`、`osc9`、`osc777`、`bel`）管回合结束、出错、待审批、待回答时的终端通知，`customStatusLine.command` 让状态栏显示一条自定义命令的输出。

## 组合根与端口

`launcher.ts` 准备好数据目录、账号与观测之后调用 `createTuiApp()`（`packages/tui/src/tui/app.ts:54`）。这个七百多行的函数是整个应用的组合根：先建终端、渲染器与主题控制器，再建 `TuiStateStore`、`TranscriptStore` 与 `TranscriptView`、输入框、`TuiChatController`、布局，最后把十几个 flow 一个个接起来。flow 之间不走事件总线，靠构造时传入的回调互相通知（`onChanged`、`append`、`setHint` 之类）。

所有对运行时的访问都经过 `TuiRuntime` 这个类型（`packages/tui/src/runtime/port.ts:613`），它是 18 个窄接口的交叉；实现是包在进程内 `CliService` 外面的 `TuiRuntimeAdapter`（`packages/tui/src/runtime/adapter.ts:109`，运行时怎么组装见[进程内入口](https://daiw.net/manual/minimax-code/runtime-entry)）。类型上方的注释说明了取舍：

> Headless run lifecycle is intentionally excluded: commands and application services should depend on the narrow domain ports above instead of this composed product boundary.

几个主要的 flow：

| flow | 位置 | 负责 |
| --- | --- | --- |
| `TuiChatController` | `controller/chat-controller.ts` | 会话列表与当前会话，发起回合，把流事件交给转录投影 |
| `TuiRuntimeEventFlow` | `controller/runtime/runtime-event-flow.ts` | 订阅运行时事件，断线重连，接管不是本端发起的回合 |
| `TuiInputFlow`、`TuiCommandFlow` | `controller/interaction/`、`controller/product/` | 全局按键；提交、斜杠命令、`!` 命令与失败恢复 |
| `TuiInteractionFlow` | `controller/interaction/interaction-flow.ts` | 审批卡片与提问卡片 |
| `TuiActiveRunFlow`、`TuiQueueFlow` | `controller/run/` | 补全与帮助；排队与转向 |
| `TuiSessionFlow`、`TuiSessionMutationFlow` | `controller/session-flow.ts` 等 | 切换、旁路会话、分叉、回退、编辑 |
| `TuiFeatureFlow` | `controller/product/feature-flow.ts` | 模型、插件、会话中心、检查面板等 `features` 里的面板 |

## 三层状态

界面状态分在三处：

- **会话快照**：`TuiChatController` 维护 `TuiChatSnapshot`（`packages/tui/src/tui/controller/chat-controller-types.ts:23`），包括状态（`idle`、`starting`、`running`、`error`）、会话列表、当前会话、账号、用量、上下文快照、进行中的回合号与最近一次回合的结果。每次变化回调 `onChange`，应用据此刷新状态栏并请求重画。
- **执行状态**：`TuiStateStore`（`packages/tui/src/tui/state/store.ts:7`）是一个 reducer 式的小仓库。`TuiState` 按会话存运行、队列、后台任务、子 Agent 四张表，以及待处理的审批与提问（`packages/tui/src/tui/state/model.ts:64`）；连接状态在 `disconnected`、`snapshotting`、`subscribed`、`reconciling`、`live` 之间转换。action 可以产出副作用（目前只有“向运行时重新对账某个会话”一种），由 `TuiEffectRunner` 执行后再派发结果；运行、队列、审批、提问各带修订号，迟到的快照因此不会覆盖新状态。
- **转录**：`TranscriptStore`（`packages/tui/src/tui/transcript/store.ts:15`）按顺序存单元，每个单元有自己的修订号，另记每个回合从哪一格开始。它上面还套两层只影响显示的投影：`TranscriptVisibilityProjection` 在编辑上一条消息时把后面的内容暂时藏起来，底层记录不动；`TranscriptPresentationController` 记着当前是紧凑还是详细模式。

## 从事件到转录单元

```mermaid
flowchart TD
  CS["CliService（进程内）"] --> PORT["TuiRuntime 端口"]
  PORT -->|本端发起的回合| CC["TuiChatController"]
  PORT -->|watchEvents| REF["TuiRuntimeEventFlow"]
  REF -->|watchSessionTurn 接管回合| CC
  REF --> SS["TuiStateStore"]
  CC --> TP["TuiTurnProjection"]
  TP --> TS["TranscriptStore"]
  TS --> TV["TranscriptView"]
  SS --> IF["TuiInteractionFlow 等"]
  TV --> ENG["引擎差量重绘"]
  IF --> ENG
```

回合事件有两个来源。本端提交的消息由 `TuiChatController.submit()` 发起，流事件直接回调进来；排队消息开始执行、切到一个正在跑的会话这类不是由它直接发起的回合，由 `TuiRuntimeEventFlow` 用 `watchSessionTurn` 接管，带着游标（`afterCursor` 或 `afterMsgId`）续读，遇到 `resync-required` 就整段重拉历史（`packages/tui/src/tui/controller/runtime/runtime-event-flow.ts:605`）。事件总线断开时按 250 毫秒起步、10 秒封顶的退避重连，30 秒内失败到第 3 次才记一条事故（`runtime-event-flow.ts:44`）。

两条路最后都进 `applyRuntimeTurnEvent()`（`packages/tui/src/tui/controller/chat-controller.ts:690`），由 `TuiTurnProjection` 把事件投影成转录单元。打开历史会话时走 `hydrateTuiHistory()`，与实时投影共用工具、Todo、用户消息三个子投影，所以恢复出来的会话和当场看到的一样。

转录单元的类型有 20 种（`packages/tui/src/tui/transcript/model.ts:5`）：用户、助手、思考、工具、shell、委派、团队、审批、提问、diff、用量、检查报告、警告、错误等；状态 7 种，显示方式分折叠、预览、展开三档。编辑类与写文件类工具的参数会被 `buildTuiToolPreview()` 做成结构化预览：有工具结果里的 diff 就用它，否则把新旧文本拼成 diff，写文件则显示内容，内联上限 64 KiB、400 行（`packages/tui/src/runtime/tool-preview.ts:7`）。审批请求与流事件共用这一套预览。

## 转录区怎么画

`TranscriptView`（`packages/tui/src/tui/transcript/view.ts:49`）是转录区的组件，几处设计都为长会话服务：

- **投影窗口**：从最近 30 个回合开始投影，新回合进来窗口跟着变长，超过 60 个回合就重新锚定到最近 30 个；单个回合超过 120 格时只留第一格和最后 119 格，中间折成“… N earlier steps folded”；投影总量不超过 1000 格（默认值见 `view.ts:106`）。窗口外的回合折成一行“↑ N earlier turns omitted from live projection”，完整历史用 `/transcript` 翻看。
- **两级缓存**：整帧按存储修订号、显示模式修订号与宽度缓存，流式输出时没变的单元直接复用上一帧的行；单元级缓存按单元修订号命中。
- **读取类工具合并**：同一回合里连续两次以上的读取、搜索、列目录调用合成一组显示（`view.ts:392`）。
- **助手消息**：用引擎的 `Markdown` 组件渲染，回合进行中换用流式主题；行首加 `●` 锚点，整段包进 OSC 133 区段，全屏模式据此在消息之间跳转。

工具调用画成一行：状态标记（进行中 `•`、成功 `✓` 或 `•`、失败 `×`）、动词、摘要。动词来自 `packages/tui/src/tui/transcript/tool-definitions.ts:26` 的内置定义，例如 `bash` 是 Running、Ran、Command failed，编辑类是 Editing、Edited、Edit failed。默认的紧凑模式下工具一律折成这一行，思考只在进行中给预览（`packages/tui/src/tui/transcript/presentation/state.ts:38`）；Ctrl+O 切到详细模式后，思考、工具输出与 diff 全部展开，结构化预览最多显示 30 行（`view.ts:670`）。

## 斜杠命令怎样分发

每条命令是一个 `TuiCommandSource`：名字、别名、类别、面向谁（`user` 或 `internal`）、可发现性、就绪要求、是否只能在空闲时用、显示条件、参数提示与参数补全、快捷键说明（`catalog.ts:51`）。目录构建时名字与别名统一转小写查重，重复就抛错。解析在 `matchTuiCommandInput()`（`catalog.ts:689`）：第一个词以 `/` 开头、大小写不敏感地匹配名字或别名；没有参数提示的命令不接受尾随参数，`/new 某某` 因此不算命令。

分发前依次检查：旁路会话里只放行 `/help`、`/changelog`、`/context`、`/status`、`/usage`、`/export`、`/transcript`、`/copy`、`/parent` 九条只读命令（`catalog.ts:39`）；`/new`、`/sessions`、`/fork`、`/rewind`、`/edit`、`/rename`、`/compact`、`/hotkeys`、`/reload` 在回合运行时不可用，提示先停下当前回合（`catalog.ts:760`）。通过后调用 `TuiCommandFlow` 里的处理函数。认不出的输入不报错，照常作为消息发给模型，技能命令走的就是这条路。

命令还声明就绪要求：`/help`、`/doctor`、`/quit` 等是 `immediate`，启动时运行时还没就绪也能立即执行；`/status`、`/checkin` 等只等控制器；其余要等运行时完全就绪。

## 审批卡片与差异审阅

工具需要批准时，`TuiInteractionFlow` 把请求画成 `TuiPermissionPicker`（`packages/tui/src/tui/features/interaction/permission-picker.ts:54`），并按通知设置发一条“Permission needs your input”的终端通知。卡片内容：

- **标题**按工具名推断：命令类是“Run this command?”，写文件“Write this file?”，编辑“Apply these edits?”，删除“Delete this item?”（`permission-picker.ts:469`）。
- **证据区**优先显示结构化预览（diff 或文件内容），否则显示工具参数，shell 命令做语法高亮；默认只露 6 行，Ctrl+E 展开全部，PgUp/PgDn 滚动（`permission-picker.ts:23`）。
- **三个选项**：“1 Allow for this conversation”（值是 `allowOnce`，新会话里会再问）、“2 Always allow matching actions”、“3 Deny and guide MCode”（`permission-picker.ts:87`）；这类动作不支持记住时只剩两项。数字键直接选；`Esc` 直接拒绝，Ctrl+C 停止回合。选“总是允许”会先进确认页，逐条列出将要保存的规则范围（`ruleContents`）再落盘；选“拒绝”可以附一段指导，拒绝生效后这段话以“Regarding the denied … action”开头，作为一条消息提交给模型（`packages/tui/src/tui/controller/interaction/interaction-flow.ts:1269`）。

请求一直开着，直到运行时确认结果；若别的界面已经处理过，卡片收起并在转录里记“Resolved on another surface.”。规则怎样匹配、三种权限模式的差别，见[权限模式与规则](https://daiw.net/manual/minimax-code/permission)。

差异审阅有两处。一是上面说的：审批时看到的是“Proposed”状态的 diff，执行后工具卡片下的同一份预览标成“Applied”或“Not applied”，详细模式里可以回看（`packages/tui/src/tui/transcript/presentation/structured-preview.ts:139`）。二是 `/review`：它发出一条固定的消息“Please review my uncommitted changes.”，并带上 `reviewRequest: { scope: 'local_changes' }`（`packages/tui/src/tui/controller/product/command-flow.ts:139`），审阅本身在运行时里跑；TUI 用 XML 解析器读回 `<annotation-result>` 结构，得到 P0 到 P3 分级的发现列表，每条带路径，可带行号范围（`packages/tui/src/review/result.ts:56`）。

## 布局与其余目录

`shell/chat-layout.ts` 把界面切成欢迎页、通知、转录、交互面板、活动行、目标、待发队列、任务、输入框、状态栏几块（`packages/tui/src/tui/shell/chat-layout.ts:17`）；交互面板出现时输入框让位。`/transcript`、`/plugins`、`/history` 这类整屏功能在全屏模式下直接挂进备用屏布局，在主屏模式下则以全尺寸覆盖层的方式叠在上面（`packages/tui/src/tui/shell/regular-feature-presenter.ts:47`）；`/sessions`、审批、提问这类面板则出现在输入框的位置上。

`features` 按功能分成 18 个子目录（`auth`、`changelog`、`composer`、`session-mutation`、`provider`、`update` 等），每个是一个或几个面板组件，由对应的 flow 打开。`widgets` 是对引擎组件的薄包装，其中的 `Editor`（`packages/tui/src/tui/widgets/editor/editor.ts:36`）按上一篇台账 L009 的说法，只负责草稿与附件的身份，通用的编辑行为全在引擎里。`interaction` 放审批与提问的纯数据逻辑，例如 `/q1` 这样按序号回答提问的语法（`packages/tui/src/tui/interaction/questionnaire.ts:22`）。

`automation` 从代码看是留给外部自动化读取的：状态栏条目里有 `build-mode`，且设置了环境变量 `MCODE_TUI_RESULT_PATH` 时，每个回合的结果以 JSON Lines 追加到该文件（`packages/tui/src/tui/automation/result-writer.ts:19`），状态栏还会输出一行机器可读的 `[V] seq=… state=…`（`packages/tui/src/tui/shell/status-protocol.ts:48`）。

## 与文档对照

`docs/tui-capabilities.md` 按能力列恢复情况，落到 TUI 上的入口如下：

| 文档里的能力 | TUI 入口 |
| --- | --- |
| 登录、退出、Token Plan、额度、签到 | `/login`、`/logout`、`/status`（显示 5 小时与每周额度）、`/checkin` |
| BYOK 与自定义模型 | `/provider`、`/model` |
| 官方、本地与 GitHub 插件 | `/plugins` |
| 更新 | `/update`（搜索可见），另有 `mcode update` |
| 反馈与诊断上传 | `/feedback`（搜索可见） |
| 自动权限 | `/permission auto`、`Alt+M` |
| 内置技能、MCP、插件工具 | `/skills`、`/mcp`（均为搜索可见） |

文档与代码在这些条目上没有冲突。README 的快捷键表同样与绑定一致，只是没写两处细节：输入框为空时连按两次 `Esc` 会进入编辑上一条消息，审批卡片里的 `Esc` 是拒绝而不是关闭。遥测、更新、签到与反馈的实现见[遥测、更新与诊断](https://daiw.net/manual/minimax-code/telemetry-and-update)。

下一篇：[Headless 与 ACP](https://daiw.net/manual/minimax-code/headless-and-acp)——同一套运行时不开界面时怎样跑，`mcode exec` 与 ACP 服务的协议适配。
