fix(terminal): shrink widest flex table columns first (#123934)

This commit is contained in:
Peter Steinberger
2026-08-14 19:32:08 -07:00
committed by GitHub
parent d8cd661517
commit 86f38f7c8f
2 changed files with 80 additions and 26 deletions
+64 -2
View File
@@ -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);
}
+16 -24
View File
@@ -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.