聊天元件與渲染
ChatWidget 是 TUI 的主面板:它持有 transcript(已落定的歷史 cells)、串流 stream controller、底部 BottomPane(輸入框 + 彈窗棧)以及一堆 per-feature 狀態(token、rate limit、MCP startup、skills 等)。它不直接寫終端,而是把內部狀態折成一個 Renderable 樹交給 Tui::draw,真正調 ratatui 的地方在 Tui 層。
職責
- 把 transcript + 底部面板組裝成
Renderable樹:as_renderable用FlexRenderable把 active cell、hook cell、token 活動、底部面板按權重堆疊 (codex-rs/tui/src/chatwidget/rendering.rs:6-58)。 - 處理串流 agent 輸出:
StreamController包著StreamCore,push delta、commit tick、finalize 都在這裡,stream 結束時把零散的AgentMessageCell合併成單個AgentMarkdownCell重新從源 markdown 渲染 (codex-rs/tui/src/streaming/controller.rs:475-529)。 - Markdown 渲染:
append_markdown/append_markdown_agent把 markdown 文字經unwrap_markdown_fences(去掉外層```md包裹讓表格能正確渲染)再餵給pulldown-cmark(codex-rs/tui/src/markdown.rs:36-69)。 - Diff 渲染:
DiffSummary把FileChange(Add / Delete / Update)按路徑排序、統計增刪行數、按語法著色,作為Box<dyn Renderable>輸出 (codex-rs/tui/src/diff_render.rs:298-349)。 - 底部面板棧:
BottomPane持有ChatComposer和view_stack,所有 popup(approval、slash command、file search、settings)都是BottomPaneViewtrait 的實現 (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 等)原樣保留。
BottomPane 的 view_stack 是棧而不是單選,是因為 approval、slash、file search 可以巢狀——比如 slash command 彈出後又觸發 approval。棧頂 view 拿按鍵事件,完成後 pop,composer 始終保留在最底下不丟輸入狀態。
關鍵檔案
codex-rs/tui/src/chatwidget.rs:533-619 — ChatWidget 結構體欄位,從 stream controller 到 MCP startup status 全在裡面。codex-rs/tui/src/chatwidget/rendering.rs:6-58 — as_renderable,把 widget 內部狀態折成 FlexRenderable 樹。codex-rs/tui/src/streaming/controller.rs:475-529 — StreamController 的 push / finalize / on_commit_tick 介面。codex-rs/tui/src/markdown.rs:36-69 — append_markdown / append_markdown_agent 的入口。codex-rs/tui/src/markdown_render.rs:291-333 — render_markdown_text_with_width_and_cwd 系列函式,真正調 pulldown-cmark 的地方。codex-rs/tui/src/diff_render.rs:298-349 — DiffSummary 和 FileChange 的 Renderable 實現。codex-rs/tui/src/bottom_pane/mod.rs:217-252 — BottomPane 持 composer + view_stack + 狀態行。codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60 — BottomPaneView trait,所有 popup 的契約。codex-rs/tui/src/diff_model.rs:8-22 — FileChange 列舉,Add / Delete / Update 的最小資料模型。codex-rs/tui/src/history_cell/mod.rs:191-234 — HistoryCell trait,transcript 裡每條記錄的介面。as_renderable 把元件樹折成 Renderable,程式碼很短但資訊密度高——active cell、hook cell、token 活動、rate limit hint、底部面板各自 push 到 flex,權重 1 是會隨視窗拉伸的,權重 0 是定高的:
// 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 邊界把表格切斷:
// 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))
}DiffSummary 轉 Box<dyn Renderable> 時按路徑排序,每個 change 先吐一行路徑 + 增刪行數,再縮排 2 列畫 diff 本身。FileChange::Update 用 unified_diff 欄位,render_change 內部按語法偵測器(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))
}
}資料流
邊界與失敗
- 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/markdownfence:其他語言 fence(rust、sh)原樣保留當程式碼區塊;且只在 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_render 和 diff_render 子模組。串流輸出靠 StreamController + 表格 holdback 平衡延遲和正確性。要看 app-server 推過來的事件怎麼變成 widget 狀態,接 App-server 架構。