Skip to content

Chat-Komponente und Rendering

源码版本rust-v0.145.0

ChatWidget ist das Hauptpanel der TUI: Es hält transcript (bereits festgelegte Historien-Cells), den Streaming-Stream-Controller, das untere BottomPane (Eingabefeld + Popup-Stack) und eine Reihe pro-Feature-Zustände (Token, Rate-Limit, MCP-Startup, Skills). Es schreibt nicht direkt ins Terminal, sondern faltet seinen internen Zustand zu einem Renderable-Baum, den Tui::draw übernimmt; der eigentliche ratatui-Aufruf passiert in der Tui-Schicht.

Verantwortlichkeiten

  1. Transcript + unteres Panel zu einem Renderable-Baum zusammenbauen: as_renderable schichtet mit FlexRenderable active cell, hook cell, Token-Aktivität und unteres Panel nach Gewicht (codex-rs/tui/src/chatwidget/rendering.rs:6-58).
  2. Streaming-Agent-Output behandeln: StreamController umschließt StreamCore; push delta, commit tick und finalize geschehen hier; am Ende des Streams werden die verteilten AgentMessageCells zu einem einzelnen AgentMarkdownCell zusammengeführt und aus dem Quell-Markdown neu gerendert (codex-rs/tui/src/streaming/controller.rs:475-529).
  3. Markdown-Rendering: append_markdown / append_markdown_agent schicken den Markdown-Text über unwrap_markdown_fences (entfernt die ```md-Hülle, damit Tabellen korrekt rendern) an pulldown-cmark (codex-rs/tui/src/markdown.rs:36-69).
  4. Diff-Rendering: DiffSummary sortiert FileChange (Add / Delete / Update) nach Pfad, zählt hinzugefügte/entfernte Zeilen, färbt nach Syntax und gibt ein Box<dyn Renderable> aus (codex-rs/tui/src/diff_render.rs:298-349).
  5. Unteres Panel-Stack: BottomPane hält ChatComposer und view_stack; alle Popups (Approval, Slash-Command, Dateisuche, Settings) sind Implementierungen des BottomPaneView-Traits (codex-rs/tui/src/bottom_pane/mod.rs:217-252).

Entwurfsbeweggründe

codex nutzt nicht das StatefulWidget-Muster von ratatui, sondern hat einen eigenen Renderable-Trait gebaut. Zwei Gründe: Erstens kann ratatuis Widget vor dem Render desired_height nicht vorab wissen, die TUI aber ist ein Inline-Viewport (kein vollbild-alt-screen) und muss zuerst die Höhe berechnen, um über das Scrollen zu entscheiden. Zweitens braucht Streaming-Output ein Zwei-Phasen-Rendering „erst in den Scrollback commiten, dann weiter zeichnen"; der Renderable-Baum unterstützt es nativ, erst desired_height der active cell zu berechnen und dann render aufzurufen.

StreamController existiert, weil LLM-Output ein Token-Strom ist, eine Markdown-Tabelle aber erst kompletten Tabellenkopf + Trennzeile sehen muss, bevor sie rendert. StreamCore hält einen TableHoldbackScanner, der beim Scannen der Deltas einen potenziellen Tabellenkopf zurückhält und Folgeinhalte puffert, bis geklärt ist, ob es eine Tabelle ist oder nicht; gleichzeitig cachet er StablePrefixLen (die Anzahl stabiler Render-Zeilen vor dem Tabellenkopf), damit nicht jeder Delta die gesamte Passage neu rendert. Das ist ein Kompromiss zwischen Performance und Korrektheit — ein vollständiges Neuzeichnen pro Frame würde bei langer Ausgabe ruckeln.

unwrap_markdown_fences ist ein interessanter Hack: LLMs hüllen Tabellen oft in ```markdown als Codeblock ein, weshalb pulldown-cmark sie als Codeblock in monospaced rendert. codex scannt vor dem Rendern diese Fence und entfernt sie nur, wenn fence info md/markdown ist und der Body eine Tabellenkopf-Trennzeile enthält; andere Fences (rust, sh usw.) bleiben unverändert.

view_stack von BottomPane ist ein Stack und keine Einzelwahl, weil Approval, Slash und Dateisuche verschachtelt auftreten können — etwa ein Slash-Command, der wiederum Approval auslöst. Die oberste View bekommt die Tastenereignisse, nach Abschluss wird sie gepopt; der Composer bleibt immer ganz unten und verliert keinen Eingabezustand.

Wichtige Dateien

codex-rs/tui/src/chatwidget.rs:533-619 — Felder der ChatWidget-Struktur, vom Stream-Controller bis zum MCP-Startup-Status.codex-rs/tui/src/chatwidget/rendering.rs:6-58as_renderable, faltet den internen Widget-Zustand in einen FlexRenderable-Baum.codex-rs/tui/src/streaming/controller.rs:475-529StreamController mit push / finalize / on_commit_tick-Schnittstelle.codex-rs/tui/src/markdown.rs:36-69 — Einstieg von append_markdown / append_markdown_agent.codex-rs/tui/src/markdown_render.rs:291-333render_markdown_text_with_width_and_cwd und verwandte Funktionen; wo tatsächlich pulldown-cmark aufgerufen wird.codex-rs/tui/src/diff_render.rs:298-349Renderable-Implementierung von DiffSummary und FileChange.codex-rs/tui/src/bottom_pane/mod.rs:217-252BottomPane mit composer + view_stack + Statuszeile.codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60BottomPaneView-Trait; Vertrag aller Popups.codex-rs/tui/src/diff_model.rs:8-22FileChange-Enum; minimales Datenmodell für Add / Delete / Update.codex-rs/tui/src/history_cell/mod.rs:191-234HistoryCell-Trait; Schnittstelle eines jeden Eintrags im transcript.

as_renderable faltet den Komponentenbaum zu Renderable; der Code ist kurz, aber informationsdicht — active cell, hook cell, Token-Aktivität, Rate-Limit-Hinweis und unteres Panel werden je in den flex gepushed; Gewicht 1 ist elastisch mit der Fenstergröße, Gewicht 0 hat feste Höhe:

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 liefert (Option<Box<dyn HistoryCell>>, Option<String>) — das erste ist die noch nicht committete End-Cell, das zweite die rohe Markdown-Quelle. Letzteres geht an AppEvent::ConsolidateAgentMessage, damit die Top-Ebene die Passage zum richtigen Zeitpunkt neu rendert und die Abschnittsgrenzen des Streaming-Markdowns eine Tabelle nicht zerschneiden:

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

Beim Umwandeln von DiffSummary in Box<dyn Renderable> wird nach Pfad sortiert; jede Change gibt erst eine Zeile mit Pfad + hinzugefügten/entfernten Zeilen aus und zeichnet den Diff selbst mit 2-Spalten-Einrückung. FileChange::Update nutzt das Feld unified_diff; render_change färbt den Code über den Sprachdetektor (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))
    }
}

