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

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

作者 David更新于 第 30 篇(共 33 篇)

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

怎么用

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+Enterrun.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+Tabcomposer.toggle-plan(shell/keybindings.ts:405)有待处理的审批或提问时拒绝切换(input-flow.ts:406)
Alt+Mcomposer.cycle-permission(shell/keybindings.ts:413)Ask、Auto、Full access 三档循环(packages/tui/src/application/permission-mode.ts:1)
Escapp.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,运行时怎么组装见进程内入口)。类型上方的注释说明了取舍:

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位置负责
TuiChatControllercontroller/chat-controller.ts会话列表与当前会话,发起回合,把流事件交给转录投影
TuiRuntimeEventFlowcontroller/runtime/runtime-event-flow.ts订阅运行时事件,断线重连,接管不是本端发起的回合
TuiInputFlow、TuiCommandFlowcontroller/interaction/、controller/product/全局按键;提交、斜杠命令、! 命令与失败恢复
TuiInteractionFlowcontroller/interaction/interaction-flow.ts审批卡片与提问卡片
TuiActiveRunFlow、TuiQueueFlowcontroller/run/补全与帮助;排队与转向
TuiSessionFlow、TuiSessionMutationFlowcontroller/session-flow.ts 等切换、旁路会话、分叉、回退、编辑
TuiFeatureFlowcontroller/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 记着当前是紧凑还是详细模式。

从事件到转录单元

图表加载中…

回合事件有两个来源。本端提交的消息由 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.”。规则怎样匹配、三种权限模式的差别,见权限模式与规则。

差异审阅有两处。一是上面说的:审批时看到的是“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 是拒绝而不是关闭。遥测、更新、签到与反馈的实现见遥测、更新与诊断。

下一篇:Headless 与 ACP——同一套运行时不开界面时怎样跑,mcode exec 与 ACP 服务的协议适配。

本页目录