Skip to content

Composant chat et rendu

源码版本rust-v0.145.0

ChatWidget est le panneau principal de la TUI : il détient le transcript (cells historiques figées), le stream controller de la sortie en streaming, le BottomPane en bas (champ de saisie + pile de popups), et un tas d'états par feature (token, rate limit, démarrage MCP, skills, etc.). Il n'écrit pas directement au terminal ; il replie son état interne en un arbre de Renderable confié à Tui::draw, qui est l'endroit où ratatui est réellement appelé.

Responsabilités

1.Assembler transcript + panneau du bas en arbre Renderable : as_renderable empile, via FlexRenderable, l'active cell, la hook cell, l'activité token et le panneau du bas selon un poids (codex-rs/tui/src/chatwidget/rendering.rs:6-58). 2. Traiter la sortie streaming de l'agent : StreamController enveloppe un StreamCore ; push delta, commit tick et finalize sont là ; à la fin du stream, les AgentMessageCell épars sont fusionnés en un seul AgentMarkdownCell re-rendu depuis le markdown source (codex-rs/tui/src/streaming/controller.rs:475-529). 3. Rendu Markdown : append_markdown / append_markdown_agent font passer le markdown par unwrap_markdown_fences (pour retirer l'enrobage ```md et que les tables se rendent correctement) avant de l'envoyer à pulldown-cmark (codex-rs/tui/src/markdown.rs:36-69). 4. Rendu Diff : DiffSummary prend les FileChange (Add / Delete / Update), trie par chemin, compte les lignes ajoutées/supprimées, colorise selon la syntaxe, et sort en Box<dyn Renderable> (codex-rs/tui/src/diff_render.rs:298-349). 5. Pile du panneau du bas : BottomPane détient un ChatComposer et un view_stack ; toutes les popups (approval, slash command, file search, settings) sont des impls du trait BottomPaneView (codex-rs/tui/src/bottom_pane/mod.rs:217-252).

Motivations de conception

codex n'utilise pas le pattern StatefulWidget de ratatui et a construit son propre trait Renderable. Deux raisons. D'abord, le Widget de ratatui ne permet pas de connaître desired_height avant le render ; or la TUI est un viewport inline (pas un alt-screen plein écran), il faut calculer la hauteur avant de décider de combien scroller. Ensuite, la sortie streaming exige un rendu en deux phases « commit-into-scrollback puis continue-à-dessiner » ; l'arbre Renderable supporte naturellement le calcul préalable du desired_height de l'active cell avant son render.

StreamController existe parce que la sortie d'un LLM est un flux de tokens, alors qu'une table markdown ne peut être rendue qu'avec l'en-tête complet + la ligne séparatrice. StreamCore maintient un TableHoldbackScanner : en scannant les delta, dès qu'il repère un en-tête potentiel il met en holdback le contenu suivant, jusqu'à confirmer ou infirmer la table ; il cache aussi StablePrefixLen (lignes de rendu stables avant l'en-tête), pour ne pas tout re-rendre à chaque delta. C'est un compromis performance vs. correction — tout re-rendre à chaque frame calerait sur les sorties longues.

unwrap_markdown_fences est un hack intéressant : un LLM emballe souvent les tables dans un ```markdown comme un code block, et pulldown-cmark les rend en monospace ; codex scanne cette fence avant le rendu et ne la retire que si l'info fence est md/markdown et que le body contient une ligne séparatrice d'en-tête ; les autres fences (rust, sh, etc.) sont conservées telles quelles.

Le view_stack de BottomPane est une pile plutôt qu'un singleton, parce que approval, slash et file search peuvent s'empiler — par ex. une slash command qui ouvre un approval. La vue au sommet reçoit les touches, et se pop une fois finie ; le composer reste toujours en bas, l'état de saisie n'est jamais perdu.

Fichiers clés

