聊天组件与渲染
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),需要先算高度再决定滚动多少行;二是 streaming 输出需要"先 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 架构。