# 历史记录单元 · 流式 Markdown 与终端排版

> 对话区里的每一块内容都是一个 HistoryCell，同一份数据按宽度排成主视图、对话记录、原始回滚等几种样子。本篇看单元怎样从“活动单元”走进历史，流式回复怎样按换行定稿、按提交节拍匀速落进回滚而不闪烁，以及 Markdown 表格、仓库自带的终端 Mermaid 渲染器、URL 感知的折行与 diff 着色。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/history-cells
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 历史记录单元 · 流式 Markdown 与终端排版

[TUI 架构](https://daiw.net/manual/codex-source/tui-architecture)一篇讲到，`ChatWidget` 把 app-server 的通知变成“历史记录单元”，再经 `AppEvent::InsertHistoryCell` 交给 `App`。这一篇看这些单元本身：它们是什么，流式回复怎么一行行定稿，终端里又是怎么排 Markdown、表格、Mermaid 和 diff 的。

## 用户看到的样子

对话区里每一块都是一个单元：你的消息，agent 的回复与推理摘要，执行的命令和输出，文件补丁，MCP 调用与网页搜索，hook，计划，各种提示。回复里的表格排成对齐的列，Mermaid 代码块画成字符图，公式换成 Unicode 记号；这几类渲染可以用 `tui.rendering` 下的 `mermaid`、`math`、`tables`、`lists` 分别关掉，关掉后保留原文（见手册[配置项速查](https://daiw.net/manual/codex/config-reference)）。`/raw`（`Alt+R`）切到便于在终端里选中复制的原始回滚模式。

## 单元：一份数据，几种呈现

所有单元实现同一个 trait（节选）：

```rust
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
// ...
    /// Returns the logical lines for the main chat viewport.
    fn display_lines(&self, width: u16) -> Vec<Line<'static>>;

    /// Returns copy-friendly plain logical lines for raw scrollback mode.
    fn raw_lines(&self) -> Vec<Line<'static>>;
// ...
    fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 {
        Paragraph::new(Text::from(self.display_lines_for_mode(width, mode)))
            .wrap(Wrap { trim: false })
            .line_count(width)
            .try_into()
            .unwrap_or(0)
    }
// ...
    fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
        self.display_lines(width)
    }
```

（`codex-rs/tui/src/history_cell/mod.rs:198`）

关键在于单元存的是数据，不是排好的行：每个方法都接收当前宽度，现排现用。`display_lines` 给主视图，`raw_lines` 给原始模式，`transcript_lines` 给完整对话记录（`Ctrl+T`）；全屏对话记录还会用 `compact_hyperlink_lines` 与 `expanded_hyperlink_lines` 在折叠和展开之间切换（见[下一篇](https://daiw.net/manual/codex-source/fullscreen-transcript)）。高度默认用 ratatui 的 `Paragraph::line_count` 实测，因为超宽的 URL 会被终端硬折，按逻辑行数估会少算。

除去测试，仓库里有 42 个 `HistoryCell` 实现，大多在 `history_cell/` 目录：`messages.rs` 放用户消息、agent 回复、推理摘要和流式尾部；`exec_cell/` 放命令，连续的读文件、列目录、搜索会合成一个“探索”组；`patches.rs` 放补丁摘要；`mcp.rs` 的工具结果在主视图里所有结果块共用三行预览，完整内容留给展开后的对话记录；`hook_cell.rs` 刻意安静，运行超过 300 毫秒（`HOOK_RUN_REVEAL_DELAY`）才显示，成功又没有输出的 hook 不在历史里留痕。

单元有两种身份。正在变化的那一个是 `ChatWidget` 的活动单元（`active_cell`），可以原地修改，比如一组还在跑的命令；定稿的单元经 `AppEvent::InsertHistoryCell` 追加进 `App` 的 `transcript_cells`（一个 `Vec<Arc<dyn HistoryCell>>`）。终端回滚模式下，定稿单元按当时的宽度排成行、写进终端回滚，写出去就改不了了。这正是流式输出的难点：模型一个 token 一个 token 地吐，怎样才能边显示边定稿，又不把写错的东西留在回滚里？

## 流式 Markdown：按换行定稿

`streaming/` 目录的答案是“两个区”：已经确定不会再变的稳定区，排队写进历史；可能还会变的尾部区，放在活动单元里每帧重画。整条链如下：

```mermaid
flowchart LR
  D[agentMessage 增量] --> C[MarkdownStreamCollector<br/>只提交到最后一个换行]
  C -->|完整的行| R[StreamingRender<br/>只重渲最后一个顶层块]
  C -->|未完成的一行| P[ProsePreview<br/>一次性预览]
  R -->|稳定区| Q[StreamState 队列<br/>记下入队时间]
  R -->|表格、公式、Mermaid| T[StreamingAgentTailCell<br/>活动单元]
  P --> T
  Q -->|提交节拍<br/>Smooth 或 CatchUp| H[AgentMessageCell<br/>写入历史]
  F[消息完成] --> K[ConsolidateAgentMessage<br/>合并成 AgentMarkdownCell]
  H --> K
```

第一道闸是换行。收集器只把最后一个换行之前的内容交出去渲染：

```rust
    /// Commit newly completed raw markdown source up to the last newline.
    ///
    /// This returns the range of source that has not been returned by a previous commit. Calling it
    /// after a delta without a newline returns `None`, which prevents the live stream from rendering
    /// incomplete markdown blocks that may change meaning when the rest of the line arrives.
    pub fn commit_complete_source(&mut self) -> Option<std::ops::Range<usize>> {
        let commit_end = self.buffer.rfind('\n').map(|idx| idx + 1)?;
        let commit_start = self.committed_source_len;
        if commit_end <= commit_start {
            return None;
        }

        self.committed_source_len = commit_end;
        Some(commit_start..commit_end)
    }
```

（`codex-rs/tui/src/markdown_stream.rs:82`）

还没收到换行的那半行不进渲染结果，只由 `ProsePreview` 取其末尾至多 8192 字节画一个一次性预览，预览行永远不进回滚；还没闭合的链接地址先压着不显示，免得链接闭合时整行塌掉。已提交的源码交给 `StreamingRender` 增量渲染：已经完成的顶层块留着不动，只重渲最后一个顶层块。

渲染结果里哪些行算稳定，由 `StreamCore`（`codex-rs/tui/src/streaming/controller.rs`）决定。多数情况下，渲染出来的行直接进稳定区；例外是渲染结果还会被后文改写的结构：检测到管道表格（表头加分隔行）时，从表头起全部留在尾部，因为新来一行可能改变所有列宽；未闭合的独立公式同理；Mermaid 代码块闭合时才从源码变成图，下一个块开始后才落进回滚。尾部是这样取的：

```rust
    #[inline]
    fn current_tail_lines(&self) -> Vec<HyperlinkLine> {
        let start = self.enqueued_stable_len.min(self.render.lines.len());
        let mut lines = self.render.lines[start..].to_vec();
        lines.extend(self.preview.lines.iter().cloned());
        lines
    }
```

（`codex-rs/tui/src/streaming/controller.rs:269`）

尾部从“已入队”的位置算起，而不是“已写出”的位置：注释提醒，否则排着队还没写出的行会在活动单元里再出现一次。三个计数满足 `emitted_stable_len <= enqueued_stable_len <= render.lines.len()`，已提交的源码在一条流结束前只追加、不修改。

稳定区的行带着入队时间进 FIFO 队列，由 `App` 主循环里的提交节拍取走。节拍间隔 `COMMIT_ANIMATION_TICK` 与帧间隔相同，约 8.3 毫秒；`streaming/chunking.rs` 的自适应策略有两档：平时 `Smooth` 每拍取一行，出现“打字”效果；队列积到 8 行或最老的一行等了 120 毫秒，就切到 `CatchUp`，每拍把积压一次取完。退回 `Smooth` 要求队列降到 2 行以内、最老一行不超过 40 毫秒，并保持 250 毫秒；退出后 250 毫秒内不再进入 `CatchUp`，除非积压严重（64 行或 300 毫秒）。这组迟滞防止两档来回跳。模块注释还提到 `docs/tui-stream-chunking-review.md` 等三份调参文档，但 `rust-v0.158.0` 的仓库里找不到它们。

每拍取出的行包成 `AgentMessageCell` 插进历史。消息完成时，`ChatWidget` 发出 `AppEvent::ConsolidateAgentMessage`，`App` 从 `transcript_cells` 末尾往回找出这一串 `AgentMessageCell`，用 `splice` 换成一个存着完整 Markdown 源码的 `AgentMarkdownCell`（`codex-rs/tui/src/app/agent_message_consolidation.rs:56`）。流式期间排好的行只对当时的宽度成立；合并之后，终端改变宽度时就能从源码重排，表格也能按新宽度重新画框。

## Markdown、表格与 Mermaid

最终的排版在 `markdown_render.rs`：消费 `pulldown-cmark` 的事件流，输出 ratatui 的行。表格走一条五步流水线：先挑出 pulldown-cmark 宽松解析时误收进表格的行，补齐列数，按内容分配列宽，挑选呈现方式，最后把挑出的行当普通文本接在表格后面。列分三类：长段落、路径和 URL 这类长串、计数和状态这类短值；空间不够时长串先让，短值最后让；再窄就把每一行改成“键：值”记录。代码块高亮用 `syntect` 加 `two_face` 的语法与主题包，`render/highlight.rs` 对超过 512 KB、10000 行或单行超过 4 KiB 的输入直接放弃高亮，退回纯文本。

Mermaid 图不靠浏览器，而是仓库自带的 `codex-mermaid` crate（`codex-rs/mermaid/`，唯一的外部依赖是 `unicode-width`）。它支持流程图、时序图、状态图、类图、ER 图各一个明确的子集，限额写死在 `lib.rs` 里：源码 16 KiB、16 个节点、24 条边、标签 40 个显示单元、画布 65536 个字符格。超出子集、超限或放不下当前宽度，分别返回 `Unsupported`、`Limit`、`TooWide`，从不返回半张图；TUI 这边（`codex-rs/tui/src/markdown_render/mermaid.rs:39`）据此在源码上方加一行灰色说明，照常显示源码。渲染结果是带 `Node`、`Edge`、`Text` 角色的片段，库本身不输出转义序列，颜色由 TUI 按当前语法主题上色。

## 折行与 diff

折行统一走 `wrapping.rs`，仓库根 `AGENTS.md` 也规定给 ratatui 的行折行要用这里的 `word_wrap_lines`、`word_wrap_line`。它的特别之处是 URL 感知：`textwrap` 默认会在 `/` 和 `-` 处断开，把 URL 拆成两半、在终端里点不开；`adaptive_wrap_*` 系列发现行里有像 URL 的词，就保持这个词完整，其余文字照常按词折行，而 `src/main.rs` 这样的路径不算 URL。写入回滚时，纯 URL 的行干脆不插入硬换行，交给终端自己折，好让终端识别出可点的链接。

补丁（历史里的补丁摘要、审批时的全屏补丁视图）由 `diff_render.rs` 渲染；`/diff` 则不经过它，而是让 git 带 `--color` 输出，再用 `ansi_escape_line` 转成 ratatui 的行。`diff_render.rs` 给每行加右对齐的行号和 `+`、`-` 标记，按文件扩展名做语法高亮。高亮以整个 hunk 为单位，让 syntect 的解析状态跨行延续，多行字符串和块注释才能着对色；hunk 之间不延续。增删行的底色随终端背景的明暗切换，并为 truecolor、256 色、16 色终端各备一套调色板；语法主题若定义了 `markup.inserted`、`markup.deleted` 的背景色，就用主题的。

## 和《从 LLM 到 Coding Agent》对照

那本书的[流式处理](https://daiw.net/manual/llm-to-agent/streaming)里，文本增量“来一片显示一片”，直接写到标准输出。这在纯文本下没问题，一旦要把 Markdown 排成表格、代码块、图，半截的输入就可能改变前文的含义。Codex 的办法是三层节制：按换行提交，只让可能被改写的结构留在可变的尾部，再用提交节拍把稳定的行匀速放进回滚。

Grok Build 的[终端里实时渲染 markdown](https://daiw.net/manual/grok-build/markdown-render) 用“检查点冻结”对付同一个问题：只在顶层块边界推进冻结点，每次只重渲尾巴。Codex 的 `StreamingRender` 也只重渲最后一个顶层块，思路相近；不同在出口，Grok Build 自己保留全部历史块做虚拟滚动，Codex 在回滚模式下把稳定行交给终端，所以多了一层“先排队、再定稿”。

---

上一篇：[输入框 · 补全、粘贴与快捷键](https://daiw.net/manual/codex-source/chat-composer) · 下一篇：[全屏对话记录 · 搜索、选择与折叠](https://daiw.net/manual/codex-source/fullscreen-transcript)
