fix(tui): cached streaming markdown prefix renders

Reused rendered frozen-prefix lines during transient Markdown renders so streaming assistant updates avoid re-rendering stable code blocks and other completed blocks.

Added focused regression coverage for prefix reuse and cache advancement across newly frozen blocks.

Fixes #3975
This commit is contained in:
roboomp
2026-07-01 02:46:36 +00:00
parent b2a859a7c5
commit 7054265483
3 changed files with 242 additions and 41 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed streaming markdown renders reprocessing the already-frozen prefix on every delta, so transient assistant-message paints reuse rendered prefix lines and only render the changed tail ([#3975](https://github.com/can1357/oh-my-pi/issues/3975)).
## [16.2.10] - 2026-06-30
### Fixed
+157 -41
View File
@@ -769,6 +769,26 @@ function codespanSwatch(code: string, glyph: string): string {
return colorSwatch(match[1], glyph);
}
interface RenderSignature {
width: number;
paddingX: number;
paddingY: number;
codeBlockIndent: number;
themeId: number;
defaultTextStyleId: number;
imageProtocol: string;
hyperlinks: boolean;
textSizing: boolean;
bgColorProbe: string;
headingProbe: string;
}
interface StreamPrefixLineCache extends RenderSignature {
text: string;
tokenCount: number;
lines: readonly string[];
}
export class Markdown implements Component {
#text: string;
#paddingX: number; // Left/right padding
@@ -796,6 +816,7 @@ export class Markdown implements Component {
// tokenization, so this cache is independent of the render caches above.
#streamPrefixText?: string;
#streamPrefixTokens?: Token[];
#streamPrefixLineCache?: StreamPrefixLineCache;
#ignoreTight = false;
@@ -829,6 +850,7 @@ export class Markdown implements Component {
// outlives the content it indexed.
this.#streamPrefixText = undefined;
this.#streamPrefixTokens = undefined;
this.#streamPrefixLineCache = undefined;
}
this.invalidate();
}
@@ -942,6 +964,7 @@ export class Markdown implements Component {
// Replace tabs with 3 spaces for consistent rendering
const normalizedText = replaceTabs(this.#text);
const signature = this.#renderSignature(width, paddingX);
// L2: module-level LRU — survives component disposal/recreation across
// session-tree navigations. Key encodes every dimension that affects the
@@ -957,9 +980,7 @@ export class Markdown implements Component {
// by MarkdownTheme and is one of the most styling-sensitive entries.
let cacheKey: string | undefined;
if (!this.transientRenderCache) {
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
const headingProbe = this.#theme.heading("");
cacheKey = `${normalizedText}\x00${width}\x00${paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${TERMINAL.textSizing ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
cacheKey = this.#renderCacheKey(normalizedText, signature);
const cached = renderCache.get(cacheKey);
if (cached !== undefined) {
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
@@ -972,18 +993,130 @@ export class Markdown implements Component {
// Parse markdown to HTML-like tokens
const tokens = this.#lexTokens(normalizedText);
const contentLines = this.transientRenderCache
? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
const emptyLines = this.#renderEmptyPaddingLines(signature);
// Convert tokens to styled terminal output
const renderedLines: string[] = [];
// Combine top padding, content, and bottom padding
const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
const result = rawResult.length > 0 ? rawResult : [""];
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);
renderedLines.push(...tokenLines);
// Update caches and hand the array out by reference. Callers must not
// mutate it (Component render contract); the L2 entry is shared across
// instances keyed on identical inputs.
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = result;
// Update L2 module-level LRU so future instances with the same key skip
// the marked.lexer + highlightCode (Rust FFI) work entirely.
if (cacheKey !== undefined) {
renderCache.set(cacheKey, result);
}
return result;
}
#renderSignature(width: number, paddingX: number): RenderSignature {
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
const headingProbe = this.#theme.heading("");
return {
width,
paddingX,
paddingY: this.#paddingY,
codeBlockIndent: this.#codeBlockIndent,
themeId: objectId(this.#theme),
defaultTextStyleId: this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1,
imageProtocol: TERMINAL.imageProtocol ?? "",
hyperlinks: TERMINAL.hyperlinks,
textSizing: TERMINAL.textSizing,
bgColorProbe,
headingProbe,
};
}
#renderCacheKey(normalizedText: string, signature: RenderSignature): string {
return `${normalizedText}\x00${signature.width}\x00${signature.paddingX}\x00${signature.paddingY}\x00${signature.codeBlockIndent}\x00${signature.themeId}\x00${signature.defaultTextStyleId}\x00${signature.imageProtocol}\x00${signature.hyperlinks ? 1 : 0}\x00${signature.textSizing ? 1 : 0}\x00${signature.bgColorProbe}\x00${signature.headingProbe}`;
}
#renderStreamingContentLines(
tokens: Token[],
normalizedText: string,
signature: RenderSignature,
contentWidth: number,
): string[] {
const frozenText = this.#streamPrefixText;
const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
}
const contentLines: string[] = [];
const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
let renderedUntil = 0;
if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
contentLines.push(...reusablePrefix.lines);
renderedUntil = reusablePrefix.tokenCount;
}
if (renderedUntil < frozenTokenCount) {
contentLines.push(
...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
);
renderedUntil = frozenTokenCount;
}
this.#streamPrefixLineCache = {
...signature,
text: frozenText,
tokenCount: frozenTokenCount,
lines: contentLines.slice(),
};
if (renderedUntil < tokens.length) {
contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
}
return contentLines;
}
#matchingStreamPrefixLineCache(
normalizedText: string,
frozenText: string,
signature: RenderSignature,
): StreamPrefixLineCache | undefined {
const cache = this.#streamPrefixLineCache;
if (!cache) return undefined;
if (!normalizedText.startsWith(cache.text) || !frozenText.startsWith(cache.text)) return undefined;
if (cache.width !== signature.width) return undefined;
if (cache.paddingX !== signature.paddingX) return undefined;
if (cache.paddingY !== signature.paddingY) return undefined;
if (cache.codeBlockIndent !== signature.codeBlockIndent) return undefined;
if (cache.themeId !== signature.themeId) return undefined;
if (cache.defaultTextStyleId !== signature.defaultTextStyleId) return undefined;
if (cache.imageProtocol !== signature.imageProtocol) return undefined;
if (cache.hyperlinks !== signature.hyperlinks) return undefined;
if (cache.textSizing !== signature.textSizing) return undefined;
if (cache.bgColorProbe !== signature.bgColorProbe) return undefined;
if (cache.headingProbe !== signature.headingProbe) return undefined;
return cache;
}
#renderContentLines(
tokens: Token[],
start: number,
end: number,
contentWidth: number,
signature: RenderSignature,
): string[] {
const renderedLines: string[] = [];
for (let i = start; i < end; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
renderedLines.push(...this.#renderToken(token, contentWidth, nextToken?.type));
}
// Wrap lines (NO padding, NO background yet)
const wrappedLines: string[] = [];
for (const line of renderedLines) {
// Skip wrapping for image protocol lines and OSC 66 sized headings
@@ -995,12 +1128,10 @@ export class Markdown implements Component {
}
}
// Add margins and background to each wrapped line
const leftMargin = padding(paddingX);
const rightMargin = padding(paddingX);
const leftMargin = padding(signature.paddingX);
const rightMargin = padding(signature.paddingX);
const bgFn = this.#defaultTextStyle?.bgColor;
const contentLines: string[] = [];
let previousLineWasOsc66 = false;
for (const line of wrappedLines) {
@@ -1023,45 +1154,30 @@ export class Markdown implements Component {
}
previousLineWasOsc66 = false;
const lineWithMargins = leftMargin + line + rightMargin;
if (bgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
contentLines.push(applyBackgroundToLine(lineWithMargins, signature.width, bgFn));
} else {
// No background - just pad to width
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
const paddingNeeded = Math.max(0, signature.width - visibleLen);
contentLines.push(lineWithMargins + padding(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = padding(width);
return contentLines;
}
#renderEmptyPaddingLines(signature: RenderSignature): string[] {
const emptyLine = padding(signature.width);
const emptyLines: string[] = [];
for (let i = 0; i < this.#paddingY; i++) {
const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
const bgFn = this.#defaultTextStyle?.bgColor;
for (let i = 0; i < signature.paddingY; i++) {
const line = bgFn ? applyBackgroundToLine(emptyLine, signature.width, bgFn) : emptyLine;
emptyLines.push(line);
}
// Combine top padding, content, and bottom padding
const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
const result = rawResult.length > 0 ? rawResult : [""];
// Update caches and hand the array out by reference. Callers must not
// mutate it (Component render contract); the L2 entry is shared across
// instances keyed on identical inputs.
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = result;
// Update L2 module-level LRU so future instances with the same key skip
// the marked.lexer + highlightCode (Rust FFI) work entirely.
if (cacheKey !== undefined) {
renderCache.set(cacheKey, result);
}
return result;
return emptyLines;
}
/**
@@ -0,0 +1,81 @@
import { describe, expect, it } from "bun:test";
import { clearRenderCache, Markdown, type MarkdownTheme } from "@oh-my-pi/pi-tui/components/markdown";
import { defaultMarkdownTheme } from "./test-themes.js";
const WIDTH = 72;
const FROZEN_CODE_PREFIX = "```ts\nconst frozen = 1;\n```\n\n";
function renderCold(text: string, theme: MarkdownTheme): readonly string[] {
clearRenderCache();
const md = new Markdown(text, 0, 0, theme);
return md.render(WIDTH);
}
describe("Markdown streaming prefix render cache", () => {
it("reuses rendered frozen prefix lines during transient append renders", () => {
let codeBlockCalls = 0;
let codeBlockBorderCalls = 0;
const theme: MarkdownTheme = {
...defaultMarkdownTheme,
codeBlock: text => {
codeBlockCalls++;
return defaultMarkdownTheme.codeBlock(text);
},
codeBlockBorder: text => {
codeBlockBorderCalls++;
return defaultMarkdownTheme.codeBlockBorder(text);
},
};
const firstText = `${FROZEN_CODE_PREFIX}tail one`;
const secondText = `${FROZEN_CODE_PREFIX}tail one plus more streamed words`;
const md = new Markdown(firstText, 0, 0, theme);
md.transientRenderCache = true;
md.render(WIDTH);
codeBlockCalls = 0;
codeBlockBorderCalls = 0;
md.setText(secondText);
const streamingLines = md.render(WIDTH);
expect(codeBlockCalls).toBe(0);
expect(codeBlockBorderCalls).toBe(0);
expect(streamingLines).toEqual(renderCold(secondText, theme));
});
it("advances the rendered prefix cache when a new stable block freezes", () => {
let codeBlockCalls = 0;
let codeBlockBorderCalls = 0;
const theme: MarkdownTheme = {
...defaultMarkdownTheme,
codeBlock: text => {
codeBlockCalls++;
return defaultMarkdownTheme.codeBlock(text);
},
codeBlockBorder: text => {
codeBlockBorderCalls++;
return defaultMarkdownTheme.codeBlockBorder(text);
},
};
const firstBlock = "```ts\nconst first = 1;\n```\n\n";
const secondBlock = "```ts\nconst second = 2;\n```\n\n";
const firstText = `${firstBlock}first tail`;
const secondText = `${firstBlock}${secondBlock}second tail`;
const thirdText = `${firstBlock}${secondBlock}second tail plus more words`;
const md = new Markdown(firstText, 0, 0, theme);
md.transientRenderCache = true;
md.render(WIDTH);
md.setText(secondText);
md.render(WIDTH);
codeBlockCalls = 0;
codeBlockBorderCalls = 0;
md.setText(thirdText);
const streamingLines = md.render(WIDTH);
expect(codeBlockCalls).toBe(0);
expect(codeBlockBorderCalls).toBe(0);
expect(streamingLines).toEqual(renderCold(thirdText, theme));
});
});