历史记录单元 · 流式 Markdown 与终端排版
对话区里的每一块内容都是一个 HistoryCell,同一份数据按宽度排成主视图、对话记录、原始回滚等几种样子。本篇看单元怎样从“活动单元”走进历史,流式回复怎样按换行定稿、按提交节拍匀速落进回滚而不闪烁,以及 Markdown 表格、仓库自带的终端 Mermaid 渲染器、URL 感知的折行与 diff 着色。
历史记录单元 · 流式 Markdown 与终端排版
TUI 架构一篇讲到,ChatWidget 把 app-server 的通知变成“历史记录单元”,再经 AppEvent::InsertHistoryCell 交给 App。这一篇看这些单元本身:它们是什么,流式回复怎么一行行定稿,终端里又是怎么排 Markdown、表格、Mermaid 和 diff 的。
用户看到的样子
对话区里每一块都是一个单元:你的消息,agent 的回复与推理摘要,执行的命令和输出,文件补丁,MCP 调用与网页搜索,hook,计划,各种提示。回复里的表格排成对齐的列,Mermaid 代码块画成字符图,公式换成 Unicode 记号;这几类渲染可以用 tui.rendering 下的 mermaid、math、tables、lists 分别关掉,关掉后保留原文(见手册配置项速查)。/raw(Alt+R)切到便于在终端里选中复制的原始回滚模式。
单元:一份数据,几种呈现
所有单元实现同一个 trait(节选):
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 在折叠和展开之间切换(见下一篇)。高度默认用 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/ 目录的答案是“两个区”:已经确定不会再变的稳定区,排队写进历史;可能还会变的尾部区,放在活动单元里每帧重画。整条链如下:
第一道闸是换行。收集器只把最后一个换行之前的内容交出去渲染:
/// 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 代码块闭合时才从源码变成图,下一个块开始后才落进回滚。尾部是这样取的:
#[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》对照
那本书的流式处理里,文本增量“来一片显示一片”,直接写到标准输出。这在纯文本下没问题,一旦要把 Markdown 排成表格、代码块、图,半截的输入就可能改变前文的含义。Codex 的办法是三层节制:按换行提交,只让可能被改写的结构留在可变的尾部,再用提交节拍把稳定的行匀速放进回滚。
Grok Build 的终端里实时渲染 markdown 用“检查点冻结”对付同一个问题:只在顶层块边界推进冻结点,每次只重渲尾巴。Codex 的 StreamingRender 也只重渲最后一个顶层块,思路相近;不同在出口,Grok Build 自己保留全部历史块做虚拟滚动,Codex 在回滚模式下把稳定行交给终端,所以多了一层“先排队、再定稿”。
上一篇:输入框 · 补全、粘贴与快捷键 · 下一篇:全屏对话记录 · 搜索、选择与折叠