在当前 Codex 架构里,“TUI 把按键翻译成 Op”只是一个便于记忆的简称,严格说并不准确。TUI
已经是 App Server 的客户端:控件先生成 AppCommand,App 再把它翻译成 typed
ClientRequest,App Server 最后才提交 Core Op、调用 steer_input,或完成一个正在等待的
审批回调。
这层间接性不是多余包装。它让同一套 Core 可以服务 TUI、IDE 与其他 JSON-RPC 客户端,也让 TUI 的 草稿恢复、弹窗、乐观渲染和快捷键语义留在界面层,而不会泄漏进 Runtime 协议。
五层翻译,而不是一次 enum 转换
一条用户动作要穿过五个边界:
- Interaction/Slash dispatch 判断按键属于编辑、弹窗、命令还是提交;
- ChatWidget 把草稿解析成
UserInput,冻结本次 Turn 上下文; AppCommand表达与具体控件无关的 TUI 动作;AppEvent::CodexOp把动作交给 App 的串行事件循环;- Thread routing 选择
turn/start、turn/steer、turn/interrupt等 App Server 方法。
AppCommand 覆盖 UserTurn、Interrupt、Review、Compact、UserShell、设置覆盖以及各种审批答案。它的价值
在于切断控件与传输的直接依赖:ChatWidget 不必持有 AppServerSession,App 也不必理解 composer 如何
保存 mention binding。
普通提交先过输入门
submit_user_message_with_history_and_shell_escape_policy 是普通输入的关键收口。它不是“拿到字符串就
发送”,而是按顺序处理:
- SessionConfigured 尚未到达:把原消息和 history policy 放回队首;
- 文本和图片都为空:拒绝;
- 当前模型不支持图片:恢复文本、text elements、local/remote images 和 mention bindings;
- 文本以
!开头且允许 shell escape:转为RunUserShellCommand; - 其他输入:组装 Image、LocalImage、Text、Skill 和 Mention 项。
这里恢复“完整草稿”很重要。若只恢复显示文本,用户选中的 skill/plugin/app mention 会退化为普通
$name 字符,重试时语义已经变化。
def accept_composer_message(widget, message):
if not widget.session_configured:
widget.queue_front(message)
return ACCEPTED_BUT_DEFERRED
if message.has_no_text_or_images():
return REJECTED
if message.has_images() and not widget.model_supports_images():
widget.restore_draft_with_bindings(message)
return REJECTED
if message.text.startswith("!") and widget.shell_escape_allowed:
return emit(AppCommand.run_user_shell(message.text[1:].strip()))
items = map_images_and_text(message)
items += resolve_skill_plugin_app_mentions(message)
widget.inject_ide_context(items)
return build_user_turn(items, widget.effective_turn_settings())
UserTurn 冻结哪些上下文
AppCommand::UserTurn 除 items 外还携带 cwd、approval policy、active permission profile、model、
reasoning effort、summary、service tier、final output schema、collaboration mode 和 personality。
这些字段选择的是“本次提交看到的有效值”。用户按下 Enter 后即使马上切模型,也不能让已经排队的消息
偷偷改用新模型。App routing 随后把权限 profile 与 runtime workspace roots materialize 成 App Server
TurnStartParams。
Personality 还有双重门:配置 Feature 必须启用,当前模型也必须声明支持。Collaboration mode 同样只在 相关能力启用时发送。界面里能选择某项,不代表请求可以无条件带上它。
Mention 不是字符串替换
TUI 先从文本中收集 tool mention,再与明确的 mention_bindings 合并:
skill://path解析为UserInput::Skill {name,path};plugin://config_name解析为 Mention;app://id只有 connector 存在且可 mention 时才接受;- 绑定与自动扫描都用集合去重,避免一个对象被发送两次。
跨 Session 的消息历史不能只存展开后的对象,因此 TUI 把绑定编码为可恢复的 placeholder;用户从历史 召回文本时,composer 能重建 mention 与路径的对应关系。
为什么先画出用户消息
AppEvent 在同一个循环中串行处理,而远程 turn/start 可能等待连接、环境或网络。为避免用户按下
Enter 后界面像“没反应”,App-event 路径会先把 prompt 乐观写入 transcript,再发请求。
这只是显示承诺,不是 Runtime 确认。TUI 同时保留 safety buffer;如果 turn/start 被服务端拒绝,
handle_turn_start_rejection 负责恢复状态并显示错误。直接提交路径不共享这条 App 事件队列,因此保留
原来的失败处理。
async def submit_from_widget(message):
command = parse_and_freeze(message)
if command.is_new_user_turn:
transcript.render_optimistically(message)
safety_buffer.remember(message)
try:
await app.submit_active_thread_command(command)
except TurnStartRejected as error:
transcript.reconcile_rejected_prompt(error)
Start 与 Steer 的竞态
同一个用户动作是新 Turn 还是 steering,不由 ChatWidget 最终决定。App 查询每个 Thread 的本地 event
store:有 active turn id 时先发 turn/steer(expectedTurnId),没有才发 turn/start。
本地缓存来自异步 notification,天然可能落后于服务端。因此路由显式处理三个竞态:
- 服务端返回 NoActiveTurn:旧 Turn 已结束但通知未到,清掉缓存并 fallback 到 start;
- ExpectedTurnMismatch:Review 等流程已经换了 Turn,写入 actual id 并仅重试一次;
- ActiveTurnNotSteerable:Review/Compact 正在运行,尝试把消息排队;不能排队则显示结构化错误。
async def route_user_turn(thread_id, command):
cached = store.active_turn_id(thread_id)
if cached:
retried = False
while True:
try:
return await app_server.turn_steer(thread_id, cached, command.items)
except NoActiveTurn:
store.clear_active_turn_id(thread_id)
break
except ExpectedTurnMismatch as error:
store.set_active_turn_id(thread_id, error.actual)
if retried:
raise
cached, retried = error.actual, True
except NonSteerableTurn as error:
return widget.queue_or_report_rejected_steer(error)
return await app_server.turn_start(thread_id, command.to_params())
若客户端无限重试 mismatch,它可能永远追逐一个快速切换的活动 Turn;“最多一次”把这类竞态变成有界 失败,而不是 UI 卡死。
Interrupt 为什么也要防竞态
Escape/Ctrl-C 不会无条件发送 Interrupt。Interaction 层先让 overlay、popup 或 composer 消费按键;只有
确有 cancellable work 时才生成 AppCommand::Interrupt。
路由读取 active turn id,并在 store 中记录 pending_interrupt_turn_id,同一 Turn 的重复按键不会并发
发多个请求。若 App Server 报告实际活动 Turn 已更换,TUI也只用 actual id 重试一次。
这条请求的等待时间可能比普通设置请求长,因为 App Server 要等 Core 真正产生 TurnAborted 才响应。 所以实现把它放入 Tokio task,避免 App 主事件循环被阻塞。
Slash command 不是一种统一协议
斜杠命令会按职责分流:
/compact→AppCommand::Compact→thread/compact/start;/review→ Review →review/start;/model等界面命令可能只打开 picker;- 设置变化形成 OverrideTurnContext 或专用配置请求;
!cmd走thread/shellCommand,不调用模型。
因此不能从“命令在输入框里出现”推断它会成为 UserInput,也不能从 AppCommand 名称推断最终一定提交 Core Op。有些动作只改变本地 UI,有些调用 App Server 状态 API,有些才映射为 Op。
审批是从服务端反向发起
命令执行审批、patch 审批、request_user_input 和 MCP elicitation 的方向与普通输入相反:Core 先产生 事件,App Server 将其投影为 JSON-RPC server request,TUI 建立弹窗。
PendingAppServerRequests 保存 request id 与 approval/call/turn 的关联。用户点击 Allow 后形成
ExecApproval 等 AppCommand,但 App 会优先查 pending request,完成原 server request 的 callback;
它不是另发一个无关联 notification。
async def answer_approval(command):
pending = requests.take_by_approval_id(command.id)
if pending is None:
return show_stale_approval_warning()
response = map_decision(command.decision)
await pending.jsonrpc_callback.respond(response)
Turn Started/Complete 会取消仍未回答的反向请求。否则旧 Turn 的“允许执行”可能在新 Turn 中唤醒错误的 waiter,这是权限系统最危险的一类时序错误。
Embedded 与 Remote 参数并非完全相同
AppServerSession 支持进程内 Embedded 和 Remote 两种连接。二者使用同一 typed ClientRequest,
但 Thread 参数模式不同:Remote 不应传递只对本机有意义的 model provider config 等嵌入式对象。
“同一协议类型”保证语义一致,“参数裁剪”保证部署边界正确。若把本地配置对象完整序列化到远端,既会 暴露不该跨进程的信息,也可能让远端尝试解释本地路径。
失败与恢复矩阵
| 位置 | 失败 | TUI 行为 |
|---|---|---|
| Session 未配置 | 提交过早 | 原消息放回队首 |
| 模型不支持图片 | 能力校验失败 | 恢复完整草稿与绑定 |
| model 为空 | Thread 尚未同步 | 报错并恢复 composer |
| turn/start 被拒 | 服务端校验失败 | reconcile 乐观 prompt |
| steer 无 active turn | 通知滞后 | 清缓存,改发 start |
| steer ID mismatch | Turn 被替换 | actual id 重试一次 |
| Review/Compact steer | 非可 steering Task | 排队或显示错误 |
| 重复 interrupt | 同一 Turn 已 pending | 静默去重 |
| 审批请求已取消 | 回答过期 | 不向 Core 发送决定 |
应锁定的测试
async def test_stale_active_turn_falls_back_to_start(app):
app.store.active_turn_id = "old"
app.server.steer_returns(NoActiveTurn())
await app.submit("next")
assert app.server.calls == ["turn/steer(old)", "turn/start"]
async def test_unsupported_image_restores_bindings(widget):
draft = message("$skill", image="a.png", binding="skill://x/SKILL.md")
assert not widget.submit(draft)
assert widget.composer.binding("$skill") == "skill://x/SKILL.md"
async def test_duplicate_interrupt_is_suppressed(app):
app.store.pending_interrupt_turn_id = "t1"
await app.interrupt("t1")
assert app.server.interrupt_call_count == 0
TUI 的核心职责不是把所有动作硬塞进一个 Op enum,而是保持 UI 状态、App Server 状态与 Core 状态
最终收敛。分层协议、结构化竞态错误和乐观 UI 的补偿路径,共同实现了这件事。
评论
登录后即可评论