Datenfluss

Grenzen und Fehler

  • stream finalize löst scrollback reflow aus: Bei ConsolidationScrollbackReflow::Required geht die Cell nicht direkt in die Historie, sondern wird bis zur Behandlung von AppEvent::ConsolidateAgentMessage verschoben, weil der live tail in die bereits festgelegte Markdown-Cell eingearbeitet werden muss (codex-rs/tui/src/chatwidget/streaming.rs:22-65).
  • Stale width verschiebt Streaming-Render: Die beim Erstellen von StreamController ermittelte width ist nach einem resize veraltet und wird durch den resize-reflow der App-Ebene repariert; sonst bricht der Streaming-Tail bis zur nächsten Neuordnung nach der alten Viewport-Breite um (codex-rs/tui/src/streaming/controller.rs:481-494).
  • unwrap_markdown_fences entfernt nur md/markdown-Fences: Andere Sprach-Fences (rust, sh) bleiben als Codeblock erhalten; zudem wird der äußere Fence nur entfernt, wenn der Body eine Tabellenkopf-Trennzeile enthält, um Fehlinterpretationen zu vermeiden (codex-rs/tui/src/markdown.rs:13-22).
  • Composer von BottomPane geht nie verloren: Selbst wenn ein Popup oben auf dem view_stack liegt, bleibt der Composer-Zustand unten erhalten; nach dem Pop des Popups hat das Eingabefeld wieder seinen alten Inhalt (codex-rs/tui/src/bottom_pane/mod.rs:218-223).
  • FileChange ist das minimale Datenmodell: Nur Add / Delete / Update; Update nutzt einen unified_diff-String statt strukturierter Hunks; der Parser liegt auf core-Ebene (codex-rs/tui/src/diff_model.rs:8-22).

Zusammenfassung

ChatWidget bindet „transcript + Streaming + unteres Panel" an einen Renderable-Baum, den die TUI-Hauptschleife zeichnet; die eigentliche Markdown- und Diff-Rendering-Logik verteilt sich auf die Submodule markdown_render und diff_render. Streaming-Output balanciert über StreamController und Tabellen-Holdback Latenz und Korrektheit. Wie die vom app-server hereinkommenden Ereignisse in Widget-Zustände übersetzt werden, siehe App-server-Architektur.