Skip to content

Boucle principale TUI et dispatch d'événements

源码版本rust-v0.145.0

App est la structure de tête de la TUI (terminal UI) de codex ; elle détient ChatWidget, Tui (ratatui terminal) et AppServerSession — trois handles — et fusionne dans un select! unique les touches terminal, le flux d'événements app-server et la file interne d'AppEvent. Cette couche ne fait ni rendu ni exécution de modèle ; elle décide seulement « qui est le prochain événement à traiter, et faut-il quitter ».

Responsabilités

  1. L'entrée run_main parse Cli (prompt, --ask-for-approval, --no-alt-screen, etc.), charge la config, bootstrap app-server, puis passe la main à App::run (codex-rs/tui/src/lib.rs:908-957).
  2. Le select! principal attend quatre sources simultanément : messages internes AppEvent, flux d'événements du thread courant, TuiEvent terminal, flux de notifications app-server. Chaque branche confie l'événement à son handler et renvoie AppRunControl::Continue ou Exit (codex-rs/tui/src/app.rs:1185-1244).
  3. AppEvent est le seul bus de messages entre widget et top-level ; les variantes couvrent de NewSession, OpenResumePicker à ConsolidateAgentMessage, si bien que le widget n'a pas besoin de manipuler directement les handles internes de App (codex-rs/tui/src/app_event.rs:179-295).
  4. La sortie a deux modes : ExitMode::ShutdownFirst envoie d'abord Op::Shutdown et attend la fin de core, ExitMode::Immediate sort directement de la boucle en sautant le shutdown (codex-rs/tui/src/app_event.rs:1135-1148).
  5. Au Drop terminal, on appelle tui.terminal.clear() et on nettoie l'image ambient pet, pour restaurer l'écran original même en cas d'erreur en cours de route (codex-rs/tui/src/app.rs:1246-1266).

Motivations de conception

Au début, la TUI de codex mélangeait rendu, touches et notifications app-server dans une seule boucle ; plus tard, le bus AppEvent a été extrait, parce que les profondeurs de l'arbre de widget doivent souvent déclencher « ouvrir picker, modifier config, fermer thread » — des actions que seul le top-level peut faire. Si on laissait le widget tenir directement un handle AppServerSession, l'arbre des composants dépendrait à l'envers de l'état top-level. En introduisant AppEventSender (un simple sender clone-able de unbounded_channel), le widget ne fait qu'« émettre des événements », et la décision reste au top-level — dépendance unidirectionnelle, plus facile à tester et à remplacer.

Dans le select! à quatre voies, le flux app-server a un garde if : should_handle_active_thread_events décide quand brancher le canal du thread actif, pour éviter que recv() ne bloque inutilement tant que le thread n'est pas lancé. L'ordre de sortie aussi compte : si le flux d'entrée terminal se ferme, on tente d'abord ShutdownFirst plutôt que de breaker directement — comme ça, en cas de coupure SSH, core peut encore finir sa propre clôture.

ExitMode a deux modes parce que certains chemins (par ex. switch arg0, fatal error) savent déjà que core est soit fermé soit inutile à attendre ; un Immediate direct est plus sûr ; Ctrl+D / /quit classiques passent par ShutdownFirst, qui laisse à Op::Shutdown une fenêtre de 2 secondes.

Fichiers clés

codex-rs/tui/src/main.rs:50-83 — entrée binaire, après TopCli::parse appelle run_main, et imprime le token usage et le resume hint à la sortie.codex-rs/tui/src/cli.rs:8-76 — structure Cli, rassemble prompt, --ask-for-approval, --no-alt-screen, champs internes resume/fork.codex-rs/tui/src/app.rs:766-787 — signature de App::run, regroupe 18 paramètres de démarrage, l'entrée principale de toute la TUI.codex-rs/tui/src/app.rs:1185-1244 — la boucle select! principale, dispatch des quatre sources et décision AppRunControl.codex-rs/tui/src/app_event.rs:179-295 — première moitié de l'énum AppEvent, contrat de messages entre widget et top-level.codex-rs/tui/src/app/event_dispatch.rs:18-80handle_event répartit le gros match d'AppEvent dans des sous-modules, ne fait que du routing.codex-rs/tui/src/tui.rs:542-566 — la structure Tui, porte terminal, frame requester, event broker, notification backend.codex-rs/tui/src/tui.rs:895-968Tui::draw utilise stdout().sync_update pour un redraw atomique, gère le resize du viewport et les pending history lines.

Dans la boucle principale, les quatre branches font toutes match Box::pin(app.handle_event(...)).await { Ok(control) => control, Err(err) => break Err(err) } ; la moindre erreur sort, et l'app_server.shutdown() de l'extérieur prend le relais :

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

Dans la branche événements terminal, Draw / Resize sont à haute fréquence : on lance d'abord pre_draw_tick (laisser le widget traiter les minuteurs et paste burst), puis render_chat_widget_frame pour le redraw effectif ; seuls les keypress et paste vont au traitement d'entrée de chat_widget. Draw et Resize partagent la même branche parce qu'ils ne nécessitent qu'un seul 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)?;

La sémantique de ExitMode est directement dans la doc : ShutdownFirst attend la fin d'Op::Shutdown, Immediate est la sortie de secours quand on sait qu'il n'y a rien à attendre — par ex. switch arg0 ou fatal error :

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

Flux de données

Limites et échecs

  • Flux d'entrée terminal fermé ≠ utilisateur qui quitte activement : quand le flux renvoie None, on prend ExitMode::ShutdownFirst pour laisser core finir, plutôt que de breaker direct (codex-rs/tui/src/app.rs:1218-1222).
  • Flux app-server coupé : on ferme juste le flux sans quitter : une fois listen_for_app_server_events = false, la boucle continue, elle ne reçoit simplement plus cette voie ; la vraie sortie reste déclenchée par AppEvent::Exit (codex-rs/tui/src/app.rs:1223-1232).
  • ShutdownFirst avec timeout : en amont, handle_exit_mode donne à core une fenêtre de clôture de 2 s ; au-delà, sortie forcée, pour éviter un blocage (codex-rs/tui/src/app/event_dispatch.rs:15-16).
  • Normalisation CR/LF du paste : iTerm2 et autres terminaux convertissent \n en \r lors du paste ; handle_tui_event fait explicitement pasted.replace("\r", "\n") avant d'alimenter tui-textarea (codex-rs/tui/src/app.rs:1299-1306).
  • App::drop nettoie en filet de sécurité : même si run panic en cours, Drop appelle tui.clear_ambient_pet_image et terminal.clear, l'écran n'est pas pollué par un état résiduel (codex-rs/tui/src/app.rs:1394-1396).

Récapitulatif

App::run est une couche de pure orchestration : quatre sources → select! → handlers dédiés → AppRunControl. La logique de rendu réelle est dans Composant chat et rendu ; la traduction des événements app-server en AppEvent est décrite dans Architecture App-server. Les champs resume_* / fork_* de Cli sont un canal interne pour les sous-commandes codex resume / codex fork, non exposé à l'utilisateur.