Skip to content

TUI 主循环与事件分发

源码版本rust-v0.145.0

App 是 codex 终端 UI (TUI) 的顶层结构,它持有 ChatWidgetTui(ratatui 终端)、AppServerSession 三方句柄,把终端按键、app-server 事件流、内部 AppEvent 队列汇成一个 select! 循环。这一层不做渲染也不跑模型,它只决定"下一个该处理的事件来自谁、要不要退出"。

职责

  1. 启动入口 run_main 解析 Cli(prompt、--ask-for-approval--no-alt-screen 等),装载配置并 bootstrap app-server,然后把控制权交给 App::run (codex-rs/tui/src/lib.rs:908-957)。
  2. select! 同时等四个源:AppEvent 内部消息、当前 thread 的事件流、终端 TuiEvent、app-server 通知流。每条分支把事件交给对应 handler 并返回 AppRunControl::ContinueExit (codex-rs/tui/src/app.rs:1185-1244)。
  3. AppEvent 是 widget 与顶层之间唯一的 message bus,变体从 NewSessionOpenResumePickerConsolidateAgentMessage 都覆盖,widget 因此不需要直接拿 App 内部句柄 (codex-rs/tui/src/app_event.rs:179-295)。
  4. 退出分两档:ExitMode::ShutdownFirst 先发 Op::Shutdown 等核心收尾,ExitMode::Immediate 直接跳出循环,跳过 shutdown (codex-rs/tui/src/app_event.rs:1135-1148)。
  5. 终端 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-76Cli 结构,把 prompt、--ask-for-approval--no-alt-screen、resume/fork 内部字段都收在一起。codex-rs/tui/src/app.rs:766-787App::run 签名,把 18 个启动参数收口到一处,是整个 TUI 的主入口。codex-rs/tui/src/app.rs:1185-1244 — 主 select! 循环,四路事件分发与 AppRunControl 决策。codex-rs/tui/src/app_event.rs:179-295AppEvent 枚举前半部分,widget 与顶层之间的消息契约。codex-rs/tui/src/app/event_dispatch.rs:18-80handle_eventAppEvent 大 match 拆到 submodule,自己只做路由。codex-rs/tui/src/tui.rs:542-566Tui 结构体,持 terminal、frame requester、event broker、notification backend。codex-rs/tui/src/tui.rs:895-968Tui::drawstdout().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() 兜底:

rust
// 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 的输入处理。DrawResize 共用同一分支是因为它们都只需要一次重绘:

rust
// 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 里:ShutdownFirstOp::Shutdown 完成,Immediate 是已经知道不用等的逃生口——比如 arg0 切换或 fatal error:

rust
// 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_imageterminal.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 子命令用的内部通道,不暴露给用户。