From f7c9adacdc5682fdce74099a827d5e1f29ec9ec2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 8 Sep 2026 10:20:36 -0700 Subject: [PATCH] fix(terminal): keep wrapped multiline cells aligned (#142305) --- packages/terminal-core/src/table.test.ts | 14 +++++++------- packages/terminal-core/src/table.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/terminal-core/src/table.test.ts b/packages/terminal-core/src/table.test.ts index 18ca1456f492..6cf07e5b54d5 100644 --- a/packages/terminal-core/src/table.test.ts +++ b/packages/terminal-core/src/table.test.ts @@ -626,18 +626,18 @@ console.log(JSON.stringify({ ["colored CRLF", "\r\n", "\x1b[31m", "\x1b[39m"], ["linked LF", "\n", "\x1b]8;;https://example.com/\x07", "\x1b]8;;\x07"], ])( - "preserves blank %s lines at their positions across cells", + "preserves blank %s lines without adding rows after wrapped spacing", (_label, separator, open, close) => { const out = renderTable({ border: "ascii", - width: 30, + width: 15, columns: [ - { key: "A", header: "A", flex: true }, - { key: "B", header: "B", flex: true }, + { key: "A", header: "A", minWidth: 6, maxWidth: 6 }, + { key: "B", header: "B", minWidth: 6, maxWidth: 6 }, ], rows: [ { - A: `${open}${["", "alpha", "", "omega", "", ""].join(separator)}${close}`, + A: `${open}${["", "Read ", "", "Next", "", ""].join(separator)}${close}`, B: "0\n1\n2\n3\n4", }, ], @@ -653,9 +653,9 @@ console.log(JSON.stringify({ ), ).toEqual([ ["", "0"], - ["alpha", "1"], + ["Read", "1"], ["", "2"], - ["omega", "3"], + ["Next", "3"], ["", "4"], ]); if (open) { diff --git a/packages/terminal-core/src/table.ts b/packages/terminal-core/src/table.ts index ae452b5f91b2..adf441f9798f 100644 --- a/packages/terminal-core/src/table.ts +++ b/packages/terminal-core/src/table.ts @@ -251,12 +251,13 @@ function wrapLine(text: string, width: number): string[] { ch === " " || ch === "/" || ch === "-" || ch === "_" || ch === "."; let skipNextLf = false; let hasChar = false; + let logicalLineHasOutput = false; const buf: AnsiToken[] = []; let bufVisible = 0; let lastBreakIndex: number | null = null; - // Explicit newlines emit empty buffers too, keeping sibling cell rows aligned. + // A soft wrap can empty the buffer before a newline without creating a blank logical line. const flushAt = (breakAt: number | null) => { // Keep the suffix in its buffer: long zero-width runs can exceed the argument // limit of a spread-based copy even when their visible width is small. @@ -286,7 +287,10 @@ function wrapLine(text: string, width: number): string[] { const openOsc8 = activeOsc8 ? `${ESC}]8;${activeOsc8.params};${activeOsc8.uri}${BEL}` : ""; const closeSgr = activeSgr.map((state) => state.close).join(""); - lines.push(`${content.join("")}${closeOsc8}${closeSgr}`.trimEnd()); + if (bufVisible > 0 || !logicalLineHasOutput) { + lines.push(`${content.join("")}${closeOsc8}${closeSgr}`.trimEnd()); + logicalLineHasOutput = true; + } if (breakAt == null || breakAt <= 0) { buf.length = 0; if (openOsc8) { @@ -331,6 +335,7 @@ function wrapLine(text: string, width: number): string[] { if (ch === "\n" || ch === "\r" || ch === "\r\n") { skipNextLf = ch === "\r"; flushAt(buf.length); + logicalLineHasOutput = false; return; } // Soft-wrap remainders reuse the width measured when each token entered the buffer.