fix(coding-agent,tui): reuse syntax highlighting and rendered Markdown blocks

Codemode and write rows keep their last highlighting in a CodeHighlighter
that checks theme and grammar changes itself. Markdown reuses the top-level
blocks of its last render, held weakly, and assistant messages reuse their
Markdown instances while streaming. Text.setText() keeps its wrapped lines
for unchanged text.

closes #10971
This commit is contained in:
Armin Ronacher 2026-11-01 14:40:17 +01:00
parent 6291b9bcc3
commit 0f21a3319d
16 changed files with 420 additions and 116 deletions

View file

@ -18,6 +18,8 @@
- Fixed display math wider than the content width being wrapped row by row, which split fractions and sums apart. It now falls back to the raw LaTeX source ([#11483](https://github.com/earendil-works/pi/issues/11483))
- Fixed `tui.select.pageUp` and `tui.select.pageDown` being ignored by `SelectList`, `SettingsList`, and editor autocomplete ([#7629](https://github.com/earendil-works/pi/issues/7629), based on [#7680](https://github.com/earendil-works/pi/pull/7680) by [@muyiyr](https://github.com/muyiyr))
- Fixed `wrapTextWithAnsi()` starting the next line with a space when a styled word ended exactly at the wrap width, and strikethrough continuing into the right padding of wrapped lines ([#10925](https://github.com/earendil-works/pi/issues/10925))
- Fixed `Markdown` styling, highlighting, and wrapping every block on each render. It now reuses the top-level blocks of its last render, so a streaming message renders only its changed last block, and a resize reuses highlighted code. Reused blocks are held weakly and dropped by `invalidate()`, which a theme change must call because `MarkdownTheme.highlightCode` results are reused until then ([#10971](https://github.com/earendil-works/pi/issues/10971))
- Fixed `Text.setText()` with unchanged text dropping the wrapped lines, so renderers that set the same text on every update wrapped it again ([#10971](https://github.com/earendil-works/pi/issues/10971))
## [1.4.0] - 2026-10-21

View file

@ -1,4 +1,4 @@
import { Marked, type Token, Tokenizer, type TokenizerExtension, type Tokens } from "marked";
import { Marked, type Token, Tokenizer, type TokenizerExtension, type Tokens, type TokensList } from "marked";
import markedCjkFriendly from "marked-cjk-friendly";
import { renderLatex } from "../latex.ts";
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts";
@ -244,6 +244,7 @@ export interface MarkdownTheme {
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
/** Highlighted lines of a code block. A Markdown component reuses the result for the same code until invalidate(). */
highlightCode?: (code: string, lang?: string) => string[];
/** Prefix applied to each rendered code block line (default: " ") */
codeBlockIndent?: string;
@ -265,6 +266,16 @@ interface InlineStyleContext {
stylePrefix: string;
}
/** A top-level block of the last render, keyed by its source, reused by the next render. */
interface RenderedBlock {
/** Width and type of the following block (which decides the spacing) that the lines were rendered for. */
width: number;
nextTokenType: string | undefined;
lines: string[];
/** Highlighted lines of the block's code, keyed by language and source; they survive a resize or new spacing. */
highlightedCode: Map<string, string[]>;
}
export class Markdown implements Component {
private text: string;
private paddingX: number; // Left/right padding
@ -281,7 +292,14 @@ export class Markdown implements Component {
// Parsed tokens depend only on the source, so they survive theme and width invalidation. Held weakly: a token tree is
// about ten times the size of its source, and every message of a long transcript keeps a Markdown component. The
// tokens survive a burst of re-renders, such as a theme preview, and are collected afterwards.
private cachedTokens?: WeakRef<{ source: string; tokens: Token[] }>;
private cachedTokens?: WeakRef<{ source: string; tokens: TokensList }>;
// The last render's top-level blocks. While a message streams only its last block changes, so a render styles and
// wraps only that block. Each render keeps only the blocks it used, which drops a streamed block's earlier versions.
// Held weakly like the tokens: the blocks repeat the source in their keys and the code in their highlighting, and
// only a burst of renders, such as streaming or a resize, needs them.
private blocks?: WeakRef<Map<string, RenderedBlock>>;
// Highlighted code of the block being rendered, only set while renderBlock runs.
private blockHighlightedCode?: Map<string, string[]>;
constructor(
text: string,
@ -301,10 +319,17 @@ export class Markdown implements Component {
setText(text: string): void {
this.text = text;
this.invalidate();
this.clearRenderCache();
}
invalidate(): void {
this.clearRenderCache();
// The theme may have changed, which changes every styled line and the default style's prefix.
this.blocks = undefined;
this.defaultStylePrefix = undefined;
}
private clearRenderCache(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
@ -322,6 +347,7 @@ export class Markdown implements Component {
// Don't render anything if there's no actual text
if (!text || text.trim() === "") {
this.blocks = undefined;
const result: string[] = [];
// Update cache
this.cachedText = this.text;
@ -342,65 +368,39 @@ export class Markdown implements Component {
this.cachedTokens = new WeakRef({ source: normalizedText, tokens });
}
// Convert tokens to styled terminal output
const renderedLines: string[] = [];
// A reference link resolves through a definition anywhere in the text, so blocks with the same source can render
// differently; such texts are rare in practice and render without reusing blocks.
const reuseBlocks = Object.keys(tokens.links).length === 0;
const previousBlocks = this.blocks?.deref();
const blocks = new Map<string, RenderedBlock>();
this.blocks = new WeakRef(blocks);
const contentLines: string[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
for (const tokenLine of tokenLines) {
renderedLines.push(tokenLine);
const nextTokenType = tokens[i + 1]?.type;
const reused = reuseBlocks ? (blocks.get(token.raw) ?? previousBlocks?.get(token.raw)) : undefined;
const block = reused ?? { width: -1, nextTokenType, lines: [], highlightedCode: new Map() };
if (block.width !== width || block.nextTokenType !== nextTokenType) {
this.renderBlock(block, token, nextTokenType, width, contentWidth);
}
}
// Wrap lines (NO padding, NO background yet)
const wrappedLines: string[] = [];
for (const line of renderedLines) {
if (isImageLine(line)) {
wrappedLines.push(line);
} else {
for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) {
wrappedLines.push(wrappedLine);
}
}
}
// Add margins and background to each wrapped line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const bgFn = this.defaultTextStyle?.bgColor;
const contentLines: string[] = [];
for (const line of wrappedLines) {
if (isImageLine(line)) {
if (reuseBlocks) blocks.set(token.raw, block);
for (const line of block.lines) {
contentLines.push(line);
continue;
}
const lineWithMargins = leftMargin + line + rightMargin;
if (bgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
} else {
// No background - just pad to width
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const bgFn = this.defaultTextStyle?.bgColor;
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
emptyLines.push(line);
}
flattenLines(emptyLines);
// Combine top padding, content, and bottom padding
const result = emptyLines.concat(contentLines, emptyLines);
flattenLines(result);
// Update cache
this.cachedText = this.text;
@ -410,6 +410,42 @@ export class Markdown implements Component {
return result.length > 0 ? result : [""];
}
/** Render one top-level block to output lines: wrapped, with margins, and padded or backgrounded to the width. */
private renderBlock(
block: RenderedBlock,
token: Token,
nextTokenType: string | undefined,
width: number,
contentWidth: number,
): void {
this.blockHighlightedCode = block.highlightedCode;
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const bgFn = this.defaultTextStyle?.bgColor;
const lines: string[] = [];
for (const line of this.renderToken(token, contentWidth, nextTokenType)) {
if (isImageLine(line)) {
lines.push(line);
continue;
}
for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) {
const lineWithMargins = leftMargin + wrappedLine + rightMargin;
if (bgFn) {
lines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
} else {
// No background - just pad to width
const paddingNeeded = Math.max(0, width - visibleWidth(lineWithMargins));
lines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
}
this.blockHighlightedCode = undefined;
flattenLines(lines);
block.lines = lines;
block.width = width;
block.nextTokenType = nextTokenType;
}
/**
* Apply default text style to a string.
* This is the base styling applied to all text content.
@ -567,8 +603,13 @@ export class Markdown implements Component {
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
const key = `${token.lang ?? ""}\0${token.text}`;
let highlighted = this.blockHighlightedCode?.get(key);
if (!highlighted) {
highlighted = this.theme.highlightCode(token.text, token.lang);
this.blockHighlightedCode?.set(key, highlighted);
}
for (const hlLine of highlighted) {
lines.push(`${indent}${hlLine}`);
}
} else {

View file

@ -23,6 +23,8 @@ export class Text implements Component {
}
setText(text: string): void {
// Renderers often set the same text on every update; keep the wrapped lines then.
if (text === this.text) return;
this.text = text;
this.cachedText = undefined;
this.cachedWidth = undefined;

View file

@ -1,5 +1,7 @@
import assert from "node:assert";
import { afterEach, describe, it } from "node:test";
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { Chalk } from "chalk";
import { Markdown, type MarkdownTheme } from "../src/components/markdown.ts";
@ -65,6 +67,63 @@ describe("Markdown component", () => {
});
});
describe("Block reuse", () => {
afterEach(() => resetCapabilitiesCache());
function countingTheme(calls: string[]): MarkdownTheme {
return {
...defaultMarkdownTheme,
highlightCode: (code, lang) => {
calls.push(`${lang}:${code}`);
return code.split("\n").map((line) => chalk.green(line));
},
};
}
it("highlights a code block once across streamed text and resizes, and again after invalidate", () => {
const calls: string[] = [];
const markdown = new Markdown("```ts\nx\n```", 0, 0, countingTheme(calls));
markdown.render(80);
markdown.setText("```ts\nx\n```\n\nafter");
markdown.render(40);
assert.deepStrictEqual(calls, ["ts:x"]);
markdown.invalidate();
markdown.render(40);
assert.deepStrictEqual(calls, ["ts:x", "ts:x"]);
});
it("holds rendered blocks weakly, so a full garbage collection releases them", async () => {
setFlagsFromString("--expose-gc");
const gc = runInNewContext("gc") as () => void;
const calls: string[] = [];
const markdown = new Markdown("```ts\nx\n```", 0, 0, countingTheme(calls));
markdown.render(80);
// A WeakRef keeps its target alive until the current job ends.
await new Promise((resolve) => setImmediate(resolve));
gc();
markdown.render(40);
assert.deepStrictEqual(calls, ["ts:x", "ts:x"]);
});
it("renders a block again when the block after it changes its spacing", () => {
const markdown = new Markdown("# Title", 0, 0, defaultMarkdownTheme);
markdown.render(80);
markdown.setText("# Title\nfoo");
assert.deepStrictEqual(
markdown.render(80).map((line) => stripAnsi(line).trim()),
["Title", "", "foo"],
);
});
it("follows changed reference link definitions", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
const markdown = new Markdown("[a][x]\n\n[x]: https://one.example", 0, 0, defaultMarkdownTheme);
markdown.render(80);
markdown.setText("[a][x]\n\n[x]: https://two.example");
assert.ok(stripAnsi(markdown.render(80).join("\n")).includes("https://two.example"));
});
});
describe("Lists", () => {
it("should render simple nested list", () => {
const markdown = new Markdown(

View file

@ -0,0 +1,12 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Text } from "../src/components/text.ts";
describe("Text", () => {
it("keeps its wrapped lines when the same text is set again", () => {
const text = new Text("hello world", 0, 0);
const lines = text.render(5);
text.setText("hello world");
assert.strictEqual(text.render(5), lines);
});
});