codex-rs/tui/src/chatwidget.rs:533-619 — champs de la structure ChatWidget, du stream controller au MCP startup status.codex-rs/tui/src/chatwidget/rendering.rs:6-58as_renderable, replie l'état interne du widget en arbre FlexRenderable.codex-rs/tui/src/streaming/controller.rs:475-529 — interface push / finalize / on_commit_tick de StreamController.codex-rs/tui/src/markdown.rs:36-69 — entrées append_markdown / append_markdown_agent.codex-rs/tui/src/markdown_render.rs:291-333 — la série render_markdown_text_with_width_and_cwd, où pulldown-cmark est réellement appelé.codex-rs/tui/src/diff_render.rs:298-349 — implémentation Renderable de DiffSummary et FileChange.codex-rs/tui/src/bottom_pane/mod.rs:217-252BottomPane porte composer + view_stack + ligne d'état.codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60 — le trait BottomPaneView, contrat de toutes les popups.codex-rs/tui/src/diff_model.rs:8-22 — l'énum FileChange, modèle de données minimal pour Add / Delete / Update.codex-rs/tui/src/history_cell/mod.rs:191-234 — le trait HistoryCell, interface de chaque entrée du transcript.

as_renderable replie l'arbre du composant en Renderable. Le code est court mais dense : active cell, hook cell, activité token, rate limit hint, panneau du bas poussés chacun dans le flex ; poids 1 s'étire avec la fenêtre, poids 0 reste à hauteur fixe :

rust
// chatwidget/rendering.rs:26-57 — 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 renvoie (Option<Box<dyn HistoryCell>>, Option<String>) — le premier est la cellule de queue pas encore commit, le second le markdown source. Ce dernier est envoyé à AppEvent::ConsolidateAgentMessage pour que le top-level re-rende tout le bloc au bon moment, évitant que les frontières de markdown en streaming ne coupent une table :

rust
// streaming/controller.rs:514-524 — 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))
}

La conversion DiffSummary en Box<dyn Renderable> trie par chemin ; chaque change produit d'abord une ligne chemin + lignes ajoutées/supprimées, puis le diff lui-même indenté de 2 colonnes. FileChange::Update utilise le champ unified_diff ; render_change colorise le code via un détecteur de langage (detect_lang_for_path) :

rust
// 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))
    }
}

Flux de données

Limites et échecs

  • stream finalize déclenche un scrollback reflow : en ConsolidationScrollbackReflow::Required, la cell n'entre pas directement dans l'historique, elle est différée à AppEvent::ConsolidateAgentMessage, parce que la queue live doit être ré-absorbée dans la cell markdown déjà figée (codex-rs/tui/src/chatwidget/streaming.rs:22-65).
  • Width périmée => rendu streaming décalé : la width prise à la création de StreamController devient obsolète après un resize ; il faut un reflow au niveau app pour réparer, sinon la queue streaming est wrappée à l'ancienne largeur du viewport jusqu'au prochain reflow (codex-rs/tui/src/streaming/controller.rs:481-494).
  • unwrap_markdown_fences ne retire que les fences md/markdown : les autres langages (rust, sh) restent comme code blocks ; et la fence n'est retirée que si le body contient une ligne séparatrice d'en-tête, pour éviter les faux positifs (codex-rs/tui/src/markdown.rs:13-22).
  • Le composer de BottomPane ne se perd jamais : même avec une popup au sommet du view_stack, l'état du composer est conservé en bas ; après le pop de la popup, le champ retrouve son contenu (codex-rs/tui/src/bottom_pane/mod.rs:218-223).
  • FileChange est un modèle minimal : seulement Add / Delete / Update ; Update utilise une chaîne unified_diff plutôt que des hunks structurés ; le parser est dans la couche core (codex-rs/tui/src/diff_model.rs:8-22).

Récapitulatif

ChatWidget lie « transcript + streaming + panneau du bas » en un arbre Renderable confié à Boucle principale TUI pour le rendu ; la vraie logique de rendu markdown / diff est dispatchée dans les sous-modules markdown_render et diff_render. La sortie streaming repose sur StreamController + holdback de table pour équilibrer latence et correction. Pour voir comment les événements poussés par app-server deviennent l'état du widget, enchaînez sur Architecture App-server.