From 18dee5f0a89f41466e876cbbbfe77635cd250882 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 8 Aug 2026 12:06:16 +0200 Subject: [PATCH] fix(tui): stop recompositing full-width rows in the alt-screen painter paintBox ran every visible text row through compositeTuiLine on every frame, rebuilding each row string via ANSI/grapheme segmentation even when nothing changed. Full-width boxes painting onto untouched rows now assign the source line reference directly; padding is unnecessary since rows are written with erase-line and the final width clamp still truncates over-wide lines. Cuts alt-screen per-frame allocation churn 9-18x (test/render-churn-bench.ts) and removes the fullscreen RSS penalty over regular mode on short sessions (previously +11-29 MiB loaded, +26-36 MiB peak). --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/layout.ts | 12 +- packages/tui/test/render-churn-bench.ts | 203 +++++++++++++++++++++++ packages/tui/test/tui-alt-screen.test.ts | 6 +- 4 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 packages/tui/test/render-churn-bench.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 96c181613..1b30480b7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Reduced alternate-screen per-frame allocation churn roughly 9-18x by painting full-width layout rows as direct line references instead of recompositing every visible row through ANSI/grapheme segmentation on each frame. + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index a139a2fff..51f738ecd 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -316,8 +316,16 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { const visibleRows = Math.min(imageMetadata.rows, clipBottom - row); if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows); } - if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line; - else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + // Fast path: a full-width box painting onto an untouched row can use the + // source line reference directly. Compositing here would rebuild the row + // string through ANSI/grapheme segmentation every frame; padding is + // unnecessary because rows are written with erase-line and the final + // width clamp still truncates over-wide lines. + if (box.rect.x === 0 && box.rect.width >= totalWidth && (isImageLine(line) || !screen[row])) { + screen[row] = line; + } else { + screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + } } } for (const child of box.children) paintBox(child, screen, totalWidth); diff --git a/packages/tui/test/render-churn-bench.ts b/packages/tui/test/render-churn-bench.ts new file mode 100644 index 000000000..103c9561b --- /dev/null +++ b/packages/tui/test/render-churn-bench.ts @@ -0,0 +1,203 @@ +/** + * Alt-screen render churn benchmark. + * + * Measures cumulative JS allocation and wall time for repeated TuiAltScreen + * frames on a layout mirroring pi's fullscreen interactive mode: + * VStack [ ScrollView(transcript), dock VStack [status, editor, footer] ]. + * + * Two scenarios: + * - static: nothing changes between frames (pure recomposite churn) + * - editor: one character appended to the editor per frame (doc scenario + * "30 editor updates") + * + * Allocation is estimated with the V8 sampling heap profiler including + * objects collected by minor/major GC, i.e. it measures churn, not retention. + * + * Run from packages/tui: node test/render-churn-bench.ts + */ + +import { Session } from "node:inspector/promises"; +import { performance } from "node:perf_hooks"; +import { ScrollView } from "../src/components/scroll-view.ts"; +import { Text } from "../src/components/text.ts"; +import { VStack } from "../src/components/v-stack.ts"; +import type { Terminal } from "../src/terminal.ts"; +import { type Component, Container, CURSOR_MARKER } from "../src/tui.ts"; +import { TuiAltScreen } from "../src/tui-alt-screen.ts"; + +const COLUMNS = 100; +const ROWS = 30; +const WARMUP_FRAMES = 20; +const FRAMES = 300; +const SAMPLING_INTERVAL = 4096; + +/** Terminal that discards output; keeps xterm parsing out of the measurement. */ +class NullTerminal implements Terminal { + bytesWritten = 0; + start(_onInput: (data: string) => void, _onResize: () => void): void {} + stop(): void {} + async drainInput(): Promise {} + write(data: string): void { + this.bytesWritten += data.length; + } + get columns(): number { + return COLUMNS; + } + get rows(): number { + return ROWS; + } + get kittyProtocolActive(): boolean { + return false; + } + moveBy(_lines: number): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(_title: string): void {} + setProgress(_active: boolean): void {} +} + +/** Editor stand-in: caches lines per (text, width), re-renders when text changes. */ +class EditorSim implements Component { + private text = ""; + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + append(char: string): void { + this.text += char; + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + const border = `\x1b[90m${"─".repeat(Math.max(1, width - 2))}\x1b[39m`; + const lines = [border, ` > ${this.text}${CURSOR_MARKER}`, border]; + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } +} + +function buildTranscript(): Container { + const container = new Container(); + for (let i = 0; i < 150; i++) { + const styled = + i % 3 === 0 + ? `\x1b[1m\x1b[36muser ${i}\x1b[39m\x1b[22m message with some \x1b[33mstyled\x1b[39m content padding padding` + : `assistant ${i} plain response line with enough text to be representative of a transcript row`; + container.addChild(new Text(styled, 1, 0)); + } + return container; +} + +interface SamplingNode { + selfSize: number; + children: SamplingNode[]; +} + +function sumProfile(node: SamplingNode): number { + let total = node.selfSize; + for (const child of node.children) total += sumProfile(child); + return total; +} + +interface ScenarioResult { + allocatedBytes: number; + elapsedMs: number; + bytesWritten: number; +} + +async function runScenario( + session: Session, + terminal: NullTerminal, + tui: TuiAltScreen, + frame: (index: number) => void, +): Promise { + const writtenBefore = terminal.bytesWritten; + await session.post("HeapProfiler.startSampling", { + samplingInterval: SAMPLING_INTERVAL, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + const start = performance.now(); + for (let i = 0; i < FRAMES; i++) { + frame(i); + tui.renderNow(); + } + const elapsedMs = performance.now() - start; + const { profile } = await session.post("HeapProfiler.stopSampling"); + return { + allocatedBytes: sumProfile(profile.head as SamplingNode), + elapsedMs, + bytesWritten: terminal.bytesWritten - writtenBefore, + }; +} + +function report(name: string, result: ScenarioResult): void { + const perFrameKiB = result.allocatedBytes / FRAMES / 1024; + const totalMiB = result.allocatedBytes / 1024 / 1024; + const msPerFrame = result.elapsedMs / FRAMES; + console.log( + `${name.padEnd(8)} allocated ${totalMiB.toFixed(1).padStart(7)} MiB total ` + + `${perFrameKiB.toFixed(1).padStart(8)} KiB/frame ` + + `${msPerFrame.toFixed(3).padStart(7)} ms/frame ` + + `${(result.bytesWritten / FRAMES).toFixed(0).padStart(6)} written bytes/frame`, + ); +} + +async function main(): Promise { + const terminal = new NullTerminal(); + const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench"); + + const transcript = buildTranscript(); + const editor = new EditorSim(); + const scrollView = new ScrollView(transcript, { + follow: "end", + primary: true, + overscroll: "chain", + scrollbar: "auto", + }); + const status = new Text("\x1b[2mstatus: idle\x1b[22m", 1, 0); + const footer = new Text("\x1b[2m~/workspaces/pi main 100k tokens\x1b[22m", 1, 0); + const dock = new VStack([ + { component: status, shrink: 1, minSize: 0 }, + { component: editor, shrink: 1, minSize: 3 }, + { component: footer, shrink: 1, minSize: 1 }, + ]); + const root = new VStack([ + { component: scrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 }, + { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, + ]); + tui.setLayoutRoot(root); + tui.start(); + + for (let i = 0; i < WARMUP_FRAMES; i++) tui.renderNow(); + + const session = new Session(); + session.connect(); + + const staticResult = await runScenario(session, terminal, tui, () => {}); + const editorResult = await runScenario(session, terminal, tui, (i) => { + editor.append(String.fromCharCode(97 + (i % 26))); + }); + + session.disconnect(); + tui.stop(); + + console.log(`frames=${FRAMES} viewport=${COLUMNS}x${ROWS} transcript=${transcript.render(COLUMNS).length} lines`); + report("static", staticResult); + report("editor", editorResult); +} + +await main(); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index dc84f2fd5..d3d9df32f 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -792,7 +792,7 @@ describe("TuiAltScreen", () => { it("selects visible text with the mouse and copies it with OSC 52", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); - tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.addChild(new Text("\x1b[1mal\x1b[0mpha\nbeta\ngamma\ndelta", 0, 0)); tui.start(); await terminal.waitForRender(); @@ -811,8 +811,8 @@ describe("TuiAltScreen", () => { ); assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m"))); assert.ok( - terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m\x1b[0m\x1b[7m")), - "selection inverse must be reapplied after layout segment resets", + terminal.events.some((event) => event.type === "write" && event.data.includes("al\x1b[0m\x1b[7mpha")), + "selection inverse must be reapplied after a reset inside the selection", ); assert.ok(terminal.getViewport().some((line) => line.includes("Copied!")));