# Peri Code 怎么在终端渲染 Markdown——一个事件流的三层工程

> **[Peri Code](https://github.com/konghayao/peri)** — 用 Rust 写的开源 Coding Agent，兼容 Claude Code 生态。<https://github.com/KonghaYao/peri>

LLM 流式输出一个 Markdown 表格，前几个 token 到达时第三行只写了一半——`| 用户`。表头有三列，数据行只出现一个单元格。此刻如果渲染，终端画出一个列宽错位的残缺表格，等下一个 token 补齐后重新算列宽，整个表格闪一下。

终端不是浏览器。浏览器拿到完整 HTML 一次画出来，终端是定宽字符网格，中文占 2 列，ASCII 占 1 列，每行的视觉宽度都要精确计算。Peri Code 在终端里渲染 Markdown 同时解决三个问题——把 Markdown 解析成终端字符、在每帧重绘时省掉重复解析、在流式输出中处理还没写完的结构。三个问题对应三层工程，各自独立。

## pulldown-cmark 产生事件，RenderState 消费事件生成终端行

Peri Code 用 pulldown-cmark（Rust 生态的 Markdown 解析库）解析 Markdown。pulldown-cmark 不输出完整的语法树，而是输出一个事件流。解析 `## 标题` 会依次产生三个事件——`Start(Heading)`、`Text("标题")`、`End(Heading)`。段落、代码块、列表、表格、链接，每个元素都是若干个 Start/End/Text 事件的组合。

RenderState 是消费这些事件的状态机。它维护当前的内联样式（粗体、斜体、链接色）、列表嵌套深度、引用嵌套深度等上下文，每收到一个事件就更新状态、输出 ratatui（Rust 的终端 UI 框架）的 Line（终端一行）或 Span（一行中的一个样式片段）。拿到 `Start(Strong)` 就把内联样式加上粗体标记，拿到 `Text("加粗")` 就用当前样式输出这段文字，拿到 `End(Strong)` 就去掉粗体标记。

几个元素需要特殊处理。代码块不能逐行输出——语法高亮需要看到整个代码块才能正确着色，所以 pulldown-cmark 输出的每一行代码先缓冲到 code_lines 里，等 `End(CodeBlock)` 时一次性交给高亮器。列表的缩进跟着嵌套深度走，每层缩进 2 个空格，有序列表自动维护序号。引用块每行前面加一个 `▍` 竖条前缀，嵌套引用就加两个。段落和块级元素之间需要空行分隔，但连续的块级元素会产生连续空行，ensure_blank_line 检查上一行是否已经是空行，是的话就跳过。

事件流的好处是流式友好。增量解析时可以按事件边界切分——前面已经完整的块不会因为后面新增内容而改变，渲染器只需要处理新增的事件。如果改用完整语法树，每次新增 token 都要重新构建整棵树再做 diff，代价高得多。

## 表格列宽先保最小再按比例分配

表格是 Markdown 渲染里最复杂的元素。终端宽度固定，比如 80 列，表格有 N 列，每列内容长度不同，还要考虑 CJK 字符（中文、日文、韩文等双宽字符）占 2 列。列宽分配的目标是让每列都能尽量完整显示内容，同时不超出终端总宽。

算法分三步。第一步算每列的最小宽度，取该列所有单元格的内容宽度，上限 10 列。这个保底线保证 CJK 列至少能显示几个中文字，不会被压到每行只剩一两个字。第二步算每列的理想宽度，取该列所有单元格中最宽的那个内容。第三步分配——先给每列最小宽度，剩余空间按理想宽度超出最小宽度的部分按比例分给各列。

直接等比缩放是最直觉的做法，但对 CJK 内容会出问题。一个 3 列表格，英文列内容长、中文列内容短，等比缩放后中文列可能只有 4 列宽，每行放两个中文字，读起来像竖排。改成最小宽度保底后，中文列至少有 4 到 10 列宽，能正常横向显示。如果所有列的最小宽度加起来已经超过终端总宽，就从最小宽度里按比例压缩，每列至少留 2 列防止零宽。

单元格内部的换行也要处理 CJK 宽度。逐字符推进，用 unicode-width 库算每个字符的显示宽度（ASCII 字符算 1 列，中文字符算 2 列），累积到超过列宽就断行。断行时优先在空格处切，从断点往回找最后一个空格作为实际切分位置。

## 两层缓存，文本级 LRU 和消息级哈希复用

流式输出时每来一个 token 就触发一次重绘，但绝大部分内容没变。两层缓存消化掉重复计算的代价。

第一层是文本级的 LRU 缓存（LRU，最近最少使用，一种满了就淘汰最久没用过的条目的缓存策略）。这是一个全局单例，容量 256 条，key 是内容哈希加渲染宽度的组合，value 是已经解析好的 ratatui Text。同一段文本在同一个宽度下只解析一次，后续命中直接复制一份返回，跳过整个 pulldown-cmark 解析加 RenderState 状态机。宽度放进 key 是因为不同终端宽度下换行结果不同，缓存不能跨宽度复用。

第二层是消息级的哈希复用，运行在独立的渲染线程上。每条消息有一个 content_hash，渲染线程拿到新的消息列表后，逐条对比新旧哈希，找到前缀稳定区——前面连续哈希未变的消息。前缀稳定区的消息直接复用上一次的渲染结果，整条跳过。流式输出时通常只有最后一条消息在变，前面几十条的哈希完全稳定，只有一条消息真正触发重渲染。还有一些不影响显示内容的变化，比如 is_streaming 从 true 变成 false，哈希变了但渲染结果一样，这种情况也直接复用旧缓存。

两层不能合并。消息级缓存必须知道消息边界——哪些渲染行属于哪条消息，这样才能按消息粒度做前缀比较。文本级缓存不需要消息边界，它缓存的是任意文本片段到渲染结果的映射，增量解析产生的新增片段可以直接查这层缓存。如果只留消息级缓存，每次消息变化就要重新解析整条消息的所有文本；如果只留文本级缓存，就失去了整条消息跳过的能力，每条消息内部的每个文本块都要单独查哈希。

## 增量解析沿块级边界切分，只处理新增内容

流式期间每隔几十毫秒新增几个 token，每次都重新解析整段文本代价太高。增量解析的目标是只处理新增部分，复用已经渲染好的前缀。

关键是找到块级边界。Markdown 的块级元素——段落、表格、代码块、列表——之间用空行分隔。空行之前的内容是完整的块，不会再因为后面新增内容而改变。空行之后的块可能还在增长，需要重新解析。find_last_block_boundary 从已渲染的前缀范围内向前扫描，找最后一个不在代码围栏内的空行，作为切分点。

代码围栏的状态要追踪。代码块内部的空行不是块级边界，在那里切分会把一个代码块拦腰截断。扫描器前向追踪反引号的开关状态，围栏内的 `\n\n` 跳过，只有围栏外的空行才算边界。

找到边界后走三条路径。边界恰好等于已渲染位置，说明前文稳定，只解析新增文本追加到末尾，这是最常见的路径。边界落在已渲染位置之前，说明当前块还在增长，比如一个段落正在输出，这种情况下丢弃边界之后的渲染结果，从边界重新解析。找不到任何边界就全量重解析兜底，发生在文本刚开始、还没有空行的时候。

## 不完整的表格行暂缓渲染，流结束强制提交

增量解析解决了普通文本的流式问题，但表格有额外的麻烦。表格的每一行列数必须和表头一致，列宽由所有行统一计算。流式时一行写到一半，`| 用户` 只有一个单元格，表头有三列，此刻渲染列宽按一列算，下一个 token 补齐成 `| 用户 | string | 高频字段 |` 后列宽跳到三列，整个表格闪烁。

TableHoldbackScanner 专门处理这个问题。它从文本末尾回溯，找连续的以 `|` 开头的行，判断这些行是否完整。判断标准有两个——最后一行是否以换行符结尾，以及每行的列数是否达到表头列数。不满足任一条件就标记 holdback，把不完整行的起始位置返回给调用方。

增量渲染拿到 holdback 决策后，把实际渲染范围截断到 holdback 位置之前。不完整的那行不进入 pulldown-cmark 解析，也就不会产生残缺的表格渲染。用户看到的效果是表格行攒够了一整行才一起出现，不会因为中间状态而列宽跳变。等下一个 chunk 补全了这一行，scanner 重新判断，列数够了就提交渲染。

流结束时还有最后一道保险。is_streaming 从 true 变成 false，说明这条消息输出完了，此时 ensure_rendered_flush 把 scanner 切到非流式模式，强制提交所有还在 holdback 的内容。即使 LLM 真的输出了残缺表格，也会原样渲染出来，不会永久吞掉。

开头那个表格最终完整地呈现在终端里。三列对齐，中文没有压扁，流式过程中没有列宽跳变。三层工程各自解决了一个环节的问题，用户看到的始终是一个完整的表格。

项目地址：[github.com/konghayao/peri](https://github.com/konghayao/peri)
