mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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).
This commit is contained in:
parent
9dd90a4971
commit
18dee5f0a8
4 changed files with 220 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
203
packages/tui/test/render-churn-bench.ts
Normal file
203
packages/tui/test/render-churn-bench.ts
Normal file
|
|
@ -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<void> {}
|
||||
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<ScenarioResult> {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
@ -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!")));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue