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 子命令用的內部通道,不暴露給使用者。