终端界面
不带参数运行 zcode 打开的全屏 TUI:OpenTUI 之上的 React 渲染与同进程直调,界面分区、输入框与全部快捷键,斜杠命令与切换面板,Markdown 与 diff 渲染,审批与问卷,侧边栏的 MCP 与子 Agent 观察,以及为什么要拦截 stderr。
不带子命令运行 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 才经子进程和协议(下一篇)。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 的仓库全景里,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)。打包本身见命令行入口、无头模式与打包。
同进程直调: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:把运行时拼起来。数据通路有三条。
提交。空闲时的输入先进斜杠命令分发器 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):
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):
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)。
界面分区
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)。受理规则见输入受理、命令队列与引导。 - 复制:鼠标松开时若有选中文本就复制(
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)。每条命令在哪一层执行、会不会进模型,见技能与自定义命令。
模型、推理强度与模式各有一个专用面板,敲到对应前缀就出现:
| 草稿 | 数据来源 | 选中后提交 |
|---|---|---|
/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):
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 其实是独立开关这件事,见权限模式与规则。
转录区: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),见动态工作流(三)。
流式输出:一个 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):
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 模式,规则匹配与“始终允许”的持久化见权限模式与规则。
侧边栏: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。
子代理 一节按 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。
业务状态不在 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)。
与 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 三节,目前只能用鼠标。
为什么要拦截 stderr
TUI 独占整个终端,渲染器按自己的缓冲逐格刷新屏幕,任何绕过它直接写进终端的字节都会插在光标所在处,把画面打乱。main.ts 在加载业务模块之前为 TUI 装了三层(apps/zcode-cli/packages/cli/src/main.ts:24、main.ts:34):
- 全局
console换成 stdout 与 stderr 都指向 stderr 的新Console(apps/zcode-cli/packages/cli/src/protocol-console.ts:10),注释点名 AI SDK 的第一条提示用的是console.info(main.ts:33)。 - 滤掉几条已知的运行时警告:SQLite 实验特性、
module.register()弃用、NODE_TLS_REJECT_UNAUTHORIZED、AI SDK 的警告提示(apps/zcode-cli/packages/cli/src/runtime-warnings.ts:8)。 interceptTuiStderr接管process.stderr.write,写入只进一个保留最后 65536 个字符的缓冲,不上屏(apps/zcode-cli/packages/cli/src/tui-stderr.ts:58):
// 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)。入口进程边界的全貌见命令行入口、无头模式与打包。
下一篇:ZCode Protocol V4:Agent 对外的线协议——桌面端与 Web 不和运行时同进程,它们怎样经 stdio 驱动 Agent 子进程,命令怎样受理、快照怎样续传。