fix(tui): reduce memory retained per rendered message

Markdown holds its parsed tokens weakly: a token tree is about ten times its
source, and every transcript message keeps a Markdown component. The tokens
still survive bursts of re-renders such as theme previews. Markdown, Text, and
Box flatten their cached lines, which V8 otherwise keeps as trees of the
concatenated parts. A long assistant message keeps about a fifth of the heap.
This commit is contained in:
Mario Zechner 2026-10-01 11:36:36 +02:00
parent 5806068c26
commit 54c19a2529
5 changed files with 23 additions and 7 deletions

View file

@ -9,6 +9,7 @@
### Fixed
- Fixed color bleeding past mouse selections and search highlights in fullscreen mode when a styled token ends at the highlight boundary ([#10169](https://github.com/earendil-works/pi/issues/10169))
- Fixed memory retained per rendered message: `Markdown` holds its parsed tokens weakly, and `Markdown`, `Text`, and `Box` flatten their cached lines. A long assistant message keeps about a fifth of the heap it kept before.
## [0.99.2] - 2026-09-30

View file

@ -1,5 +1,5 @@
import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts";
import { visibleWidth } from "../utils.ts";
import { flattenLines, visibleWidth } from "../utils.ts";
type RenderCache = {
childLines: string[];
@ -151,6 +151,7 @@ export class Box implements Component {
}
// Update cache
flattenLines(result);
this.cache = { childLines, width, bgSample, lines: result };
return result;

View file

@ -2,7 +2,7 @@ import { Marked, type Token, Tokenizer, type TokenizerExtension, type Tokens } f
import { renderLatex } from "../latex.ts";
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts";
import type { Component } from "../tui.ts";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
@ -246,8 +246,10 @@ export class Markdown implements Component {
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
// Parsed tokens depend only on the source, so they survive theme and width invalidation.
private cachedTokens?: { source: string; tokens: Token[] };
// 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[] }>;
constructor(
text: string,
@ -300,11 +302,12 @@ export class Markdown implements Component {
const normalizedText = text.replace(/\t/g, " ");
// Parse markdown to HTML-like tokens
let tokens = this.cachedTokens?.source === normalizedText ? this.cachedTokens.tokens : undefined;
const cached = this.cachedTokens?.deref();
let tokens = cached?.source === normalizedText ? cached.tokens : undefined;
if (!tokens) {
tokens = markdownParser.lexer(normalizedText);
trimPartialClosingFences(tokens);
this.cachedTokens = { source: normalizedText, tokens };
this.cachedTokens = new WeakRef({ source: normalizedText, tokens });
}
// Convert tokens to styled terminal output
@ -365,6 +368,7 @@ export class Markdown implements Component {
// Combine top padding, content, and bottom padding
const result = emptyLines.concat(contentLines, emptyLines);
flattenLines(result);
// Update cache
this.cachedText = this.text;

View file

@ -1,5 +1,5 @@
import type { Component } from "../tui.ts";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
/**
* Text component - displays multi-line text with word wrapping
@ -96,6 +96,7 @@ export class Text implements Component {
}
const result = [...emptyLines, ...contentLines, ...emptyLines];
flattenLines(result);
// Update cache
this.cachedText = this.text;

View file

@ -888,6 +888,15 @@ function splitIntoTokensWithAnsi(text: string): string[] {
* @param width - Maximum visible width per line
* @returns Array of wrapped lines (NOT padded to width)
*/
/**
* Flatten cached lines. V8 keeps a string built by concatenation as a tree of its parts until something reads it
* whole, and a cached line kept as such a tree retains several times its own size. Converting a string to a number
* reads it whole, so V8 flattens it in place; the strings' values do not change.
*/
export function flattenLines(lines: readonly string[]): void {
for (const line of lines) Number(line);
}
export function wrapTextWithAnsi(text: string, width: number): string[] {
if (!text) {
return [""];