From 2781cad20125ff3a210b795f60620b048e1bbe9b Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:18:13 -0700 Subject: [PATCH] perf(process): stop padding rendered terminal lines to full width (#1450) Rendered frames padded every visible row with trailing spaces to the terminal width. Blank rows cost a full row each and every frame repeats them, so the padding made up 14-39% of capture Evidence (2 KB of sparse output produced 228 KB). The padding carried no information: columns is recorded and serialized_state keeps the exact terminal state. Lines are now trimmed after normalization, so line.padEnd(columns) restores the previous value. The comparison contract commits rendered_line_format so older padded captures do not compare as the same contract. Co-authored-by: Claude Opus 5.5 --- docs/process-capture.md | 6 +++ src/domain/process/processScenario.ts | 1 + src/process/capture/TerminalRenderer.test.ts | 47 +++++++++++++++++++ src/process/capture/TerminalRenderer.ts | 17 ++++--- .../processCaptureNormalization.test.ts | 1 + 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/process/capture/TerminalRenderer.test.ts diff --git a/docs/process-capture.md b/docs/process-capture.md index 1d3d23e7fb..c315e3e9ef 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -110,6 +110,12 @@ while comparisons and trace assertions use the normalized text. Retaining the original adds at most `limits.output_bytes` of text, because it comes from the same admitted PTY chunks rather than a second unbounded stream. +Rendered frame `lines` hold the visible rows after normalization, without +trailing spaces; pad a line to the frame's `columns` to restore the fixed-width +row. `serialized_state` keeps the exact terminal state. Captures commit this +line format in their comparison contract, so a capture whose lines were padded +to full width does not compare as the same contract. + Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and `listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`; diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 8312e1d2a1..0ecf2c259e 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -108,6 +108,7 @@ export const processComparisonContract = ( ...(scenario.normalization.ports ? { port_normalization_version: "contextual-endpoints-v2" } : {}), + rendered_line_format: "trailing-spaces-trimmed-v1", }); /** diff --git a/src/process/capture/TerminalRenderer.test.ts b/src/process/capture/TerminalRenderer.test.ts new file mode 100644 index 0000000000..e5a19996b1 --- /dev/null +++ b/src/process/capture/TerminalRenderer.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; + +import { TerminalRenderer } from "./TerminalRenderer.js"; + +const renderFrames = async ( + data: string, + normalize: (value: string) => string = (value) => value, +) => { + const renderer = new TerminalRenderer({ + columns: 20, + rows: 4, + scrollback: 10, + maxBytes: 100_000, + normalize, + }); + renderer.write(data, 0); + const frames = await renderer.frames(); + const retention = renderer.retention(); + await renderer.dispose(); + return { frames, retention }; +}; + +it("omits trailing blank cells from visible lines without losing rows", async () => { + const { frames, retention } = await renderFrames( + "left right \r\n\r\n indented", + ); + const [frame] = frames; + expect(frame?.lines).toEqual(["left right", "", " indented", ""]); + // Fixed-width rows remain recoverable from the recorded column count. + expect(frame?.lines.map((line) => line.padEnd(frame.columns, " "))).toEqual([ + "left right ", + " ".repeat(20), + " indented ", + " ".repeat(20), + ]); + expect(retention.retained_bytes).toBe( + Buffer.byteLength(frame?.serialized_state ?? "") + + Buffer.byteLength("left right indented"), + ); +}); + +it("trims only after normalization sees the full-width row", async () => { + const { frames } = await renderFrames("pid 4242", (value) => + value.replace(/4242 +$/u, ""), + ); + expect(frames[0]?.lines[0]).toBe("pid "); +}); diff --git a/src/process/capture/TerminalRenderer.ts b/src/process/capture/TerminalRenderer.ts index e7f1a0fc8a..4322e2781a 100644 --- a/src/process/capture/TerminalRenderer.ts +++ b/src/process/capture/TerminalRenderer.ts @@ -14,6 +14,8 @@ const HeadlessPackage = const SerializePackage = require("@xterm/addon-serialize") as typeof import("@xterm/addon-serialize"); +const TRAILING_SPACES = / +$/u; + interface TerminalRendererOptions { readonly columns: number; readonly rows: number; @@ -106,13 +108,16 @@ export class TerminalRenderer { const lines: string[] = []; for (let row = 0; row < this.#terminal.rows; row += 1) { const line = buffer.getLine(buffer.viewportY + row); + // Trailing blank cells carry no information beyond `columns`; dropping + // them keeps sparse screens from costing a full row per blank line. lines.push( - this.options.normalize( - (line?.translateToString(false, 0, this.#terminal.cols) ?? "").padEnd( - this.#terminal.cols, - " ", - ), - ), + this.options + .normalize( + ( + line?.translateToString(false, 0, this.#terminal.cols) ?? "" + ).padEnd(this.#terminal.cols, " "), + ) + .replace(TRAILING_SPACES, ""), ); } const serializedState = this.options.normalize( diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 58cf22c0fd..8156aff97f 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -64,6 +64,7 @@ itWithCaptureCapability( ).toBe(true); expect(capture.manifest.comparison_contract).toMatchObject({ port_normalization_version: "contextual-endpoints-v2", + rendered_line_format: "trailing-spaces-trimmed-v1", }); expect(capture.exit).toMatchObject({ code: 0, reason: "exited" }); expect(capture.cleanup).toMatchObject({