# 终端界面

> 不带参数运行 zcode 打开的全屏 TUI：OpenTUI 之上的 React 渲染与同进程直调，界面分区、输入框与全部快捷键，斜杠命令与切换面板，Markdown 与 diff 渲染，审批与问卷，侧边栏的 MCP 与子 Agent 观察，以及为什么要拦截 stderr。

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

不带子命令运行 `zcode`，命令名缺省就是 `tui`（`apps/zcode-cli/packages/cli/src/run.ts:65`），打开一个占满终端的界面。代码分两块：界面本身在 `apps/zcode-cli/packages/tui`，91 个源文件、约 1.37 万行，入口只导出一个 `runTui`（`apps/zcode-cli/packages/tui/src/index.ts:1`）；把界面接到运行时上的胶水在 cli 包的 `tui-*.ts` 与两个剪贴板文件里，约 1900 行。

先说结论：TUI 不走 ZCode Protocol。它和 `AgentRuntime` 在同一个 Node 进程里，靠一组回调直接调用 bootstrap 的 `ZCodeApp`；桌面端与 Web 才经子进程和协议（[下一篇](https://daiw.org/manual/zcode/zcode-protocol)）。`apps/zcode-cli/AGENTS.md` 给它划的边界是（`apps/zcode-cli/AGENTS.md:71`）：

> TUI 只负责输入采集、布局渲染和临时交互态，例如光标、输入框、滚动位置和当前弹窗选择；session、mode、model、tool、todo、permission、checkpoint 等业务状态不得保存在 TUI 层，必须由 server/bootstrap/core/session 存储并通过显式接口或 session event 下发。

## 技术栈

依赖只有几项（`apps/zcode-cli/packages/tui/package.json:21`）：

| 依赖 | 版本 | 用途 |
| --- | --- | --- |
| `@mbears/opentui-core` | 0.2.15 | 终端渲染器、Yoga 布局，以及 `textarea`、`markdown`、`code` 等内建元素 |
| `@mbears/opentui-react` | 0.2.15 | React reconciler：`createRoot`、`useKeyboard`、`useTerminalDimensions` |
| `react` | 19.2.5 | 组件与状态 |
| `shiki` | ^4.1.0 | diff 视图的语法高亮 |
| `web-tree-sitter` | 0.25.10 | opentui-core 的 peer 依赖（`apps/zcode-cli/pnpm-lock.yaml:995`），TUI 源码里没有直接引用 |
| `react-devtools-core`、`ws` | 7.0.1、8.18.0 | opentui-react 的 peer 依赖（`pnpm-lock.yaml:1000`） |

`@mbears/opentui-*` 是不是 OpenTUI 的分叉？仓库里的线索是这些：包名不在上游的 `@opentui/*` 作用域（站内 [OpenCode 的仓库全景](https://daiw.org/manual/opencode/monorepo-map)里，OpenCode 用的是 `@opentui/core`、`@opentui/solid`）；第三方清单却把两个包的源码仓库都记成 `https://github.com/anomalyco/opentui`（`third-party/inventory.json:4778`），许可证的版权人写的是 opentui（`THIRD-PARTY-NOTICES.md:4609`），`apps/zcode-cli/skills-lock.json:4` 还从同一个仓库装了 opentui 的编码技能。lockfile 里 `@mbears/opentui-core` 声明 `engines: node >=22`（`pnpm-lock.yaml:994`），依赖 `yoga-layout`、`marked`、`diff`、`bun-ffi-structs`，可选依赖是六个平台的原生包，外加 Node 的 FFI 库 `koffi` 与 `unsafe-pointer`（`pnpm-lock.yaml:4174`）。能确定的只有：这是 OpenTUI 以另一个 npm 作用域发布的一份构建，带着在 Node 下加载原生渲染库所需的 FFI 依赖；仓库里没有分叉说明、补丁或改动记录，改没改过源码看不出来。

`runTui` 先要求 stdin 与 stdout 都是 TTY，否则在 stderr 打一行提示、返回 1（`apps/zcode-cli/packages/tui/src/tui.tsx:14`）。随后 `createCliRenderer`：目标帧率 30，开鼠标与鼠标移动事件，关掉 OpenTUI 自带的控制台，`exitOnCtrlC: false` 把 `Ctrl+C` 留给应用（`tui.tsx:23`）；退出信号列表特意去掉了 `SIGPIPE`，注释说切换会话时关闭 MCP 管道可能触发它，而 OpenTUI 默认会因此销毁整个界面（`tui.tsx:29`）。React 树由 `createRoot(renderer)` 挂上（`tui.tsx:63`）。终端明暗主题是异步探测的：先等终端回报 250 毫秒，再查 16 色调色板 350 毫秒、按背景色亮度判断（`apps/zcode-cli/packages/tui/src/theme/terminal.ts:5`），不挡首屏（`tui.tsx:141`）。

写法上有个特点：30 个 `.tsx` 文件里没有一处 JSX，组件一律 `React.createElement`，其中 28 个文件开头都定义了同一个别名 `const h = React.createElement`（例如 `apps/zcode-cli/packages/tui/src/app-view.tsx:46`），OpenTUI 的 `box`、`text`、`textarea`、`scrollbox`、`markdown`、`code` 以字符串类型出现。构建是 `tsc` 加 esbuild 打成单个 ESM 文件，`@zcode/*` 以外的依赖全部 external，注释说 OpenTUI 的原生库与 worker 必须保持包内相对路径（`apps/zcode-cli/packages/tui/scripts/build.mjs:14`）。SEA 单文件形态下，cli 的 `loadTuiRuntime` 把打进 SEA 资产的整套 TUI 运行时逐个校验 sha256 后解压到缓存目录，再动态 import：macOS 是 `~/Library/Caches/zcode/sea-assets`，Windows 在 `%LOCALAPPDATA%` 下，Linux 用 `$XDG_CACHE_HOME` 或 `~/.cache`（`apps/zcode-cli/packages/cli/src/tui-runtime-loader.ts:35`、`tui-runtime-loader.ts:120`）。打包本身见[命令行入口、无头模式与打包](https://daiw.org/manual/zcode/cli-surface)。

## 同进程直调：TUI 怎样接上运行时

`runTuiCommand`（`apps/zcode-cli/packages/cli/src/tui-command.ts:16`）用 `createTuiSubmitPrompt` 造一个提示处理器，注册退出清理，再把一组回调装进 `TuiOptions` 交给 `runTui`（`tui-command.ts:59`）。`TuiOptions` 里除了标准流、语言、主题、初始模型这类数据，还有近二十个回调：`submitPrompt`、`sendInput`、`setMode`、`recallPreviousInput`、`listModelOptions`、`listMcpServers`、`readSubagents`、`subscribeSessionEvents`、`readClipboardImage` 等（`apps/zcode-cli/packages/tui/src/types.ts:298`）。启动元数据也是回调：`loadStartupOptions` 在启动画面画出第一帧之后才调用（`types.ts:299`、`tui.tsx:114`），并行读会话元数据、自定义命令与 git 分支（`tui-command.ts:60`），慢的运行时初始化因此不拖首屏。

处理器持有唯一的 `ZCodeApp`，第一次用到才调 bootstrap 的 `createZCodeApp`（`apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:190`），运行配置里固定打开流式输出 `modelStreaming: "on"`（`tui-prompt-handler.ts:163`）。`/new`、`/resume`、`/fork` 都是换一个 App：先建新的、再关旧的（`tui-prompt-handler.ts:118`），装配细节见[bootstrap：把运行时拼起来](https://daiw.org/manual/zcode/bootstrap-assembly)。数据通路有三条。

**提交**。空闲时的输入先进斜杠命令分发器 `createCommandCenter`（`apps/zcode-cli/packages/cli/src/command-center/create.ts:38`），不是命令就直接 `app.submitPrompt`（`create.ts:53`）；回合进行中的输入走 `sendInput`，但 `/model`、`/effort` 仍交给分发器，好让它们在回合中途改掉后续请求的配置（`tui-prompt-handler.ts:327`）。

**审批**。传给 `createZCodeApp` 的 `permissionBroker` 只是个转发器，把请求交给当前这次提交登记的处理函数，没有就拒绝（`tui-prompt-handler.ts:78`）：

```ts
  const permissionBroker: NonNullable<ZCodeAppOptions["permissionBroker"]> = {
    requestPermission: async (request, requestOptions) => {
      const requestPermission = activeRequestPermission;
      if (!requestPermission) {
        return {
          decision: "deny",
          reason: `No interactive approval handler configured for ${request.toolName}`,
          resolvedAt: new Date(),
        };
      }

      return await requestPermission(request, requestOptions);
    },
  };
```

`submitPrompt` 与 `sendInput` 在调用期间把 TUI 传来的 `requestPermission` 设为当前处理函数，返回后还原（`tui-prompt-handler.ts:304`、`tui-prompt-handler.ts:318`）。

**事件**。每次提交带一个回合级 `onEvent`，另有一条跨回合的常驻订阅。`types.ts:231` 的注释讲了第二条的理由：动态工作流的进度、后台完成通知驱动的回合都是“回合之外”的事件，回合级回调在回合结束时就失效了。常驻订阅由 `createTuiSessionEventRelay` 挂在 `runtime.subscribeEvents` 上，换 App 时统一重挂（`tui-prompt-handler.ts:129`、`apps/zcode-cli/packages/cli/src/tui-session-event-relay.ts:37`）：

```ts
  const reattach = (): void => {
    detach();
    if (sinks.size === 0) return;
    const subscribe = input.readSubscriber(input.currentRuntime());
    detachCurrent = subscribe?.({
      onSessionEvent: (event) => {
        // 直接遍历 Set：JS 的 Set 迭代对「遍历中删除」是安全的（已删未访问的条目会被跳过），
        // 所以 sink 在回调里退订不会破坏本次扇出，也不该再收到这一条。
        for (const sink of sinks) sink(event);
      },
    });
  };
```

两条通路会把同一条事件各送一次。TUI 入口先过主会话闸门：带 `source: "subagent"` 的工具镜像、sessionId 不是当前主会话的事件都不进转录，因为子 Agent 的原始事件会带着自己的 sessionId 投进父运行时的同一个 sink 集合（`apps/zcode-cli/packages/tui/src/app-session-event-handler.ts:34`）；再按事件 id 去重，只记最近 2048 个（`app-session-event-handler.ts:19`）。主会话 id 用 getter 现读，因为换会话后缓存下来的 id 会立刻过期（`types.ts:269`）。

```mermaid
flowchart LR
  subgraph CLIPKG["cli 包"]
    TC["runTuiCommand"] --> PH["createTuiSubmitPrompt"]
    PH --> CC["command center"]
    PH --> RELAY["TuiSessionEventRelay"]
  end
  subgraph BOOT["bootstrap 与 core"]
    APP["ZCodeApp"] --> RT["AgentRuntime"]
  end
  subgraph TUIPKG["@zcode/tui"]
    RUN["runTui"] --> TA["TuiApp"]
    TA --> AV["AppView"]
    AV --> CP["ContentPane 转录区"]
    AV --> ACT["审批 / 选择器 / 输入区"]
    AV --> SB["Sidebar"]
    AV --> SV["SubagentView"]
  end
  TC -- "TuiOptions 回调" --> RUN
  TA -- "submitPrompt / sendInput" --> PH
  CC --> APP
  PH --> APP
  RT -- "subscribeEvents" --> RELAY
  RELAY -- "SessionEvent" --> TA
  APP -- "permissionBroker" --> PH
  PH -- "requestPermission" --> TA
```

## 界面分区

`AppShell` 是一行两栏：左边主栏，右边侧边栏（`apps/zcode-cli/packages/tui/src/app-components.tsx:27`）。

| 区域 | 组件 | 说明 |
| --- | --- | --- |
| 转录区 | `ContentPane` | 一个 `scrollbox`，贴底滚动、开视口裁剪（`apps/zcode-cli/packages/tui/src/app-transcript-components.tsx:43`）；空会话时画 ZCode 字符画 logo |
| 动作区 | 三选一 | 有待审批就画审批或问卷面板，有 action 类选择器就画选择器，否则画输入区（`app-view.tsx:192`） |
| 输入区 | 自上而下 | 登录提示、`@` 候选、composer 类选择器、模型、推理强度、模式、斜杠候选、队列、输入框、活动行（`app-view.tsx:261`） |
| 侧边栏 | `Sidebar` | 固定 42 列；终端宽于 120 列时常驻，否则按快捷键才以半透明遮罩叠在主栏上（`apps/zcode-cli/packages/tui/src/app-sidebar-layout.ts:5`、`app-sidebar-layout.ts:81`） |
| 子 Agent 视图 | `SubagentView` | 在侧边栏选中子 Agent 后替换整个主栏，主会话视图只是隐藏（`app-view.tsx:167`） |

输入框的边框标题显示当前模式（如 `Build`），框内底行是“模型 provider | 推理强度”；框下的活动行在回合进行中转 spinner、提示 `esc to interrupt`，右侧是上下文用量，例如 `12.3K (8%)`（`apps/zcode-cli/packages/tui/src/app-input-status.tsx:85`、`app-input-status.tsx:177`）。

## 输入框

- **多行**：OpenTUI 的 `textarea`，按词折行，高度随内容在 2 到 6 行之间伸缩（`apps/zcode-cli/packages/tui/src/app-input-pane.tsx:15`）；`Enter` 提交，`Shift+Enter` 换行（`app-input-pane.tsx:34`）。回合进行中占位提示变成“输入内容会排队”。
- **`@` 提及**：光标前最近的 `@` 要在行首或空白之后，且到光标之间没有空白，才算一次提及（`apps/zcode-cli/packages/tui/src/app-file-mentions.ts:162`）。候选由 cli 列目录得出：拒绝绝对路径和含 `..` 的路径，跳过 `.git`、`node_modules` 等 7 个目录，没敲 `.` 时隐藏点文件，目录在前、前缀匹配在前，最多 50 条（`apps/zcode-cli/packages/cli/src/tui-workspace-paths.ts:9`、`tui-workspace-paths.ts:72`），面板一次显示 8 条（`app-file-mentions.ts:8`）。`Tab` 或 `Enter` 采纳：目录只补全文本、继续往下挑；文件写成 `@路径` 并登记为附件（`app-file-mentions.ts:212`）。发送时只带正文里还留着占位符的附件（`apps/zcode-cli/packages/tui/src/app-input.ts:255`）。
- **粘贴图片**：`Ctrl+V` 触发，带 meta 修饰的 `V` 或原始字节 `0x16` 也算（`apps/zcode-cli/packages/tui/src/app-keyboard.ts:373`），回合进行中不接受（`apps/zcode-cli/packages/tui/src/app-clipboard-image.ts:34`）。读剪贴板在 cli：macOS 用 `osascript` 把 PNG 写到 `~/.zcode/clipboard` 下的临时目录，Linux 依次试 `wl-paste` 与 `xclip` 的 PNG、JPEG、GIF、WebP，Windows 用 PowerShell 的 `Get-Clipboard -Format Image`，单张上限 20 MiB（`apps/zcode-cli/packages/cli/src/clipboard-image.ts:7`、`clipboard-image.ts:74`）。图片以 data URL 进附件，输入框里插一个 `[image #N]` 占位（`app-clipboard-image.ts:58`）。
- **输入历史**：草稿为空时 `↑` 翻上一条（`apps/zcode-cli/packages/tui/src/app-keyboard-helpers.ts:61`）。历史由 App 的 `recallPreviousInputHistory` 从运行时取（`apps/zcode-cli/packages/cli/src/tui-prompt-handler-queries.ts:41`），TUI 只记偏移量和翻历史前的草稿，`↓` 翻到底时把原草稿还回来（`apps/zcode-cli/packages/tui/src/app-input-history.ts:110`），附件随历史一起恢复。
- **忙碌时提交**：走 `sendInput`，`delivery` 为 `auto` 并带上当前回合 id（`apps/zcode-cli/packages/tui/src/app-submit.ts:151`）。运行时回 `queued` 时，这条消息先进输入框上方的队列面板，提示“下一次工具调用后提交。”，不直接进转录，因为它还没注入模型上下文（`app-submit.ts:176`，文案在 `apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:87`）；收到 `turn_steer_drained` 或 `turn_steer_discarded` 事件后才从队列移除（`apps/zcode-cli/packages/tui/src/app-events.ts:93`）。受理规则见[输入受理、命令队列与引导](https://daiw.org/manual/zcode/prompt-admission)。
- **复制**：鼠标松开时若有选中文本就复制（`app-view.tsx:102`）。写剪贴板同时发 OSC 52 序列和调本机命令（`pbcopy`，`wl-copy`、`xclip`、`xsel`，PowerShell 的 `Set-Clipboard`），上限 1 MiB（`apps/zcode-cli/packages/cli/src/clipboard-text.ts:4`、`clipboard-text.ts:84`）。
- `/login ... <api-key>` 这类带密钥的命令，转录里显示成 `<redacted>`（`app-submit.ts:293`）。

## 快捷键

所有按键由 `useTuiKeyboardControls` 一个处理函数分派（`apps/zcode-cli/packages/tui/src/app-keyboard.ts:95`），优先级从高到低：子 Agent 只读视图、审批与问卷、选择器、`Ctrl+X` 前缀、输入区其余按键，前一层吃掉的键后一层看不到。输入区：

| 按键 | 作用 | 出处 |
| --- | --- | --- |
| `Enter`、`Shift+Enter` | 提交；换行。终端报告为 `linefeed` 的键同样提交 | `app-input-pane.tsx:34` |
| `Tab` | 采纳高亮候选，依次试模型、推理强度、模式、斜杠命令；`@` 面板打开时补全路径 | `app-keyboard.ts:261` |
| `Shift+Tab` | 按 plan、build、edit、yolo 轮换模式，`@` 面板打开时也生效 | `app-keyboard.ts:250` |
| `↑`、`↓` | 有候选面板（含 `@` 面板）时移动高亮；否则在草稿为空或正在翻历史时翻输入历史 | `app-keyboard.ts:312`、`app-keyboard.ts:333` |
| `Esc` | `@` 面板打开时先关它；再依次关掉模型、推理强度、模式、斜杠候选；都没有时中断正在进行的输出 | `app-file-mentions.ts:118`、`app-keyboard.ts:281` |
| `Ctrl+C` | 有选中文本先复制，有草稿就清空；空草稿时 2 秒内连按两次退出 | `app-keyboard.ts:201`、`app-keyboard-helpers.ts:102` |
| `Ctrl+Y` | 复制选中文本 | `app-keyboard.ts:229` |
| `Ctrl+U` | 清空草稿与附件 | `app-keyboard.ts:365` |
| `Ctrl+V` | 粘贴剪贴板图片，meta 修饰的 `V` 同样生效 | `app-keyboard.ts:373` |
| `Ctrl+R` | 转录里有失败或被打断的压缩时，重试那条 `/compact`，否则不占用 | `app-keyboard.ts:235` |
| `+`、`-` | 草稿为空且有工作流卡片时，展开或收起全部卡片 | `app-keyboard-helpers.ts:83` |
| `Ctrl+X`，2 秒内接 `B`、`M`、`A` | 显示或隐藏侧边栏；展开或收起改动文件、API 两节 | `apps/zcode-cli/packages/tui/src/app-sidebar-shortcut.ts:1` |

面板里的按键：

| 场景 | 按键 | 出处 |
| --- | --- | --- |
| 审批面板 | `↑`、`↓` 选，`Enter` 确认，`Esc` 拒绝，其余键被吞掉 | `apps/zcode-cli/packages/tui/src/app-approval.ts:14` |
| 问卷面板 | `↑`、`↓` 或 `k`、`j` 移动，`1` 到 `9` 直选，`Space` 切换多选，`o` 自填答案，`s` 跳过，`Enter` 下一题，`Esc` 拒答；答完进入复核页，`Enter` 提交，`Tab` 回到第一题，`PageUp` 回到最后一题 | `apps/zcode-cli/packages/tui/src/app-question-state.ts:25`、`app-question-state.ts:121` |
| 选择器（`/resume`、`/login`、`/fork` 等） | 打字即过滤，`Backspace` 删字，`Ctrl+U` 清空过滤，`Enter` 选中，`Esc` 取消；输入 API Key 这类条目时显示为星号 | `apps/zcode-cli/packages/tui/src/app-selection-keyboard.ts:85` |
| 子 Agent 只读视图 | `Esc` 回主会话，`Ctrl+C`、`Ctrl+Y` 复制，方向键与翻页键留给滚动，其余键吞掉 | `app-keyboard.ts:141` |
| 启动画面 | `Ctrl+C` 以退出码 130 退出 | `apps/zcode-cli/packages/tui/src/app-startup.tsx:21` |

## 斜杠命令与三个切换面板

输入框以 `/` 开头且还没有空白时弹出斜杠候选，按名字或别名的子串过滤（`apps/zcode-cli/packages/tui/src/app-input.ts:31`），一次显示 6 条（`app-components.tsx:16`）。候选表由 cli 给：19 条内置命令加用户的自定义命令（`apps/zcode-cli/packages/cli/src/command-center/slash-commands.ts:238`，内置表在 `packages/shared/src/zcode-slash-command-help.ts:9`）。`Tab` 把草稿补成 `/名字 `，`Enter` 直接提交高亮那条（`apps/zcode-cli/packages/tui/src/app-submit-resolver.ts:21`）。每条命令在哪一层执行、会不会进模型，见[技能与自定义命令](https://daiw.org/manual/zcode/skills-commands)。

模型、推理强度与模式各有一个专用面板，敲到对应前缀就出现：

| 草稿 | 数据来源 | 选中后提交 |
| --- | --- | --- |
| `/model` | 面板出现时调 `listModelOptions` 重拉目录（`apps/zcode-cli/packages/tui/src/app-model-command.ts:28`） | `/model providerId/modelId`，同时在输入里附上结构化的 `modelSelection`，不靠解析这段文本（`apps/zcode-cli/packages/tui/src/app.tsx:257`、`app-input.ts:275`） |
| `/effort` 或 `/variant` | 会话元数据里的推理强度选项（`app-input.ts:22`） | `/effort 档位` |
| `/mode` | 写死的四项 plan、build、edit、yolo，各带一句说明（`apps/zcode-cli/packages/tui/src/app-mode-command.ts:10`） | `/mode 模式` |

`Shift+Tab` 轮换模式时不等运行时：先把界面上的模式改掉，再以运行时的返回为准，失败就回滚（`apps/zcode-cli/packages/tui/src/app-mode.ts:41`）：

```ts
    const previousMode = mode;
    const nextMode = nextTuiSwitchableMode(mode);
    // Keyboard handlers fire-and-forget this promise, so reflect the local mode
    // immediately and roll back if the session mutation fails.
    setMode(nextMode);
    void Promise.resolve(setModeHandler(nextMode)).then(
      (result) => {
        setMode(result.mode);
        setStatus(result.response ?? formatModeSwitchStatus(result.mode));
      },
      (error: unknown) => {
        setMode(previousMode);
        setStatus(formatModeSwitchFailedStatus(error));
      },
    );
```

四种模式的语义与 Plan 其实是独立开关这件事，见[权限模式与规则](https://daiw.org/manual/zcode/permission)。

## 转录区：Markdown、代码与 diff

消息分 user、agent、system、timeline 四种角色，agent 消息由文本、思考、工具三类片段组成（`apps/zcode-cli/packages/tui/src/app-model.ts:20`、`app-model.ts:30`）。

- **助手文本**：`MarkdownText` 优先用 OpenTUI 的 `markdown` 元素，开 `conceal` 隐藏标记符，回合进行中传 `streaming`（`apps/zcode-cli/packages/tui/src/app-markdown.tsx:43`）。配色是一张按 `markup.heading`、`markup.raw.inline`、`keyword` 这类作用域写的主题表（`apps/zcode-cli/packages/tui/src/app-markdown-theme.ts:4`），这种点号作用域是 tree-sitter 高亮的捕获名，从依赖看代码块着色由 OpenTUI 借 `web-tree-sitter` 完成。拿不到 `markdown` 元素时退回 `code` 元素，再不行就是纯文本（`app-markdown.tsx:55`）。
- **思考**：默认折叠成一行 `+ …`，点一下展开成 `- …`（`apps/zcode-cli/packages/tui/src/app-thought-components.tsx:13`、`app-thought-components.tsx:49`）。
- **工具**：每个调用一行标题，加最多 4 行参数摘要，键名像 token、secret、password、api key 的参数值不显示（`apps/zcode-cli/packages/tui/src/app-tool-transcript.ts:9`、`app-tool-transcript.ts:11`）；输出最多 8 行（`apps/zcode-cli/packages/tui/src/app-tool-components.tsx:8`）。
- **diff**：工具结果带 `file_diff` 显示载荷时交给 `ShikiDiffView`：终端宽于 120 列用左右分栏，否则上下统一视图（`apps/zcode-cli/packages/tui/src/app-shiki-diff-view.tsx:7`、`app-shiki-diff-view.tsx:96`）。语言按扩展名推断，主题是 `github-dark` 与 `github-light`，高亮超过 2.5 秒就放弃、退回无色文本，结果按主题、语言与内容缓存（`apps/zcode-cli/packages/tui/src/app-shiki-highlighter.ts:10`、`app-shiki-highlighter.ts:58`）。同一份载荷里的增删行数累加进侧边栏的改动文件，最多记 100 个文件（`apps/zcode-cli/packages/tui/src/app-modified-files.ts:6`）。
- **压缩**：`/compact` 不画成用户消息，而是一条横线时间线（`app-submit.ts:236`），失败或被打断时提示按 `Ctrl+R` 重试（`apps/zcode-cli/packages/tui/src/app-compact-timeline.ts:91`）。
- **工作流卡片**：`CreateWorkflow` 的工具行换成实时卡片，状态读工作流镜像而不读工具行（`app-transcript-components.tsx:148`），见[动态工作流（三）](https://daiw.org/manual/zcode/dwf-tools)。

## 流式输出：一个 delta 一次 setState

`model_streaming` 事件按 `kind` 分派（`apps/zcode-cli/packages/tui/src/app-model-streaming.ts:11`）。`text_delta` 与 `reasoning_delta` 按 `assistantMessageId` 追加到对应消息的最后一个文本或思考片段，“文本、工具、文本”的交错因此保持模型原本的顺序（`app-model-streaming.ts:54`）；没有 `assistantMessageId` 的增量才落进全局的 `liveModelText`，渲染时作为最后一条临时消息接在转录末尾（`app-view.tsx:113`）；`tool_input_*` 只是未来工具调用的参数 JSON，只更新状态，不进转录（`app-model-streaming.ts:49`）。

TUI 这一侧没有节流或合帧：每个 delta 都是一次 React 的 `setMessages`，合并交给 React 的批处理，刷新频率由渲染器的 `targetFps: 30` 封顶（`tui.tsx:49`），长会话靠 `scrollbox` 的视口裁剪只画可见部分。协议那边则给每个订阅者按 30 或 150 毫秒攒一帧（见下一篇）。`app-motion.tsx` 管的是动画不是节流：活动行的点阵 spinner 10 帧、每 80 毫秒一换，空会话 logo 的高光每 80 毫秒一帧、1.8 秒扫过一遍（`apps/zcode-cli/packages/tui/src/app-motion.tsx:10`）。

回合结束的 `turn_complete` 带着最终回答。通知驱动的回合没有 `submitPrompt` 可以回填结果，TUI 就拿它兜底；为了不和提交结果双写，判断依据是“转录里是否已有同样的文本”而不是先后顺序（`apps/zcode-cli/packages/tui/src/app-turn-complete.ts:11`）。

## 审批与问卷

运行时需要问人时，经上面的转发器调到 `createTuiPermissionRequester`，它把请求包成一个待决的 Promise 放进 `approvalQueue`，界面只画队首（`apps/zcode-cli/packages/tui/src/app-permission.ts:31`、`app-view.tsx:192`）。几条规则：

- 三个选项依次是 Allow once、Always allow in this project、Deny，默认高亮的是 **Deny**（`app-permission.ts:101`），不看就按 `Enter` 等于拒绝。
- Bash 的前缀规则最多列 5 条，标成 Command prefix 或 Exact command only（`app-approval.ts:91`）。
- 选“项目内始终允许”时，把运行时附带的建议规则原样作为 `permissionUpdates` 交回，落盘由运行时做（`app-approval.ts:123`）：

```ts
function createApprovalResult(
  request: PermissionBrokerRequest,
  decision: ApprovalDecision,
): PermissionBrokerResult {
  if (decision === "deny") {
    return {
      decision: "deny",
      reason: "Denied in TUI",
      resolvedAt: new Date(),
    };
  }

  return {
    decision: "allow",
    permissionUpdates:
      decision === "allow_project" ? permissionUpdatesForApproval(request) : undefined,
    reason: decision === "allow_project" ? "Approved for this project in TUI" : "Approved in TUI",
    resolvedAt: new Date(),
  };
}
```

- `CreateWorkflow` 与 `AmendWorkflow` 在 TUI 里自动放行、不弹面板，也刻意不带 `permissionUpdates`，注释说这只是跳过一次确认，不是授权（`app-permission.ts:13`）。
- 请求被取消（例如回合被中断）时，面板自己撤下（`app-permission.ts:88`）。

`AskUserQuestion` 走同一条路：工具名是它时，先用 contracts 的 schema 校验输入，不合法直接回拒绝（`app-permission.ts:54`），合法就画成问卷。答完进入复核页，`Enter` 后以 `decision: "modify"` 把答案与注解作为改写后的工具输入交回（`app-question-state.ts:121`）。问卷结构与自动解决见 [Todo、提问与 Plan 模式](https://daiw.org/manual/zcode/interaction-tools)，规则匹配与“始终允许”的持久化见[权限模式与规则](https://daiw.org/manual/zcode/permission)。

## 侧边栏：MCP 与子 Agent 观察

状态、子代理、MCP、改动文件、Todo 五节常显；运行信息、上下文与缓存、API 请求三节只在 `ZCODE_RUNTIME_ENV=development` 时出现（`apps/zcode-cli/packages/tui/src/app-sidebar.tsx:77`、`apps/zcode-cli/packages/cli/src/tui-command.ts:31`）。Todo 取自 `TodoWrite` 工具结果里的 JSON，最多列 6 条（`app-events.ts:355`、`app-sidebar.tsx:289`）。底行是工作区路径加 git 分支，分支来自 `git symbolic-ref --quiet --short HEAD`，750 毫秒超时（`apps/zcode-cli/packages/cli/src/tui-workspace-git.ts:4`）。

**MCP** 一节每 5 秒调一次 App 的 `listMcpServers`，出错后改为 10 秒（`apps/zcode-cli/packages/tui/src/app-mcp-status.ts:5`、`tui-prompt-handler-queries.ts:61`），没有可用模型时不查（`app-view.tsx:110`）。服务器按名字排序、最多 5 行，每行是状态、名字、传输方式与工具数，connected、connecting、failed、untrusted 分别用成功、强调、危险、警告四种主题色（`apps/zcode-cli/packages/tui/src/app-sidebar-mcp.tsx:16`、`app-sidebar-mcp.tsx:131`）。连接生命周期见 [MCP](https://daiw.org/manual/zcode/mcp)。

**子代理** 一节按 `SUBAGENTS.md` 的十条规则实现（`apps/zcode-cli/packages/tui/SUBAGENTS.md:3`），其中第 5 条是（`SUBAGENTS.md:7`）：

> Bootstrap owns directory interpretation and persisted transcript reads. Reuse the protocol's subagent projector; TUI owns selection, folding and rendering only.

规则与代码的对应如下；第 4 条“主转录拒收外来会话与工具镜像”就是前面说的主会话闸门，这里不再列：

| 规则 | 代码 |
| --- | --- |
| 列出主会话的全部子 Agent，含已结束的 | 目录来自 App 的 `readSubagents`，已结束的分页加载（`apps/zcode-cli/packages/tui/src/app-subagents.ts:164`） |
| 选中后左栏换成只读转录，主运行时照跑 | `SubagentView` 替换主栏（`app-view.tsx:167`）；只能用鼠标点侧边栏条目打开（`apps/zcode-cli/packages/tui/src/app-sidebar-subagents.tsx:60`） |
| 目录与转录由 bootstrap 解释 | bootstrap 复用协议层的子 Agent 投影函数（`apps/zcode-cli/packages/bootstrap/src/app/subagent-observation.ts:11`） |
| 先订阅再读历史，缓存并发事件，只应用水位之上的 | 打开时先备好缓存再读快照，读完回放缓存（`app-subagents.ts:82`）；序号不高于水位的事件忽略（`apps/zcode-cli/packages/tui/src/app-subagent-transcript.ts:60`） |
| 丢弃过期的加载 | 每次打开递增代号，返回时对不上就丢（`app-subagents.ts:98`） |
| 生命周期事件刷新目录，token 只更新转录，不轮询 | 只有 13 种事件触发目录刷新，并发刷新合成一次（`apps/zcode-cli/packages/tui/src/app-subagent-events.ts:23`、`app-subagents.ts:42`） |
| 只读视图的按键先于输入框与审批 | `app-keyboard.ts:141` |
| 主会话有待处理交互时提示返回 | 显示“主会话需要你的输入，返回后处理”（`apps/zcode-cli/packages/tui/src/app-subagent-view.tsx:53`） |

另外两个兜底：缓存超过 4096 条，或者序号出现断档，就整条重新打开（`app-subagents.ts:144`、`app-subagents.ts:146`）。子 Agent 本身的机制见[子 Agent](https://daiw.org/manual/zcode/subagents)。

## 业务状态不在 TUI

代码里能对上开头那条边界的地方：

- 接口是回调式的，`TuiOptions` 的注释写明运行时的所有权留在 CLI（`types.ts:299`）。
- 模式如上所示，以运行时返回为准；模型与推理强度来自 `model_selected` 事件和每次结果附带的元数据（`app-events.ts:286`、`tui-prompt-handler-queries.ts:8`）。
- Todo、改动文件、MCP 状态、子 Agent 目录都是事件或查询的投影；输入历史存在运行时。
- 工作流能否恢复由服务端裁定，类型注释写着“绝不在 TUI 重推导”（`types.ts:246`）。

TUI 自己持有的是转录消息数组、草稿与附件、各面板的高亮位置、侧边栏折叠状态和待决的审批 Promise，都算临时交互态。换会话时由 CLI 回传的 `restoredMessages` 重建转录，`resetSessionProjection` 清空用量、Todo 与改动文件（`apps/zcode-cli/packages/tui/src/app-result.ts:79`）。

<Callout type="warn">
  与 `apps/zcode-cli/AGENTS.md` 的两处出入。其一，AGENTS.md 规定折叠指示符统一用 `+`/`-`（`apps/zcode-cli/AGENTS.md:72`），思考片段照做了，侧边栏的节标题和“已结束”分组却用 `▼`/`▶`（`apps/zcode-cli/packages/tui/src/app-sidebar-section-header.tsx:12`、`app-sidebar-subagents.tsx:120`）。其二，AGENTS.md 要求核心操作都能用键盘完成（`apps/zcode-cli/AGENTS.md:15`），但打开子 Agent 转录、展开思考片段、折叠子代理、MCP 与 Todo 三节，目前只能用鼠标。
</Callout>

## 为什么要拦截 stderr

TUI 独占整个终端，渲染器按自己的缓冲逐格刷新屏幕，任何绕过它直接写进终端的字节都会插在光标所在处，把画面打乱。`main.ts` 在加载业务模块之前为 TUI 装了三层（`apps/zcode-cli/packages/cli/src/main.ts:24`、`main.ts:34`）：

1. 全局 `console` 换成 stdout 与 stderr 都指向 stderr 的新 `Console`（`apps/zcode-cli/packages/cli/src/protocol-console.ts:10`），注释点名 AI SDK 的第一条提示用的是 `console.info`（`main.ts:33`）。
2. 滤掉几条已知的运行时警告：SQLite 实验特性、`module.register()` 弃用、`NODE_TLS_REJECT_UNAUTHORIZED`、AI SDK 的警告提示（`apps/zcode-cli/packages/cli/src/runtime-warnings.ts:8`）。
3. `interceptTuiStderr` 接管 `process.stderr.write`，写入只进一个保留最后 65536 个字符的缓冲，不上屏（`apps/zcode-cli/packages/cli/src/tui-stderr.ts:58`）：

```ts
  // TUI owns the full terminal. Raw stderr emitted during startup, including
  // Node runtime warnings from static imports, corrupts the screen.
  stderr.write = ((chunk, encodingOrCallback, callback) => {
    const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;
    bufferedOutput = (bufferedOutput + stringifyChunk(chunk, encoding)).slice(
      -MAX_BUFFERED_CHARACTERS,
    );

    const writeCallback = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
    if (writeCallback) queueMicrotask(() => writeCallback());

    return true;
  }) as NodeJS.WriteStream["write"];
```

CLI 自己要给人看的错误写到一个 `passthrough` 流，绕过拦截（`tui-stderr.ts:56`、`main.ts:38`）。进程收尾时 `restore()` 还原写入函数，但调用时没有传 `flush: true`（`main.ts:96`），从代码看缓冲里的内容就此丢弃。判断是不是 TUI 调用的 `isTuiInvocation` 与 run 共用同一份参数定义：没有 `--help`、`--version`、`-p`、`--target`，且第一个位置参数缺省或为 `tui`（`tui-stderr.ts:11`）。入口进程边界的全貌见[命令行入口、无头模式与打包](https://daiw.org/manual/zcode/cli-surface)。

下一篇：[ZCode Protocol V4：Agent 对外的线协议](https://daiw.org/manual/zcode/zcode-protocol)——桌面端与 Web 不和运行时同进程，它们怎样经 stdio 驱动 Agent 子进程，命令怎样受理、快照怎样续传。
