mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
6291b9bcc3
commit
0f21a3319d
16 changed files with 420 additions and 116 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
12
packages/tui/test/text.test.ts
Normal file
12
packages/tui/test/text.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue