终端界面

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

作者 David更新于 42 篇(共 47 篇)

不带子命令运行 zcode,命令名缺省就是 tuiapps/zcode-cli/packages/cli/src/run.ts:65),打开一个占满终端的界面。代码分两块:界面本身在 apps/zcode-cli/packages/tui,91 个源文件、约 1.37 万行,入口只导出一个 runTuiapps/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-core0.2.15终端渲染器、Yoga 布局,以及 textareamarkdowncode 等内建元素
@mbears/opentui-react0.2.15React reconciler:createRootuseKeyboarduseTerminalDimensions
react19.2.5组件与状态
shiki^4.1.0diff 视图的语法高亮
web-tree-sitter0.25.10opentui-core 的 peer 依赖(apps/zcode-cli/pnpm-lock.yaml:995),TUI 源码里没有直接引用
react-devtools-corews7.0.1、8.18.0opentui-react 的 peer 依赖(pnpm-lock.yaml:1000

@mbears/opentui-* 是不是 OpenTUI 的分叉?仓库里的线索是这些:包名不在上游的 @opentui/* 作用域(站内 OpenCode 的仓库全景里,OpenCode 用的是 @opentui/core@opentui/solid);第三方清单却把两个包的源码仓库都记成 https://github.com/anomalyco/opentuithird-party/inventory.json:4778),许可证的版权人写的是 opentui(THIRD-PARTY-NOTICES.md:4609),apps/zcode-cli/skills-lock.json:4 还从同一个仓库装了 opentui 的编码技能。lockfile 里 @mbears/opentui-core 声明 engines: node >=22pnpm-lock.yaml:994),依赖 yoga-layoutmarkeddiffbun-ffi-structs,可选依赖是六个平台的原生包,外加 Node 的 FFI 库 koffiunsafe-pointerpnpm-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: falseCtrl+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 的 boxtexttextareascrollboxmarkdowncode 以字符串类型出现。构建是 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~/.cacheapps/zcode-cli/packages/cli/src/tui-runtime-loader.ts:35tui-runtime-loader.ts:120)。打包本身见命令行入口、无头模式与打包

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

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

处理器持有唯一的 ZCodeApp,第一次用到才调 bootstrap 的 createZCodeAppapps/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:把运行时拼起来。数据通路有三条。

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

审批。传给 createZCodeApppermissionBroker 只是个转发器,把请求交给当前这次提交登记的处理函数,没有就拒绝(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);
    },
  };

submitPromptsendInput 在调用期间把 TUI 传来的 requestPermission 设为当前处理函数,返回后还原(tui-prompt-handler.ts:304tui-prompt-handler.ts:318)。

事件。每次提交带一个回合级 onEvent,另有一条跨回合的常驻订阅。types.ts:231 的注释讲了第二条的理由:动态工作流的进度、后台完成通知驱动的回合都是“回合之外”的事件,回合级回调在回合结束时就失效了。常驻订阅由 createTuiSessionEventRelay 挂在 runtime.subscribeEvents 上,换 App 时统一重挂(tui-prompt-handler.ts:129apps/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:5app-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:85app-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 列目录得出:拒绝绝对路径和含 .. 的路径,跳过 .gitnode_modules 等 7 个目录,没敲 . 时隐藏点文件,目录在前、前缀匹配在前,最多 50 条(apps/zcode-cli/packages/cli/src/tui-workspace-paths.ts:9tui-workspace-paths.ts:72),面板一次显示 8 条(app-file-mentions.ts:8)。TabEnter 采纳:目录只补全文本、继续往下挑;文件写成 @路径 并登记为附件(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-pastexclip 的 PNG、JPEG、GIF、WebP,Windows 用 PowerShell 的 Get-Clipboard -Format Image,单张上限 20 MiB(apps/zcode-cli/packages/cli/src/clipboard-image.ts:7clipboard-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),附件随历史一起恢复。
  • 忙碌时提交:走 sendInputdeliveryauto 并带上当前回合 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_drainedturn_steer_discarded 事件后才从队列移除(apps/zcode-cli/packages/tui/src/app-events.ts:93)。受理规则见输入受理、命令队列与引导
  • 复制:鼠标松开时若有选中文本就复制(app-view.tsx:102)。写剪贴板同时发 OSC 52 序列和调本机命令(pbcopywl-copyxclipxsel,PowerShell 的 Set-Clipboard),上限 1 MiB(apps/zcode-cli/packages/cli/src/clipboard-text.ts:4clipboard-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 前缀、输入区其余按键,前一层吃掉的键后一层看不到。输入区:

按键作用出处
EnterShift+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:312app-keyboard.ts:333
Esc@ 面板打开时先关它;再依次关掉模型、推理强度、模式、斜杠候选;都没有时中断正在进行的输出app-file-mentions.ts:118app-keyboard.ts:281
Ctrl+C有选中文本先复制,有草稿就清空;空草稿时 2 秒内连按两次退出app-keyboard.ts:201app-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 秒内接 BMA显示或隐藏侧边栏;展开或收起改动文件、API 两节apps/zcode-cli/packages/tui/src/app-sidebar-shortcut.ts:1

面板里的按键:

场景按键出处
审批面板 选,Enter 确认,Esc 拒绝,其余键被吞掉apps/zcode-cli/packages/tui/src/app-approval.ts:14
问卷面板kj 移动,19 直选,Space 切换多选,o 自填答案,s 跳过,Enter 下一题,Esc 拒答;答完进入复核页,Enter 提交,Tab 回到第一题,PageUp 回到最后一题apps/zcode-cli/packages/tui/src/app-question-state.ts:25app-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+CCtrl+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:257app-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:20app-model.ts:30)。

  • 助手文本MarkdownText 优先用 OpenTUI 的 markdown 元素,开 conceal 隐藏标记符,回合进行中传 streamingapps/zcode-cli/packages/tui/src/app-markdown.tsx:43)。配色是一张按 markup.headingmarkup.raw.inlinekeyword 这类作用域写的主题表(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:13app-thought-components.tsx:49)。
  • 工具:每个调用一行标题,加最多 4 行参数摘要,键名像 token、secret、password、api key 的参数值不显示(apps/zcode-cli/packages/tui/src/app-tool-transcript.ts:9app-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:7app-shiki-diff-view.tsx:96)。语言按扩展名推断,主题是 github-darkgithub-light,高亮超过 2.5 秒就放弃、退回无色文本,结果按主题、语言与内容缓存(apps/zcode-cli/packages/tui/src/app-shiki-highlighter.ts:10app-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_deltareasoning_deltaassistantMessageId 追加到对应消息的最后一个文本或思考片段,“文本、工具、文本”的交错因此保持模型原本的顺序(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:31app-view.tsx:192)。几条规则:

  • 三个选项依次是 Allow once、Always allow in this project、Deny,默认高亮的是 Denyapp-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(),
  };
}
  • CreateWorkflowAmendWorkflow 在 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:77apps/zcode-cli/packages/cli/src/tui-command.ts:31)。Todo 取自 TodoWrite 工具结果里的 JSON,最多列 6 条(app-events.ts:355app-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:5tui-prompt-handler-queries.ts:61),没有可用模型时不查(app-view.tsx:110)。服务器按名字排序、最多 5 行,每行是状态、名字、传输方式与工具数,connected、connecting、failed、untrusted 分别用成功、强调、危险、警告四种主题色(apps/zcode-cli/packages/tui/src/app-sidebar-mcp.tsx:16app-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:23app-subagents.ts:42
只读视图的按键先于输入框与审批app-keyboard.ts:141
主会话有待处理交互时提示返回显示“主会话需要你的输入,返回后处理”(apps/zcode-cli/packages/tui/src/app-subagent-view.tsx:53

另外两个兜底:缓存超过 4096 条,或者序号出现断档,就整条重新打开(app-subagents.ts:144app-subagents.ts:146)。子 Agent 本身的机制见子 Agent

业务状态不在 TUI

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

  • 接口是回调式的,TuiOptions 的注释写明运行时的所有权留在 CLI(types.ts:299)。
  • 模式如上所示,以运行时返回为准;模型与推理强度来自 model_selected 事件和每次结果附带的元数据(app-events.ts:286tui-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:12app-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:24main.ts:34):

  1. 全局 console 换成 stdout 与 stderr 都指向 stderr 的新 Consoleapps/zcode-cli/packages/cli/src/protocol-console.ts:10),注释点名 AI SDK 的第一条提示用的是 console.infomain.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):
  // 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:56main.ts:38)。进程收尾时 restore() 还原写入函数,但调用时没有传 flush: truemain.ts:96),从代码看缓冲里的内容就此丢弃。判断是不是 TUI 调用的 isTuiInvocation 与 run 共用同一份参数定义:没有 --help--version-p--target,且第一个位置参数缺省或为 tuitui-stderr.ts:11)。入口进程边界的全貌见命令行入口、无头模式与打包

下一篇:ZCode Protocol V4:Agent 对外的线协议——桌面端与 Web 不和运行时同进程,它们怎样经 stdio 驱动 Agent 子进程,命令怎样受理、快照怎样续传。

本页目录