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