Componente de chat y renderizado
ChatWidget es el panel principal del TUI: sostiene el transcript (las celdas de historial ya fijadas), el controlador de stream, el BottomPane inferior (input box + pila de popups) y un conjunto de estados por feature (token, rate limit, MCP startup, skills, etc.). No escribe directamente en el terminal: pliega su estado interno en un árbol Renderable que se le pasa a Tui::draw, donde de verdad se llama a ratatui.
Responsabilidades
- Ensamblar transcript + panel inferior en un árbol
Renderable:as_renderableusaFlexRenderablepara apilar por peso la active cell, la hook cell, la actividad de token y el panel inferior (codex-rs/tui/src/chatwidget/rendering.rs:6-58). - Procesar la salida streaming del agent:
StreamControllerenvuelve aStreamCore; push de delta, commit tick y finalize ocurren aquí; al terminar el stream, losAgentMessageCelldispersos se consolidan en un únicoAgentMarkdownCellque se re-renderiza desde el markdown fuente (codex-rs/tui/src/streaming/controller.rs:475-529). - Renderizado de Markdown:
append_markdown/append_markdown_agentpasan el texto markdown porunwrap_markdown_fences(saca el envoltorio```mdpara que las tablas se rendericen bien) y luego se lo dan apulldown-cmark(codex-rs/tui/src/markdown.rs:36-69). - Renderizado de diff:
DiffSummaryordena losFileChange(Add / Delete / Update) por path, cuenta líneas añadidas/eliminadas, colorea por sintaxis y los saca comoBox<dyn Renderable>(codex-rs/tui/src/diff_render.rs:298-349). - Pila del panel inferior:
BottomPanesostiene elChatComposery elview_stack; todos los popups (approval, slash command, file search, settings) son implementaciones del traitBottomPaneView(codex-rs/tui/src/bottom_pane/mod.rs:217-252).
Motivación de diseño
codex no usa el patrón StatefulWidget de ratatui; construye su propio trait Renderable. Por dos motivos. Primero, el Widget de ratatui no puede conocer el desired_height antes de renderizar, y el TUI es un viewport inline (no un alt-screen a pantalla completa), así que hay que calcular primero la altura antes de decidir cuántas líneas hacer scroll. Segundo, la salida streaming necesita un render en dos fases de «primero commitear al scrollback y luego seguir pintando», y el árbol Renderable soporta de forma natural calcular antes el desired_height de la active cell y luego render.
StreamController existe porque la salida del LLM es un flujo de tokens, y una tabla markdown solo se puede renderizar cuando se ve la cabecera completa + la fila separadora. StreamCore mantiene un TableHoldbackScanner: al escanear el delta, si detecta una posible cabecera, hace holdback del contenido siguiente hasta confirmar si es o no una tabla; además cachea StablePrefixLen (las líneas renderizadas del prefijo estable anterior a la cabecera), para no tener que volver a renderizar todo el tramo con cada delta. Es un compromiso entre rendimiento y corrección — renderizar todo desde cero en cada frame, con salidas largas, se atasca.
unwrap_markdown_fences es un hack curioso: los LLM suelen envolver las tablas en ```markdown como si fueran un bloque de código, y pulldown-cmark por eso las renderiza como código monoespaciado. codex escanea esa fence antes de renderizar y solo la quita cuando la fence info es md/markdown y el body contiene la fila separadora de cabecera; las demás fences (rust, sh, etc.) se conservan tal cual.
El view_stack de BottomPane es una pila y no una selección única, porque approval, slash y file search se pueden anidar — por ejemplo, un slash command abre un popup que a su vez dispara un approval. La view en la cima de la pila recibe los eventos de teclado; al terminar, se hace pop, y el composer se queda siempre al fondo sin perder el estado de entrada.
Archivos clave
codex-rs/tui/src/chatwidget.rs:533-619 — campos del struct ChatWidget, desde el stream controller hasta el estado de MCP startup.codex-rs/tui/src/chatwidget/rendering.rs:6-58 — as_renderable, pliega el estado interno del widget en un árbol FlexRenderable.codex-rs/tui/src/streaming/controller.rs:475-529 — interfaces push / finalize / on_commit_tick de StreamController.codex-rs/tui/src/markdown.rs:36-69 — entradas append_markdown / append_markdown_agent.codex-rs/tui/src/markdown_render.rs:291-333 — familia render_markdown_text_with_width_and_cwd, donde de verdad se llama a pulldown-cmark.codex-rs/tui/src/diff_render.rs:298-349 — implementación de Renderable para DiffSummary y FileChange.codex-rs/tui/src/bottom_pane/mod.rs:217-252 — BottomPane con composer + view_stack + línea de estado.codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60 — trait BottomPaneView, el contrato de todos los popups.codex-rs/tui/src/diff_model.rs:8-22 — enum FileChange, el modelo mínimo de datos para Add / Delete / Update.codex-rs/tui/src/history_cell/mod.rs:191-234 — trait HistoryCell, la interfaz de cada entrada del transcript.as_renderable convierte el árbol de componentes en Renderable; el código es corto pero denso: active cell, hook cell, actividad de token, hint de rate limit y panel inferior se pushean cada uno al flex; peso 1 para lo que se estira con la ventana, peso 0 para lo de altura fija:
// chatwidget/rendering.rs:26-57 — composición del árbol flex
let mut flex = FlexRenderable::new();
flex.push(/*flex*/ 1, active_cell_renderable);
flex.push(/*flex*/ 0, active_hook_cell_renderable);
if let Some(cell) = self.pending_token_activity_output() {
flex.push(/*flex*/ 1, RenderableItem::Owned(Box::new(TranscriptAreaRenderable { ... })));
}
// ...
flex.push(/*flex*/ 0, self.bottom_pane
.as_renderable_with_composer_right_reserve(active_cell_right_reserve)
.inset(Insets::tlbr(/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0)));StreamController::finalize devuelve (Option<Box<dyn HistoryCell>>, Option<String>) — el primero es la celda final sin commitear todavía, y el segundo es el markdown fuente. Este último se envía a AppEvent::ConsolidateAgentMessage para que el nivel superior, en el momento adecuado, vuelva a renderizar todo el tramo, evitando que los límites del markdown por segmentos durante el streaming corten las tablas:
// streaming/controller.rs:514-524 — doble retorno de finalize
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
let (remaining, source) = self.core.finalize_remaining();
if source.is_empty() {
self.core.reset();
return (None, None);
}
let out = self.emit(remaining);
self.core.reset();
(out, Some(source))
}Al convertir DiffSummary a Box<dyn Renderable>, se ordena por path; cada change imprime primero una línea con path + recuento de líneas añadidas/eliminadas, y luego el diff en sí indentado 2 columnas. FileChange::Update usa el campo unified_diff; render_change lo resalta según el detector de sintaxis (detect_lang_for_path):
// diff_render.rs:323-348 — DiffSummary → Renderable
impl From<DiffSummary> for Box<dyn Renderable> {
fn from(val: DiffSummary) -> Self {
let mut rows: Vec<Box<dyn Renderable>> = vec![];
let mut changes: Vec<_> = val.changes.into_iter().collect();
changes.sort_by(|left, right| left.0.cmp(&right.0));
for (i, (path, change)) in changes.into_iter().enumerate() {
if i > 0 { rows.push(Box::new(RtLine::from(""))); }
let (added, removed) = line_counts(&change);
let mut path = RtLine::from(display_path_for(&path, val.cwd.as_path()));
path.push_span(" ");
path.extend(render_line_count_summary(added, removed));
// ...
}
Box::new(ColumnRenderable::with(rows))
}
}Flujo de datos
Bordes y fallos
- stream finalize dispara scrollback reflow: cuando
ConsolidationScrollbackReflow::Required, la celda no entra directamente en history, sino que se pospone aAppEvent::ConsolidateAgentMessage, porque el live tail necesita ser absorbido por la markdown cell ya fijada (codex-rs/tui/src/chatwidget/streaming.rs:22-65). - stale width descuadra el render streaming: el width con el que se creó el
StreamControllerse queda viejo tras un resize; hace falta repararlo con el resize reflow de la capa app; si no, el tail streaming se envuelve con el ancho del viewport viejo hasta el próximo reflow (codex-rs/tui/src/streaming/controller.rs:481-494). unwrap_markdown_fencessolo quita la fencemd/markdown: las fences de otros lenguajes (rust,sh) se conservan como bloques de código; y solo se quita cuando el body contiene la fila separadora de cabecera, para evitar falsos positivos (codex-rs/tui/src/markdown.rs:13-22).- el composer de
BottomPanenunca se pierde: aunque haya un popup en la cima del view_stack, el estado del composer se conserva en el fondo; al hacer pop del popup, el input box recupera su contenido (codex-rs/tui/src/bottom_pane/mod.rs:218-223). FileChangees el modelo mínimo de datos: solo Add / Delete / Update; Update usa un string unified_diff y no hunks estructurados; el parser está en la capa core (codex-rs/tui/src/diff_model.rs:8-22).
Resumen
ChatWidget ata «transcript + streaming + panel inferior» en un árbol Renderable que el bucle principal del TUI se encarga de dibujar; la lógica real de renderizado de markdown / diff se reparte entre los submódulos markdown_render y diff_render. La salida streaming equilibra latencia y corrección con StreamController + el holdback de tablas. Para ver cómo los eventos que empuja app-server se convierten en estado del widget, sigue por Arquitectura de App-server.