From 86f38f7c8fbb4cb4b7c737a3e052e089563f290e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 19:32:08 -0700 Subject: [PATCH] fix(terminal): shrink widest flex table columns first (#123934) --- packages/terminal-core/src/table.test.ts | 66 +++++++++++++++++++++++- packages/terminal-core/src/table.ts | 40 ++++++-------- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/packages/terminal-core/src/table.test.ts b/packages/terminal-core/src/table.test.ts index a2ddfac81d22..3977a3ac6076 100644 --- a/packages/terminal-core/src/table.test.ts +++ b/packages/terminal-core/src/table.test.ts @@ -22,6 +22,43 @@ function expectIntroducersToStartCompleteSequences( } } +const pluginListColumns = [ + { key: "Name", header: "Name", minWidth: 14, flex: true }, + { key: "ID", header: "ID", minWidth: 10, flex: true }, + { key: "Format", header: "Format", minWidth: 9 }, + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Source", header: "Source", minWidth: 26, flex: true }, + { key: "Version", header: "Version", minWidth: 8 }, +]; + +const pluginListRows = [ + { + Name: "Amazon Bedrock", + ID: "amazon-bedrock", + Format: "openclaw", + Status: "enabled", + Source: "~/Projects/openclaw/extensions/amazon-bedrock/index.ts", + Version: "2026.8.1", + }, + { + Name: "N".repeat(40), + ID: "i".repeat(22), + Format: "openclaw", + Status: "disabled", + Source: `/${"s".repeat(80)}`, + Version: "2026.8.1", + }, +]; + +function renderPluginListTable(width: number): string { + return renderTable({ + width, + border: "unicode", + columns: pluginListColumns, + rows: pluginListRows, + }); +} + describe("renderTable", () => { afterEach(() => { vi.unstubAllEnvs(); @@ -60,6 +97,30 @@ describe("renderTable", () => { expect(out).toMatch(/[│|] Dashboard\s+[│|]/); }); + it("keeps identifier columns intact when wider flex columns can absorb the shrink", () => { + const out = renderPluginListTable(120); + + expect(out).toMatch(/│ Amazon Bedrock\s+│ amazon-bedrock\s+│/); + }); + + it.each([60, 80, 120, 160, 200])( + "keeps the plugin-list shape deterministic and within %i columns", + (width) => { + const out = renderPluginListTable(width); + const lines = out.trimEnd().split("\n"); + const headerCells = (lines[1] ?? "").split("│").slice(1, -1); + + expect(out).toBe(renderPluginListTable(width)); + expect(Math.max(...lines.map(visibleWidth))).toBeLessThanOrEqual(width); + expect(headerCells).toHaveLength(pluginListColumns.length); + for (const [index, column] of pluginListColumns.entries()) { + expect(visibleWidth(headerCells[index] ?? "")).toBeGreaterThanOrEqual( + visibleWidth(column.header) + 2, + ); + } + }, + ); + it("expands flex columns to fill available width", () => { const width = 60; const out = renderTable({ @@ -532,18 +593,19 @@ describe("renderTable", () => { } }); - it("keeps borders aligned when a narrow flex column receives wide content", () => { + it("terminates with aligned borders when a flex column starts at its floor", () => { const out = renderTable({ width: 10, border: "ascii", columns: [ { key: "A", header: "long header here" }, - { key: "B", header: "", flex: true }, + { key: "B", header: "", flex: true, maxWidth: 3 }, ], rows: [{ A: "data", B: "📸" }], }); const lines = out.trimEnd().split("\n"); const headerWidth = visibleWidth(lines[0] ?? ""); + expect(headerWidth).toBeGreaterThan(10); for (const line of lines) { expect(visibleWidth(line)).toBe(headerWidth); } diff --git a/packages/terminal-core/src/table.ts b/packages/terminal-core/src/table.ts index 8317ca4dd562..7ac971acccb7 100644 --- a/packages/terminal-core/src/table.ts +++ b/packages/terminal-core/src/table.ts @@ -601,45 +601,37 @@ export function renderTable(opts: RenderTableOptions): string { if (maxWidth && total > maxWidth) { let over = total - maxWidth; - const flexOrder = columns - .map((_c, i) => ({ i, w: widths[i] ?? 0 })) - .filter(({ i }) => Boolean(columns[i]?.flex)) - .toSorted((a, b) => b.w - a.w) - .map((x) => x.i); + const flexColumns = columns.flatMap((column, i) => (column.flex ? [i] : [])); + const nonFlexColumns = columns.flatMap((column, i) => (column.flex ? [] : [i])); - const nonFlexOrder = columns - .map((_c, i) => ({ i, w: widths[i] ?? 0 })) - .filter(({ i }) => !columns[i]?.flex) - .toSorted((a, b) => b.w - a.w) - .map((x) => x.i); - - const shrink = (order: number[], minWidths: number[]) => { + const shrink = (indices: number[], minWidths: number[]) => { while (over > 0) { - let progressed = false; - for (const i of order) { + let widest: number | undefined; + for (const i of indices) { if ((widths[i] ?? 0) <= (minWidths[i] ?? 0)) { continue; } - widths[i] = (widths[i] ?? 0) - 1; - over -= 1; - progressed = true; - if (over <= 0) { - break; + // Water-fill from the widest eligible column. Strict comparison makes + // equal-width ties deterministic: the leftmost column shrinks first. + if (widest === undefined || (widths[i] ?? 0) > (widths[widest] ?? 0)) { + widest = i; } } - if (!progressed) { + if (widest === undefined) { break; } + widths[widest] = (widths[widest] ?? 0) - 1; + over -= 1; } }; // Prefer shrinking flex columns; only shrink non-flex if necessary. // If required to fit, allow flex columns to shrink below user minWidth // down to their absolute minimum (header + padding). - shrink(flexOrder, preferredMinWidths); - shrink(flexOrder, absoluteMinWidths); - shrink(nonFlexOrder, preferredMinWidths); - shrink(nonFlexOrder, absoluteMinWidths); + shrink(flexColumns, preferredMinWidths); + shrink(flexColumns, absoluteMinWidths); + shrink(nonFlexColumns, preferredMinWidths); + shrink(nonFlexColumns, absoluteMinWidths); } // If we have room and any flex columns, expand them to fill the available width.