Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 86 additions & 25 deletions apps/cli/src/ui/view/transcript/assistant-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Text,
} from "@step-harness/pi-tui";
import type { AssistantMessage } from "@step-harness/providers";
import { createMarkdownTransform } from "./markdown-transform.ts";
import { createLiveMarkdownTransform } from "./markdown-transform.ts";

const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";
Expand All @@ -18,6 +18,9 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
/** Cached zone-marked output, keyed by the child lines it was derived from. */
type ZoneCache = { width: number; native: string[]; lines: string[] };

/** A `Markdown` instance kept alive across deltas of the same message. */
type MarkdownSlot = { kind: "text" | "thinking"; markdown: Markdown };

/**
* Component that renders a complete assistant message
*/
Expand All @@ -32,6 +35,15 @@ export class AssistantMessageComponent extends Container {
private hasToolCalls = false;
private isStreaming = false;
private zoneCache?: ZoneCache;
/**
* Markdown children in content order.
*
* A streamed reply calls `updateContent` for every delta. Rebuilding the
* components would throw away the markdown renderer's certified prefix, so the
* instances are kept and re-pointed at the new text instead.
*/
private markdownSlots: MarkdownSlot[] = [];
private slotCursor = 0;

constructor(
message?: AssistantMessage,
Expand Down Expand Up @@ -80,7 +92,14 @@ export class AssistantMessageComponent extends Container {
}

setOutputPad(padding: number): void {
if (padding === this.outputPad) {
return;
}
this.outputPad = padding;
// Padding is fixed when a `Markdown` is constructed, so the slots cannot be
// reused across a padding change.
this.markdownSlots = [];
this.slotCursor = 0;
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
Expand Down Expand Up @@ -121,6 +140,7 @@ export class AssistantMessageComponent extends Container {

// Clear content container
this.contentContainer.clear();
this.slotCursor = 0;

const hasVisibleContent = message.content.some(
(c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
Expand All @@ -136,11 +156,7 @@ export class AssistantMessageComponent extends Container {
if (content.type === "text" && content.text.trim()) {
// Assistant text messages with no background - trim the text
// Set paddingY=0 to avoid extra spacing before tool executions
this.contentContainer.addChild(
new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme, undefined, {
transform: createMarkdownTransform("assistant", this.isStreaming, this.markdownTransformers),
}),
);
this.contentContainer.addChild(this.textMarkdown(content.text.trim()));
} else if (content.type === "thinking") {
const thinkingBlocks: string[] = [];
for (; i < message.content.length; i++) {
Expand Down Expand Up @@ -172,31 +188,14 @@ export class AssistantMessageComponent extends Container {
);
} else {
// Render each run of thinking blocks as one Markdown section.
this.contentContainer.addChild(
new Markdown(
thinkingBlocks.join("\n\n"),
this.outputPad,
0,
{ ...this.markdownTheme, code: (text: string) => theme.fg("muted", text) },
{
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
},
{
transform: createMarkdownTransform(
"assistant-thinking",
this.isStreaming,
this.markdownTransformers,
),
},
),
);
this.contentContainer.addChild(this.thinkingMarkdown(thinkingBlocks.join("\n\n")));
}
if (hasVisibleContentAfter) {
this.contentContainer.addChild(new Spacer(1));
}
}
}
this.markdownSlots.length = this.slotCursor;

// Check if incomplete/failed - show after partial content.
// For aborted/error tool calls, tool execution components show the error.
Expand All @@ -223,4 +222,66 @@ export class AssistantMessageComponent extends Container {
}
}
}

/** Markdown for an assistant text block, reused while the block keeps its kind. */
private textMarkdown(text: string): Markdown {
return this.claimSlot(
"text",
(value) =>
new Markdown(value, this.outputPad, 0, this.markdownTheme, undefined, {
transform: createLiveMarkdownTransform(
"assistant",
() => this.isStreaming,
() => this.markdownTransformers,
),
}),
text,
);
}

/** Markdown for one run of thinking blocks. */
private thinkingMarkdown(text: string): Markdown {
return this.claimSlot(
"thinking",
(value) =>
new Markdown(
value,
this.outputPad,
0,
{ ...this.markdownTheme, code: (code: string) => theme.fg("muted", code) },
{
color: (styled: string) => theme.fg("thinkingText", styled),
italic: true,
},
{
transform: createLiveMarkdownTransform(
"assistant-thinking",
() => this.isStreaming,
() => this.markdownTransformers,
),
},
),
text,
);
}

/**
* Return the slot at the current content position, re-pointing it at `text`.
*
* Slots are matched by kind rather than by content index: a message can gain a
* thinking block in front of a text block, and an instance is only worth keeping
* when it renders the same kind of content as before.
*/
private claimSlot(kind: MarkdownSlot["kind"], create: (text: string) => Markdown, text: string): Markdown {
const slot = this.markdownSlots[this.slotCursor];
if (slot !== undefined && slot.kind === kind) {
slot.markdown.setText(text);
this.slotCursor++;
return slot.markdown;
}
const markdown = create(text);
this.markdownSlots[this.slotCursor] = { kind, markdown };
this.slotCursor++;
return markdown;
}
}
20 changes: 20 additions & 0 deletions apps/cli/src/ui/view/transcript/markdown-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ export function createMarkdownTransform(
applyMarkdownTransformers(markdown, { messageType, isStreaming, availableWidth }, transformers);
}

/**
* Transform for a component that outlives a single frame.
*
* A reused `Markdown` instance keeps the transform it was constructed with, so the
* streaming state has to be read at call time: the final frame of a reply renders
* in "not streaming" mode, and Mermaid warnings only appear then.
*/
export function createLiveMarkdownTransform(
messageType: MarkdownTransformContext["messageType"],
readIsStreaming: () => boolean,
readTransformers: () => readonly MarkdownTransformer[],
): (markdown: string, availableWidth: number) => string {
return (markdown, availableWidth) =>
applyMarkdownTransformers(
markdown,
{ messageType, isStreaming: readIsStreaming(), availableWidth },
readTransformers(),
);
}

function applyMarkdownTransformers(
markdown: string,
context: MarkdownTransformContext,
Expand Down
47 changes: 47 additions & 0 deletions docs/tui-rendering-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,53 @@

容器层之上没有脏标记:组件靠"返回同一个数组实例"表达"我没变",靠 `invalidate()` 丢弃自己的缓存。产品侧大量使用后者(主题切换、语法高亮加载完成时全树 `invalidate`)。

### 4.1 块级数学 tokenizer 的扫描成本

`Markdown` 为块级数学(`$$…$$`、`\[…\]`)注册了一个 Marked block 扩展(`LATEX_MARKDOWN_EXTENSIONS` 的 `latexBlock`)。Marked 只在**即将进入 paragraph / text 之前**才收集 block 扩展的 `start()`:space、code、heading、hr、blockquote、list、html、def、table、lheading 命中时根本不会调用它。调用时传入的是 `src.slice(1)`,即**尚未消费的剩余源文本**。所以一次全文正则的代价是 `O(段落数 × 剩余长度)`,而不是 `O(块数 × 长度)`;标题、围栏、表格、分隔线不触发。

多数文档根本不含块级数学,所以该判据先做子串检查(`includes("$$")` / `includes("\\[")`),只有可能匹配时才跑正则。语义不变:正则能匹配的字符串必然包含这两个子串之一,短路不会漏判;子串检查的误判(例如围栏代码里的 `\[`)只是多跑一次正则。落点是 `components/markdown-stream.ts` 的 `findBlockMathStart`,tokenizer 的 `start()` 与流式渲染的 hazard 扫描共用它,因此"两处字面量必须同步"这个静默失败点不存在。

注意这**不是**把扫描消掉:短路后每个段落边界仍会扫一遍剩余文本,只是常数更小。本机 146 KB 混合消息、120 列、120 B/delta 的流式模拟里,这项短路把累计耗时从 12 340 ms 降到 9 845 ms(约 1.3 倍)。回归门禁用确定性的正则调用计数(`test/markdown-block-math.test.ts`),不依赖实际耗时。

### 4.2 认证前缀:流式增量渲染

流式回复每个 delta 都调用一次 `Markdown.setText`。整篇重 lex、重渲染、重换行让累计成本按消息长度的平方增长,所以 `Markdown` 保留"已经确定终结"的块的行,只重算可变尾部(`renderContent` / `renderFrom`)。这不是"留最后一块"的启发式:只有能证明"从这里以全新 Lexer 重启,与渲染全文等价"的位置才会被冻结。

保留的状态(`packages/tui/src/components/markdown.ts`):

- `stableSourceEnd`:已认证前缀的长度(归一化后的 UTF-16 偏移;tab 展开是逐字符替换,前缀关系保持)。
- `stablePaddedLines`:前缀的最终行(已加上左右 margin、背景、右侧补白),直接参与拼接。
- `stableNoWrap`:前缀里围栏代码行的 pre-wrap 文本。换行阶段的 no-wrap 判定按**行文本**而不是来源,所以这些行必须继续参与后续帧的判定。
- `stableWrappedProseRows`:前缀里被**软换行**过的散文行文本。它是"未来影响过去"的唯一入口,见下。
- `guard`:前缀留给尾部的解析事实——块级数学是否出现、是否有未闭合的行首 `[`、inline `<a …>` 与 `<pre|code|kbd|script>` 的嵌套深度。
- `previousSource` / `layoutWidth`:判断这一帧是不是同一份文档的追加,以及宽度是否变化。

认证条件(`findCertifiedCut`):候选必须紧跟一个**顶层 `space` token**(Marked 会把单个换行折进前一个 token,所以顶层 `space` 一定覆盖一个真实空行),且它后面的 token 是"见证块"——列 0 开始、首物理行已结束的 paragraph 或 heading。list、blockquote、table、围栏、缩进代码都不能当见证:它们可以跨空行继续吞入,把候选点之前的块重新解释(list 合并与序号重排、table 加行改列宽、` a\n\n b` 仍是同一个缩进代码块)。返回最靠右的候选;没有候选就不推进,尾部仍由 canonical Lexer 完整解释并显示所有不完整行。

必须整篇重算的情形分三类,混为一谈会把"有未闭合围栏"实现成"每帧全文渲染",直接吃掉代码占比高的收益:

- **粘性(直到文本被替换)**:任何行首块级数学 opener(pending latex token 会重剪它前面的段落)、任意 reference definition(`tokens.links` 非空,定义对所有块可见)。
- **本帧重算一次**:宽度、padding、主题或 `invalidate()` 变化;文本被替换而不是追加;`message_end` 的流式/终态切换。
- **只重算尾部**:没有新的证书、未闭合的 label 或 inline 标签、持续的 list/table/缩进代码/quote、open 或 EOF 临时围栏、token raw 无法精确重建(Marked 会规范化部分行尾空白,此时本帧不推进偏移,但不必把整份文档标记为粘性)。

`nowrapLines` 的语义没有改:它仍然是文档级的行文本集合,围栏代码行让**同文本**的散文行变成硬裁剪。增量路径用两件事保持等价:尾部渲染前用 `stableNoWrap` 播种,尾部散文行因此照旧被裁剪;而尾部新出现的代码行一旦与 `stableWrappedProseRows` 撞文本,就说明已冻结的散文行本该被裁剪,这一帧整篇重渲(`collidesWithStableRows`)。被裁剪过的行不进 `stableWrappedProseRows`——集合只会增长,裁剪是不可逆的。

`renderDirtyStart` 报告"相对上一帧输出可能变化的最早行",复用前缀时等于 `paddingY + 上一帧的前缀行数`,整篇重算时是 0(上下 padding 行内容相同,但换主题时它们的底色也会变,不能假定可复用),交回 `Container.render` 做前缀复用。返回的数组每帧都是新数组,已发出的数组不会被就地修改。

正确性门禁在 `test/markdown-stream-prefix.test.ts`:每个前缀、多种宽度,与"新建实例"的冷渲染逐字节比较,覆盖 35 份针对上述构造的对抗 fixture、仓库文档抽样、以及 200 份固定种子的块结构组合。`test/markdown-stream-work.test.ts` 断言工作量而不是耗时:60 KB 混合文档流式渲染只把约两倍文档长度的源码交给 lexer,文档翻倍时 lex/渲染/换行的计数不超过 2.5 倍;块级数学、reference definition、无空行段落、代码行撞文本这些情形分别断言"从不复用前缀"且输出仍与冷渲染一致。

实测(本机,120 列,120 B/delta,累计耗时含每帧完整管线):

| 场景 | 现状(每帧新建实例) | 增量渲染 |
| --- | --- | --- |
| 60 KB 散文 | 1 437 ms | 18 ms(约 79 倍) |
| 60 KB 表格 | 2 095 ms | 52 ms(约 40 倍) |
| 60 KB 50% 代码 | 462 ms | 13 ms(约 35 倍) |
| 60 KB 单个超长段落(无可认证点) | 403 ms | 411 ms(无收益,输出正确) |
| 146 KB 混合 | 12 340 ms | 275 ms(约 45 倍) |

调用方必须复用同一个 `Markdown` 实例,否则实例级状态随组件一起消失;`AssistantMessageComponent` 因此按内容种类保留实例并只调 `setText`(`markdownSlots`)。每帧仍要做一次指针复制拼出扁平数组,以及主屏/全屏自己的差分;这两项是独立成本,不随前缀复用消失。Step 呈现的包装层(`step-message.ts` 的 `renderNativeRun`:`hideCompleteFences`、空行折叠、`trimOuterRows`)同样每帧扫一遍全部 native 行,那部分成本不受这里影响,需要单独处理。

## 5. 主屏差分渲染(regular)

`TuiMainScreen.doRender`(`tui-main-screen.ts:329-750`)每帧的完整顺序:
Expand Down
Loading
Loading