Skip to content

Chat Widget and Rendering

源码版本rust-v0.145.0

ChatWidget is the main panel of the TUI: it holds the transcript (settled history cells), the streaming stream controller, the bottom BottomPane (input box + popup stack), and a bunch of per-feature state (tokens, rate limit, MCP startup, skills, etc.). It does not write to the terminal directly; it folds its internal state into a Renderable tree that Tui::draw consumes. The actual ratatui calls happen at the Tui layer.

Responsibilities

  1. Assemble transcript + bottom panel into a Renderable tree: as_renderable uses FlexRenderable to stack the active cell, hook cell, token activity, and bottom panel by weight (codex-rs/tui/src/chatwidget/rendering.rs:6-58).
  2. Handle streaming agent output: StreamController wraps StreamCore; push delta, commit tick, and finalize all happen here. When the stream ends, scattered AgentMessageCells are merged into a single AgentMarkdownCell re-rendered from the source markdown (codex-rs/tui/src/streaming/controller.rs:475-529).
  3. Markdown rendering: append_markdown / append_markdown_agent feeds markdown text through unwrap_markdown_fences (strips outer ```md wrapping so tables render correctly) and then to pulldown-cmark (codex-rs/tui/src/markdown.rs:36-69).
  4. Diff rendering: DiffSummary sorts FileChanges (Add / Delete / Update) by path, counts added / removed lines, syntax-colors, and outputs as Box<dyn Renderable> (codex-rs/tui/src/diff_render.rs:298-349).
  5. Bottom panel stack: BottomPane holds ChatComposer and view_stack; all popups (approval, slash command, file search, settings) are BottomPaneView trait impls (codex-rs/tui/src/bottom_pane/mod.rs:217-252).

Design motivation

codex does not use ratatui's StatefulWidget pattern; it builds its own Renderable trait. Two reasons: first, ratatui's Widget cannot know desired_height before render, and the TUI is an inline viewport (not full-screen alt-screen), so it needs to compute height first to decide how much to scroll; second, streaming output requires a two-phase render of "commit to scrollback first, then keep drawing", and the Renderable tree naturally supports computing the active cell's desired_height first and then rendering.

StreamController exists because LLM output is a token stream, and a markdown table cannot be rendered until the full header + separator row is visible. StreamCore maintains a TableHoldbackScanner; while scanning deltas, once a potential header is found, it holds back subsequent content until it confirms whether it's a table or not. It also caches StablePrefixLen (the number of rendered lines in the stable prefix before the header), avoiding re-rendering the whole segment on every delta. This is a trade-off between performance and correctness — full re-render per frame would stutter on long output.

unwrap_markdown_fences is an interesting hack: LLMs often wrap tables in ```markdown as a code block, so pulldown-cmark renders them as monospace. Before rendering, codex scans for this fence pattern and strips the outer fence only when the fence info is md / markdown and the body contains a header separator row; other fences (rust, sh, etc.) are preserved as-is.

BottomPane's view_stack is a stack, not a single-select, because approval, slash, and file search can nest — for example, a slash command pops up and then triggers an approval. The top view receives keypress events, pops on completion, and the composer is always kept at the bottom so input state is never lost.

Key files

codex-rs/tui/src/chatwidget.rs:533-619 — the ChatWidget struct fields, from stream controller to MCP startup status.codex-rs/tui/src/chatwidget/rendering.rs:6-58as_renderable, folding widget internal state into a FlexRenderable tree.codex-rs/tui/src/streaming/controller.rs:475-529StreamController's push / finalize / on_commit_tick interface.codex-rs/tui/src/markdown.rs:36-69 — entry for append_markdown / append_markdown_agent.codex-rs/tui/src/markdown_render.rs:291-333 — the render_markdown_text_with_width_and_cwd family, where pulldown-cmark is actually called.codex-rs/tui/src/diff_render.rs:298-349DiffSummary and FileChange's Renderable impl.codex-rs/tui/src/bottom_pane/mod.rs:217-252BottomPane holding composer + view_stack + status line.codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60 — the BottomPaneView trait, the contract for all popups.codex-rs/tui/src/diff_model.rs:8-22 — the FileChange enum, the minimal data model for Add / Delete / Update.codex-rs/tui/src/history_cell/mod.rs:191-234 — the HistoryCell trait, the interface for each record in the transcript.

as_renderable folds the component tree into a Renderable; the code is short but information-dense — active cell, hook cell, token activity, rate-limit hint, and bottom panel each push onto the flex; weight 1 stretches with the window, weight 0 is fixed-height:

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 returns (Option<Box<dyn HistoryCell>>, Option<String>) — the first is the un-committed tail cell, the second is the raw markdown source. The latter is sent via AppEvent::ConsolidateAgentMessage so the top level re-renders the whole segment at the right time, preventing streaming-time segmented-markdown boundaries from cutting tables:

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

When DiffSummary converts to Box<dyn Renderable>, it sorts by path; each change first emits a path line + added / removed line counts, then indents 2 columns to draw the diff itself. FileChange::Update uses the unified_diff field; render_change internally applies syntax highlighting via the language detector (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))
    }
}

Data flow

Boundaries and failures

  • stream finalize triggers scrollback reflow: when ConsolidationScrollbackReflow::Required, the cell does not enter history directly but is deferred until AppEvent::ConsolidateAgentMessage is handled, because the live tail needs to be folded into the settled markdown cell (codex-rs/tui/src/chatwidget/streaming.rs:22-65).
  • Stale width causes streaming misalignment: the width taken at StreamController creation becomes stale after a resize; app-layer resize reflow fixes it. Otherwise the streaming tail wraps at the old viewport width until the next reflow (codex-rs/tui/src/streaming/controller.rs:481-494).
  • unwrap_markdown_fences only strips md / markdown fences: other language fences (rust, sh) are preserved as code blocks; and stripping only happens when the body contains a header separator row, to avoid misjudgment (codex-rs/tui/src/markdown.rs:13-22).
  • BottomPane's composer is never lost: even with a popup on top of view_stack, the composer state is retained at the bottom; after the popup pops, the input box recovers its original content (codex-rs/tui/src/bottom_pane/mod.rs:218-223).
  • FileChange is the minimal data model: only Add / Delete / Update; Update uses a unified_diff string rather than structured hunks; the parser lives at the core layer (codex-rs/tui/src/diff_model.rs:8-22).

Summary

ChatWidget bundles "transcript + streaming + bottom panel" into a Renderable tree for TUI main loop to draw; the real markdown / diff rendering logic is spread across the markdown_render and diff_render submodules. Streaming output balances latency and correctness via StreamController + table holdback. To see how app-server events turn into widget state, continue with App-server architecture.