From 54c19a252997ee6607e2379b4c168a0adc93338e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Oct 2026 11:36:36 +0200 Subject: [PATCH] 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. --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/box.ts | 3 ++- packages/tui/src/components/markdown.ts | 14 +++++++++----- packages/tui/src/components/text.ts | 3 ++- packages/tui/src/utils.ts | 9 +++++++++ 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 388ee4903..8b9f681d3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 7c880d914..c70bd6003 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -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; diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 30c40c171..2fe05aad4 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -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; diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 7a50e2721..2ce82e742 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -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; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index c07945887..be852a32d 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -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 [""];