Skip to content

TUI Main Loop and Event Dispatch

源码版本rust-v0.145.0

App is the top-level structure of codex's terminal UI (TUI). It holds the ChatWidget, Tui (ratatui terminal), and AppServerSession三方 handles, funneling terminal keypresses, the app-server event stream, and an internal AppEvent queue into a single select! loop. This layer does not render or run the model; it only decides "whose event is next, and whether to exit".

Responsibilities

  1. The startup entry run_main parses the Cli (prompt, --ask-for-approval, --no-alt-screen, etc.), loads config and bootstraps app-server, then hands control to App::run (codex-rs/tui/src/lib.rs:908-957).
  2. The main select! waits on four sources simultaneously: the AppEvent internal queue, the current thread's event stream, terminal TuiEvents, and the app-server notification stream. Each branch hands the event to its handler and returns AppRunControl::Continue or Exit (codex-rs/tui/src/app.rs:1185-1244).
  3. AppEvent is the only message bus between widgets and the top level; variants range from NewSession, OpenResumePicker to ConsolidateAgentMessage, so widgets do not need to hold App internal handles directly (codex-rs/tui/src/app_event.rs:179-295).
  4. Exit has two modes: ExitMode::ShutdownFirst sends Op::Shutdown and waits for core to wind down; ExitMode::Immediate jumps out of the loop directly, skipping shutdown (codex-rs/tui/src/app_event.rs:1135-1148).
  5. On terminal Drop, tui.terminal.clear() is called and ambient pet images are cleared; even mid-error the original screen is restored (codex-rs/tui/src/app.rs:1246-1266).

Design motivation

Early codex TUI mixed rendering, keypresses, and app-server notifications in one loop. Later the AppEvent bus was carved out because widgets deep in the tree often needed to trigger "open picker, change config, close thread" — things only the top level could do. If widgets held AppServerSession handles directly, the component tree would reverse-depend on top-level state. Introducing AppEventSender (just a clone-able unbounded_channel sender) means widgets can only "send events"; decisions stay at the top — one-way dependencies, easier to test and replace.

The four-way select! guards the app-server stream with an if: should_handle_active_thread_events decides when to wire in the active-thread channel, avoiding blocking on recv() when no thread is running. Exit ordering is also deliberate: once the terminal input stream closes, it tries ShutdownFirst first instead of breaking directly — so on SSH disconnect core can still finish winding down.

ExitMode is split into two modes because some paths (like arg0 switching, fatal errors) already know core is either shut down or not worth waiting for; Immediate jumping out is safer. A normal Ctrl+D / /quit goes through ShutdownFirst, giving Op::Shutdown a 2-second timeout window.

Key files

codex-rs/tui/src/main.rs:50-83 — the binary entry; after TopCli::parse, calls run_main; on exit prints token usage and the resume hint.codex-rs/tui/src/cli.rs:8-76 — the Cli struct, collecting prompt, --ask-for-approval, --no-alt-screen, and resume / fork internal fields.codex-rs/tui/src/app.rs:766-787 — the App::run signature, folding 18 startup parameters into one place — the main entry of the whole TUI.codex-rs/tui/src/app.rs:1185-1244 — the main select! loop, four-way event dispatch and AppRunControl decisions.codex-rs/tui/src/app_event.rs:179-295 — the first half of the AppEvent enum, the message contract between widgets and the top level.codex-rs/tui/src/app/event_dispatch.rs:18-80handle_event dispatches the big AppEvent match to submodules, only routing.codex-rs/tui/src/tui.rs:542-566 — the Tui struct, holding terminal, frame requester, event broker, notification backend.codex-rs/tui/src/tui.rs:895-968Tui::draw uses stdout().sync_update for atomic redraws, handling viewport resize and pending history lines.

In the main loop, all four branches are match Box::pin(app.handle_event(...)).await { Ok(control) => control, Err(err) => break Err(err) }; any branch erroring breaks out, and the outer app_server.shutdown() is the fallback:

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),
    }
}

In the terminal-event branch, Draw / Resize are the high-frequency paths: they first run pre_draw_tick (letting widgets handle timers and paste bursts), then render_chat_widget_frame actually redraws; keypresses and paste go through chat_widget's input handling. Draw and Resize share a branch because they both only need one redraw:

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)?;

The semantics of ExitMode are written directly in the doc: ShutdownFirst waits for Op::Shutdown to complete; Immediate is the escape hatch when you know there's nothing to wait for — like arg0 switching or fatal errors:

rust
// app_event.rs:1140-1148 — ExitMode 区分是否等核心收尾
pub(crate) enum ExitMode {
    ShutdownFirst,
    /// Exit the UI loop immediately without waiting for shutdown.
    Immediate,
}

Data flow

Boundaries and failures

  • Terminal input stream closing is not the same as the user actively exiting: when the input stream becomes None, ExitMode::ShutdownFirst is used to let core wind down, not a direct break (codex-rs/tui/src/app.rs:1218-1222).
  • app-server event stream disconnect only closes the stream, doesn't exit: after listen_for_app_server_events = false the loop continues, just without receiving from this path; the real exit is still triggered by AppEvent::Exit (codex-rs/tui/src/app.rs:1223-1232).
  • ShutdownFirst has a timeout: the outer handle_exit_mode gives core a 2-second window; on timeout it forces exit, avoiding a hang (codex-rs/tui/src/app/event_dispatch.rs:15-16).
  • CR/LF normalization on paste: terminals like iTerm2 convert \n to \r when pasting; handle_tui_event explicitly does pasted.replace("\r", "\n") before feeding tui-textarea (codex-rs/tui/src/app.rs:1299-1306).
  • App::drop fallback cleanup: even if run panics midway, Drop calls tui.clear_ambient_pet_image and terminal.clear, so the screen is not polluted by leftover state (codex-rs/tui/src/app.rs:1394-1396).

Summary

App::run is a pure scheduling layer: four event sources → select! → each handler → AppRunControl. The real rendering logic is in chat widget and rendering; how app-server events are translated into AppEvent is in App-server architecture. The pile of resume_* / fork_* fields on Cli are internal channels for the codex resume / codex fork subcommands and are not exposed to users.