TUI 主循环与事件分发
App 是 codex 终端 UI (TUI) 的顶层结构,它持有 ChatWidget、Tui(ratatui 终端)、AppServerSession 三方句柄,把终端按键、app-server 事件流、内部 AppEvent 队列汇成一个 select! 循环。这一层不做渲染也不跑模型,它只决定"下一个该处理的事件来自谁、要不要退出"。
职责
- 启动入口
run_main解析Cli(prompt、--ask-for-approval、--no-alt-screen等),装载配置并 bootstrap app-server,然后把控制权交给App::run(codex-rs/tui/src/lib.rs:908-957)。 - 主
select!同时等四个源:AppEvent内部消息、当前 thread 的事件流、终端TuiEvent、app-server 通知流。每条分支把事件交给对应 handler 并返回AppRunControl::Continue或Exit(codex-rs/tui/src/app.rs:1185-1244)。 AppEvent是 widget 与顶层之间唯一的 message bus,变体从NewSession、OpenResumePicker到ConsolidateAgentMessage都覆盖,widget 因此不需要直接拿App内部句柄 (codex-rs/tui/src/app_event.rs:179-295)。- 退出分两档:
ExitMode::ShutdownFirst先发Op::Shutdown等核心收尾,ExitMode::Immediate直接跳出循环,跳过 shutdown (codex-rs/tui/src/app_event.rs:1135-1148)。 - 终端
Drop时调用tui.terminal.clear()、清 ambient pet 图,即使中途出错也保证恢复原屏幕 (codex-rs/tui/src/app.rs:1246-1266)。
设计动机
早期 codex TUI 把渲染、按键、app-server 通知混在一个 loop 里写,后来拆出 AppEvent 总线,原因是 widget 树深处经常需要触发"开 picker、改 config、关线程"这种只有顶层才能做的事。如果让 widget 直接持 AppServerSession 句柄,组件树会反向依赖顶层状态。引入 AppEventSender(就是个 unbounded_channel 的 clone-able sender)后,widget 只能"发事件",决策权留顶层——单向依赖,好测好替换。
四路 select! 里 app-server 流是带 if 守卫的:should_handle_active_thread_events 决定何时把 active thread 通道接进来,避免线程没启动时白白被 recv() 阻塞。退出顺序也有讲究:终端输入流一旦关闭先尝试 ShutdownFirst,而不是直接 break——这样 SSH 断线时核心还能跑完收尾。
ExitMode 拆两档是因为有些路径(比如 arg0 切换、fatal error)已经知道核心要么关了要么不用等,直接 Immediate 跳出更稳;普通 Ctrl+D / /quit 则走 ShutdownFirst,给 Op::Shutdown 一个 2 秒超时窗口。
关键文件
codex-rs/tui/src/main.rs:50-83 — 二进制入口,TopCli::parse 后调 run_main,退出时打印 token usage 和 resume hint。codex-rs/tui/src/cli.rs:8-76 — Cli 结构,把 prompt、--ask-for-approval、--no-alt-screen、resume/fork 内部字段都收在一起。codex-rs/tui/src/app.rs:766-787 — App::run 签名,把 18 个启动参数收口到一处,是整个 TUI 的主入口。codex-rs/tui/src/app.rs:1185-1244 — 主 select! 循环,四路事件分发与 AppRunControl 决策。codex-rs/tui/src/app_event.rs:179-295 — AppEvent 枚举前半部分,widget 与顶层之间的消息契约。codex-rs/tui/src/app/event_dispatch.rs:18-80 — handle_event 把 AppEvent 大 match 拆到 submodule,自己只做路由。codex-rs/tui/src/tui.rs:542-566 — Tui 结构体,持 terminal、frame requester、event broker、notification backend。codex-rs/tui/src/tui.rs:895-968 — Tui::draw 用 stdout().sync_update 做原子重绘,处理 viewport resize 与 pending history lines。主循环里四个分支都是 match Box::pin(app.handle_event(...)).await { Ok(control) => control, Err(err) => break Err(err) },任何一路出错都直接跳出,然后由外层 app_server.shutdown() 兜底:
// app.rs:1185-1192 — AppEvent 优先,出错即跳出
Some(event) = app_event_rx.recv() => {
match Box::pin(app.handle_event(tui, &mut app_server, event)).await {
Ok(control) => control,
Err(err) => break Err(err),
}
}终端事件分支里 Draw / Resize 是高频路径,会先跑 pre_draw_tick(让 widget 处理计时器和 paste burst),再 render_chat_widget_frame 真正重绘;按键和 paste 才走 chat_widget 的输入处理。Draw 与 Resize 共用同一分支是因为它们都只需要一次重绘:
// app.rs:1307-1321 — Draw/Resize 共用重绘路径
TuiEvent::Draw | TuiEvent::Resize => {
if self.backtrack_render_pending {
self.rebuild_transcript_after_backtrack(tui)?;
self.backtrack_render_pending = false;
}
self.chat_widget.maybe_post_pending_notification(tui);
if self.chat_widget.handle_paste_burst_tick(tui.frame_requester()) {
return Ok(AppRunControl::Continue);
}
self.chat_widget.pre_draw_tick();
let rendered_area = self.render_chat_widget_frame(tui)?;ExitMode 的语义直接写在 doc 里:ShutdownFirst 等 Op::Shutdown 完成,Immediate 是已经知道不用等的逃生口——比如 arg0 切换或 fatal error:
// app_event.rs:1140-1148 — ExitMode 区分是否等核心收尾
pub(crate) enum ExitMode {
ShutdownFirst,
/// Exit the UI loop immediately without waiting for shutdown.
Immediate,
}数据流
边界与失败
- 终端输入流关闭不等同于用户主动退出:输入流
None时走ExitMode::ShutdownFirst,让核心收尾,而不是直接 break (codex-rs/tui/src/app.rs:1218-1222)。 - app-server 事件流断开只关流不退出:
listen_for_app_server_events = false后循环继续,只是不再收这一路通知;真正的退出仍由AppEvent::Exit触发 (codex-rs/tui/src/app.rs:1223-1232)。 ShutdownFirst带超时:外层handle_exit_mode给核心 2 秒收尾窗口,超时也强制退出,避免卡死 (codex-rs/tui/src/app/event_dispatch.rs:15-16)。- paste 的 CR/LF 归一化:iTerm2 等终端粘贴时把
\n转成\r,handle_tui_event里显式pasted.replace("\r", "\n")才能喂给 tui-textarea (codex-rs/tui/src/app.rs:1299-1306)。 App::drop兜底清理:即使run中途 panic,Drop也会调tui.clear_ambient_pet_image与terminal.clear,屏幕不会被残留状态污染 (codex-rs/tui/src/app.rs:1394-1396)。
小结
App::run 是一个纯调度层:四个事件源 → select! → 各自 handler → AppRunControl。真正的渲染逻辑在 聊天组件与渲染,app-server 事件如何被翻译成 AppEvent 则走 App-server 架构。Cli 字段里那一堆 resume_* / fork_* 是给 codex resume / codex fork 子命令用的内部通道,不暴露给用户。