Chat-Komponente und Rendering
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
- Transcript + unteres Panel zu einem
Renderable-Baum zusammenbauen:as_renderableschichtet mitFlexRenderableactive cell, hook cell, Token-Aktivität und unteres Panel nach Gewicht (codex-rs/tui/src/chatwidget/rendering.rs:6-58). - Streaming-Agent-Output behandeln:
StreamControllerumschließtStreamCore; push delta, commit tick und finalize geschehen hier; am Ende des Streams werden die verteiltenAgentMessageCells zu einem einzelnenAgentMarkdownCellzusammengeführt und aus dem Quell-Markdown neu gerendert (codex-rs/tui/src/streaming/controller.rs:475-529). - Markdown-Rendering:
append_markdown/append_markdown_agentschicken den Markdown-Text überunwrap_markdown_fences(entfernt die```md-Hülle, damit Tabellen korrekt rendern) anpulldown-cmark(codex-rs/tui/src/markdown.rs:36-69). - Diff-Rendering:
DiffSummarysortiertFileChange(Add / Delete / Update) nach Pfad, zählt hinzugefügte/entfernte Zeilen, färbt nach Syntax und gibt einBox<dyn Renderable>aus (codex-rs/tui/src/diff_render.rs:298-349). - Unteres Panel-Stack:
BottomPanehältChatComposerundview_stack; alle Popups (Approval, Slash-Command, Dateisuche, Settings) sind Implementierungen desBottomPaneView-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-58 — as_renderable, faltet den internen Widget-Zustand in einen FlexRenderable-Baum.codex-rs/tui/src/streaming/controller.rs:475-529 — StreamController 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-333 — render_markdown_text_with_width_and_cwd und verwandte Funktionen; wo tatsächlich pulldown-cmark aufgerufen wird.codex-rs/tui/src/diff_render.rs:298-349 — Renderable-Implementierung von DiffSummary und FileChange.codex-rs/tui/src/bottom_pane/mod.rs:217-252 — BottomPane mit composer + view_stack + Statuszeile.codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60 — BottomPaneView-Trait; Vertrag aller Popups.codex-rs/tui/src/diff_model.rs:8-22 — FileChange-Enum; minimales Datenmodell für Add / Delete / Update.codex-rs/tui/src/history_cell/mod.rs:191-234 — HistoryCell-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:
// 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:
// 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):
// 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::Requiredgeht die Cell nicht direkt in die Historie, sondern wird bis zur Behandlung vonAppEvent::ConsolidateAgentMessageverschoben, 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
StreamControllerermittelte 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_fencesentfernt nurmd/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
BottomPanegeht 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). FileChangeist das minimale Datenmodell: Nur Add / Delete / Update; Update nutzt einenunified_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.