From 1ba178befb9831689765787de1ca480533751d14 Mon Sep 17 00:00:00 2001 From: Chip Li <53254273+hichipli@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:30:54 -0400 Subject: [PATCH] fix(process): preserve terminal cell widths during normalization (#4142) --- src/process/capture/TerminalRenderer.test.ts | 21 ++++++++++++ src/process/capture/TerminalRenderer.ts | 10 +++--- .../process/processCaptureTerminal.test.ts | 32 +++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/process/capture/TerminalRenderer.test.ts b/src/process/capture/TerminalRenderer.test.ts index 1d1ff04a1f..efb1005414 100644 --- a/src/process/capture/TerminalRenderer.test.ts +++ b/src/process/capture/TerminalRenderer.test.ts @@ -100,6 +100,27 @@ it("trims only after normalization sees the full-width row", async () => { expect(frames[0]?.lines[0]).toBe("pid "); }); +it.each([ + { data: "界", cells: 2 }, + // The pinned xterm Unicode table treats this emoji as one cell. + { data: "😀", cells: 1 }, + { data: "e\u0301", cells: 1 }, + { data: "ab", cells: 2 }, +])( + "normalizes the terminal cells of $data without extra padding", + async ({ data, cells }) => { + const { frames } = await renderFrames(data, (value) => + value.replaceAll(" ", "_"), + ); + expect(frames[0]?.lines).toEqual([ + data + "_".repeat(20 - cells), + "_".repeat(20), + "_".repeat(20), + "_".repeat(20), + ]); + }, +); + it("omits rendered frames with unknown expanded size and keeps later bounded states", async () => { const renderer = new TerminalRenderer({ columns: 10, diff --git a/src/process/capture/TerminalRenderer.ts b/src/process/capture/TerminalRenderer.ts index 06598bf86c..fc51730e6c 100644 --- a/src/process/capture/TerminalRenderer.ts +++ b/src/process/capture/TerminalRenderer.ts @@ -131,14 +131,14 @@ export class TerminalRenderer { let bytes = 0; for (let row = 0; row < this.#terminal.rows; row += 1) { const line = buffer.getLine(buffer.viewportY + row); - const paddedLine = ( - line?.translateToString(false, 0, this.#terminal.cols) ?? "" - ).padEnd(this.#terminal.cols, " "); + const renderedLine = + line?.translateToString(false, 0, this.#terminal.cols) ?? + " ".repeat(this.#terminal.cols); const trailingSpaceBytes = Buffer.byteLength( - paddedLine.match(TRAILING_SPACES)?.[0] ?? "", + renderedLine.match(TRAILING_SPACES)?.[0] ?? "", ); const normalized = this.options.normalize( - paddedLine, + renderedLine, Math.min(Number.MAX_SAFE_INTEGER, remainingBytes + trailingSpaceBytes), ); if (normalized.state === "over_budget") { diff --git a/tests/boundary/process/processCaptureTerminal.test.ts b/tests/boundary/process/processCaptureTerminal.test.ts index bc2ff739a2..880c8d868a 100644 --- a/tests/boundary/process/processCaptureTerminal.test.ts +++ b/tests/boundary/process/processCaptureTerminal.test.ts @@ -7,6 +7,38 @@ import { itWithCaptureCapability } from "./processCaptureCapability.js"; import { captureProcessScenario } from "../../../src/process/capture/ProcessHarness.js"; import { parseProcessScenario } from "../../../src/domain/process/processScenario.js"; +itWithCaptureCapability( + "normalizes wide terminal cells without inventing blank cells", + async () => { + const result = await captureProcessScenario( + parseProcessScenario({ + executable: process.execPath, + arguments: ["-e", 'process.stdout.write("界")'], + terminal: { columns: 4, rows: 1, scrollback: 0 }, + normalization: { + patterns: [{ pattern: " ", replacement: "_" }], + }, + }), + ); + if (!result.ok) throw result.error; + expect( + result.value.frames + .map(({ raw_data, data }) => raw_data ?? data) + .join(""), + ).toBe("界"); + expect(result.value.rendered_frames.at(-1)).toMatchObject({ + columns: 4, + cursor_x: 2, + lines: ["界__"], + }); + expect(result.value.cleanup).toMatchObject({ + owned_process_group: "verified", + temporary_root: "removed", + }); + }, + 20_000, +); + itWithCaptureCapability( "captures one command's terminal output, selected files, and owned cleanup", async () => {