TUI メインループとイベントディスパッチ
App は codex ターミナル UI (TUI) のトップレベル構造で、ChatWidget、Tui(ratatui 端末)、AppServerSession の三者のハンドルを持ち、ターミナルキー、app-server イベントストリーム、内部 AppEvent キューを一つの select! ループに集める。このレイヤーはレンダリングもモデル実行も行わず、「次に処理すべきイベントが誰から来るか、終了すべきか」だけを決める。
責務
- 起動入口
run_mainがCli(prompt、--ask-for-approval、--no-alt-screenなど)をパースし、設定を読み込んで app-server を bootstrap し、制御を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を送って core の後処理を待ち、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 を変える、thread を閉じる」のようなトップレベルしかできない操作を発火する必要がよくあったからだ。widget に直接 AppServerSession ハンドルを持たせると、コンポーネントツリーがトップレベル状態に逆依存する。AppEventSender(clone 可能な unbounded_channel の sender)を導入することで、widget は「イベントを送る」しかできず、意思決定権はトップレベルに残る——一方向依存で、テストも差し替えも容易だ。
四路 select! のうち app-server ストリームは if ガード付きだ:should_handle_active_thread_events がいつ active thread チャネルを接続するかを決め、thread が起動していない時に無駄に recv() でブロックされるのを防ぐ。終了順序も工夫がある:ターミナル入力ストリームが閉じたら直接 break するのではなく、まず ShutdownFirst を試す——これで SSH が切れた時も core が後処理を走り切れる。
ExitMode が二段階なのは、一部のパス(arg0 切り替え、fatal error など)では core がすでに閉じているか待つ必要がないと分かっており、直接 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 のセマンティクスはドキュメントに直接書かれている: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に進み、core に後処理をさせ、直接 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が core に 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 サブコマンド用の内部チャネルで、ユーザには見せない。