Skip to content

聊天元件與渲染

源码版本rust-v0.145.0

ChatWidget 是 TUI 的主面板:它持有 transcript(已落定的歷史 cells)、串流 stream controller、底部 BottomPane(輸入框 + 彈窗棧)以及一堆 per-feature 狀態(token、rate limit、MCP startup、skills 等)。它不直接寫終端,而是把內部狀態折成一個 Renderable 樹交給 Tui::draw,真正調 ratatui 的地方在 Tui 層。

職責

  1. 把 transcript + 底部面板組裝成 Renderable 樹:as_renderableFlexRenderable 把 active cell、hook cell、token 活動、底部面板按權重堆疊 (codex-rs/tui/src/chatwidget/rendering.rs:6-58)。
  2. 處理串流 agent 輸出:StreamController 包著 StreamCore,push delta、commit tick、finalize 都在這裡,stream 結束時把零散的 AgentMessageCell 合併成單個 AgentMarkdownCell 重新從源 markdown 渲染 (codex-rs/tui/src/streaming/controller.rs:475-529)。
  3. Markdown 渲染:append_markdown / append_markdown_agent 把 markdown 文字經 unwrap_markdown_fences(去掉外層 ```md 包裹讓表格能正確渲染)再餵給 pulldown-cmark (codex-rs/tui/src/markdown.rs:36-69)。
  4. Diff 渲染:DiffSummaryFileChange(Add / Delete / Update)按路徑排序、統計增刪行數、按語法著色,作為 Box<dyn Renderable> 輸出 (codex-rs/tui/src/diff_render.rs:298-349)。
  5. 底部面板棧:BottomPane 持有 ChatComposerview_stack,所有 popup(approval、slash command、file search、settings)都是 BottomPaneView trait 的實現 (codex-rs/tui/src/bottom_pane/mod.rs:217-252)。

設計動機

codex 沒用 ratatui 的 StatefulWidget 模式,而是自建了一套 Renderable trait。原因有兩個:一是 ratatui 的 Widget 沒法在 render 之前預知 desired_height,而 TUI 是 inline viewport(不是全螢幕 alt-screen),需要先算高度再決定滾動多少行;二是串流輸出需要「先 commit 到 scrollback 再繼續畫」的兩階段渲染,Renderable 樹天然支援先把 active cell 的 desired_height 算出來再 render

StreamController 的存在是因為 LLM 輸出是 token 流,而 markdown 表格必須看到完整表頭 + 分隔行才能渲染。StreamCore 維護一個 TableHoldbackScanner,掃描 delta 時一旦發現潛在的表頭就 holdback 後續內容,直到確認是表格或不是;同時快取 StablePrefixLen(表頭前的穩定前綴渲染行數),避免每次 delta 都重新渲染整段。這是效能和正確性的折中——直接每幀全量重渲染在長輸出時會卡。

unwrap_markdown_fences 是個有意思的 hack:LLM 經常把表格用 ```markdown 包起來當程式碼區塊,pulldown-cmark 因此把它當程式碼區塊渲染成等寬字。codex 在渲染前先掃描這種 fence,只有當 fence info 是 md/markdown body 裡有表頭分隔行才剝掉外層 fence,其他 fence(rust、sh 等)原樣保留。

BottomPaneview_stack 是棧而不是單選,是因為 approval、slash、file search 可以巢狀——比如 slash command 彈出後又觸發 approval。棧頂 view 拿按鍵事件,完成後 pop,composer 始終保留在最底下不丟輸入狀態。

關鍵檔案

codex-rs/tui/src/chatwidget.rs:533-619ChatWidget 結構體欄位,從 stream controller 到 MCP startup status 全在裡面。codex-rs/tui/src/chatwidget/rendering.rs:6-58as_renderable,把 widget 內部狀態折成 FlexRenderable 樹。codex-rs/tui/src/streaming/controller.rs:475-529StreamControllerpush / finalize / on_commit_tick 介面。codex-rs/tui/src/markdown.rs:36-69append_markdown / append_markdown_agent 的入口。codex-rs/tui/src/markdown_render.rs:291-333render_markdown_text_with_width_and_cwd 系列函式,真正調 pulldown-cmark 的地方。codex-rs/tui/src/diff_render.rs:298-349DiffSummaryFileChangeRenderable 實現。codex-rs/tui/src/bottom_pane/mod.rs:217-252BottomPane 持 composer + view_stack + 狀態行。codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60BottomPaneView trait,所有 popup 的契約。codex-rs/tui/src/diff_model.rs:8-22FileChange 列舉,Add / Delete / Update 的最小資料模型。codex-rs/tui/src/history_cell/mod.rs:191-234HistoryCell trait,transcript 裡每條記錄的介面。

as_renderable 把元件樹折成 Renderable,程式碼很短但資訊密度高——active cell、hook cell、token 活動、rate limit hint、底部面板各自 push 到 flex,權重 1 是會隨視窗拉伸的,權重 0 是定高的:

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 回傳 (Option<Box<dyn HistoryCell>>, Option<String>) —— 第一個是還沒 commit 的尾部 cell,第二個是原始 markdown 源。後者被發到 AppEvent::ConsolidateAgentMessage 讓頂層在合適時機重渲染整段,避免串流時的分段 markdown 邊界把表格切斷:

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

DiffSummaryBox<dyn Renderable> 時按路徑排序,每個 change 先吐一行路徑 + 增刪行數,再縮排 2 列畫 diff 本身。FileChange::Updateunified_diff 欄位,render_change 內部按語法偵測器(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))
    }
}

資料流

邊界與失敗

  • stream finalize 觸發 scrollback reflow:ConsolidationScrollbackReflow::Required 時 cell 不直接進 history,而是延後到 AppEvent::ConsolidateAgentMessage 處理,因為 live tail 需要被收編進已落定的 markdown cell (codex-rs/tui/src/chatwidget/streaming.rs:22-65)。
  • stale width 導致串流渲染錯位:StreamController 建立時拿的 width 在 resize 後會過時,需要靠 app 層的 resize reflow 修復;否則串流 tail 會按舊 viewport 寬度換行直到下次重排 (codex-rs/tui/src/streaming/controller.rs:481-494)。
  • unwrap_markdown_fences 只剝 md/markdown fence:其他語言 fence(rustsh)原樣保留當程式碼區塊;且只在 body 含表頭分隔行時才剝,避免誤判 (codex-rs/tui/src/markdown.rs:13-22)。
  • BottomPane 的 composer 永不丟:即使 view_stack 頂有 popup,composer 狀態也保留在底層,popup pop 後輸入框恢復原內容 (codex-rs/tui/src/bottom_pane/mod.rs:218-223)。
  • FileChange 是最小資料模型:只有 Add / Delete / Update 三種,Update 用 unified_diff 字串而不是結構化 hunk,parser 在 core 層 (codex-rs/tui/src/diff_model.rs:8-22)。

小結

ChatWidget 把「transcript + 串流 + 底部面板」綁成一個 Renderable 樹交給 TUI 主迴圈 畫出來;真正的 markdown / diff 渲染邏輯分散在 markdown_renderdiff_render 子模組。串流輸出靠 StreamController + 表格 holdback 平衡延遲和正確性。要看 app-server 推過來的事件怎麼變成 widget 狀態,接 App-server 架構