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),需要先算高度再决定滚动多少行;二是 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 等)原样保留。

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 架构