Skip to content

Bucle principal del TUI y dispatch de eventos

源码版本rust-v0.145.0

App es la estructura superior de la UI de terminal (TUI) de codex. Sostiene tres handles — ChatWidget, Tui (terminal ratatui), AppServerSession — y reúne las pulsaciones del terminal, el flujo de eventos de app-server y la cola interna de AppEvent en un único bucle select!. Esta capa no renderiza ni corre el modelo; solo decide «de quién viene el próximo evento a procesar y si hay que salir».

Responsabilidades

  1. La entrada de arranque run_main parsea Cli (prompt, --ask-for-approval, --no-alt-screen, etc.), carga la configuración y hace bootstrap del app-server, y luego cede el control a App::run (codex-rs/tui/src/lib.rs:908-957).
  2. El select! principal escucha a la vez cuatro fuentes: mensajes internos de AppEvent, el flujo de eventos del thread actual, los TuiEvent del terminal y el flujo de notificaciones de app-server. Cada rama pasa el evento al handler correspondiente y devuelve AppRunControl::Continue o Exit (codex-rs/tui/src/app.rs:1185-1244).
  3. AppEvent es el único bus de mensajes entre widget y nivel superior; las variantes van desde NewSession, OpenResumePicker hasta ConsolidateAgentMessage, de modo que el widget no necesita tomar handles internos de App (codex-rs/tui/src/app_event.rs:179-295).
  4. La salida tiene dos modos: ExitMode::ShutdownFirst envía primero Op::Shutdown y espera a que el núcleo haga su cierre; ExitMode::Immediate salta directamente fuera del bucle, saltándose el shutdown (codex-rs/tui/src/app_event.rs:1135-1148).
  5. En el Drop del terminal se llama a tui.terminal.clear() y se limpia la imagen del ambient pet, de modo que aunque algo falle a mitad, se restaura la pantalla original (codex-rs/tui/src/app.rs:1246-1266).

Motivación de diseño

Al principio, el TUI de codex mezclaba renderizado, teclas y notificaciones de app-server en un único loop; luego se sacó el bus AppEvent, porque desde el fondo del árbol de widgets se necesita a menudo disparar cosas como «abrir picker, cambiar config, cerrar thread» que solo el nivel superior puede hacer. Si se dejara que el widget sostuviera directamente el handle de AppServerSession, el árbol de componentes quedaría con una dependencia inversa hacia el estado superior. Al introducir AppEventSender (un unbounded_channel con sender clonable), el widget solo puede «enviar eventos», y la decisión queda en el nivel superior: dependencia unidireccional, fácil de testear y de reemplazar.

En el select! de cuatro vías, el flujo de app-server lleva un guard if: should_handle_active_thread_events decide cuándo conectar el canal del thread activo, evitando que cuando el thread aún no arrancó se quede bloqueado en recv(). El orden de salida también tiene su lógica: si el flujo de entrada del terminal se cierra, primero se prueba ShutdownFirst en vez de romper el bucle directamente — así, al caerse la SSH, el núcleo todavía puede terminar el cierre.

ExitMode se parte en dos modos porque algunas rutas (por ejemplo cambio de arg0, fatal error) ya saben que el núcleo o está cerrado o no hace falta esperarlo, y saltar con Immediate es más seguro; el Ctrl+D / /quit normal va por ShutdownFirst, dando a Op::Shutdown una ventana de timeout de 2 segundos.

Archivos clave

codex-rs/tui/src/main.rs:50-83 — entrada del binario; tras TopCli::parse llama a run_main, y al salir imprime el token usage y el resume hint.codex-rs/tui/src/cli.rs:8-76 — struct Cli, reúne prompt, --ask-for-approval, --no-alt-screen y los campos internos de resume/fork.codex-rs/tui/src/app.rs:766-787 — firma de App::run, con 18 parámetros de arranque en un único sitio; es la entrada principal de todo el TUI.codex-rs/tui/src/app.rs:1185-1244 — bucle select! principal; dispatch de eventos de las cuatro vías y decisión de AppRunControl.codex-rs/tui/src/app_event.rs:179-295 — primera mitad del enum AppEvent, el contrato de mensajes entre widget y nivel superior.codex-rs/tui/src/app/event_dispatch.rs:18-80handle_event reparte el gran match de AppEvent a submódulos; él mismo solo enruta.codex-rs/tui/src/tui.rs:542-566 — struct Tui, con terminal, frame requester, event broker y notification backend.codex-rs/tui/src/tui.rs:895-968Tui::draw usa stdout().sync_update para un redibujado atómico, gestionando viewport resize y pending history lines.

En el bucle principal, las cuatro ramas son match Box::pin(app.handle_event(...)).await { Ok(control) => control, Err(err) => break Err(err) }; si cualquier rama falla, salta fuera y el app_server.shutdown() del nivel exterior hace de red de seguridad:

rust
// app.rs:1185-1192 — AppEvent primero; si falla, sale
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),
    }
}

En la rama de eventos del terminal, Draw / Resize son caminos de alta frecuencia: primero corren pre_draw_tick (dejan que el widget procese timers y paste burst), y luego render_chat_widget_frame redibuja de verdad; solo las teclas y el paste van al procesamiento de entrada del chat_widget. Draw y Resize comparten rama porque ambos solo necesitan un redibujado:

rust
// app.rs:1307-1321 — Draw/Resize comparten ruta de redibujado
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 semántica de ExitMode está directamente en el doc: ShutdownFirst espera a que Op::Shutdown termine; Immediate es una salida de emergencia para cuando ya se sabe que no hace falta esperar — por ejemplo, cambio de arg0 o fatal error:

rust
// app_event.rs:1140-1148 — ExitMode distingue si espera al cierre del núcleo
pub(crate) enum ExitMode {
    ShutdownFirst,
    /// Exit the UI loop immediately without waiting for shutdown.
    Immediate,
}

Flujo de datos

Bordes y fallos

  • El flujo de entrada del terminal cerrándose no equivale a salida activa del usuario: si el flujo de entrada es None, se va por ExitMode::ShutdownFirst para que el núcleo haga el cierre, en vez de romper el bucle directo (codex-rs/tui/src/app.rs:1218-1222).
  • El flujo de eventos de app-server cortado solo cierra el flujo, no sale: con listen_for_app_server_events = false el bucle continúa, solo deja de recibir notificaciones por esa vía; la salida real la dispara AppEvent::Exit (codex-rs/tui/src/app.rs:1223-1232).
  • ShutdownFirst con timeout: el handle_exit_mode exterior da al núcleo una ventana de cierre de 2 segundos; si se supera, fuerza la salida para no colgarse (codex-rs/tui/src/app/event_dispatch.rs:15-16).
  • Normalización de CR/LF en paste: terminales como iTerm2 al pegar convierten \n en \r; handle_tui_event hace explícitamente pasted.replace("\r", "\n") antes de pasárselo a tui-textarea (codex-rs/tui/src/app.rs:1299-1306).
  • App::drop como red de seguridad de limpieza: aunque run entre en panic a mitad, Drop sigue llamando a tui.clear_ambient_pet_image y terminal.clear, así la pantalla no se queda contaminada por estado residual (codex-rs/tui/src/app.rs:1394-1396).

Resumen

App::run es una capa pura de orquestación: cuatro fuentes de eventos → select! → handlers respectivos → AppRunControl. La lógica real de renderizado está en Componente de chat y renderizado; cómo se traducen los eventos de app-server a AppEvent se ve en Arquitectura de App-server. Ese montón de campos resume_* / fork_* en Cli son canales internos para los subcomandos codex resume / codex fork, no expuestos al usuario.