feat(status): improve status output

This commit is contained in:
Peter Steinberger
2026-01-10 23:31:24 +01:00
parent 67b7877bbf
commit 1eb50ffac4
25 changed files with 2382 additions and 40 deletions

14
src/terminal/ansi.ts Normal file
View File

@@ -0,0 +1,14 @@
const ANSI_SGR_PATTERN = "\\x1b\\[[0-9;]*m";
// OSC-8 hyperlinks: ESC ] 8 ; ; url ST ... ESC ] 8 ; ; ST
const OSC8_PATTERN = "\\x1b\\]8;;.*?\\x1b\\\\|\\x1b\\]8;;\\x1b\\\\";
const ANSI_REGEX = new RegExp(ANSI_SGR_PATTERN, "g");
const OSC8_REGEX = new RegExp(OSC8_PATTERN, "g");
export function stripAnsi(input: string): string {
return input.replace(OSC8_REGEX, "").replace(ANSI_REGEX, "");
}
export function visibleWidth(input: string): number {
return Array.from(stripAnsi(input)).length;
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { renderTable } from "./table.js";
describe("renderTable", () => {
it("prefers shrinking flex columns to avoid wrapping non-flex labels", () => {
const out = renderTable({
width: 40,
columns: [
{ key: "Item", header: "Item", minWidth: 10 },
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
],
rows: [{ Item: "Dashboard", Value: "http://127.0.0.1:18789/" }],
});
expect(out).toContain("Dashboard");
expect(out).toMatch(/│ Dashboard\s+│/);
});
});

265
src/terminal/table.ts Normal file
View File

@@ -0,0 +1,265 @@
import { visibleWidth } from "./ansi.js";
type Align = "left" | "right" | "center";
export type TableColumn = {
key: string;
header: string;
align?: Align;
minWidth?: number;
maxWidth?: number;
flex?: boolean;
};
export type RenderTableOptions = {
columns: TableColumn[];
rows: Array<Record<string, string>>;
width?: number;
padding?: number;
border?: "unicode" | "ascii" | "none";
};
function repeat(ch: string, n: number): string {
if (n <= 0) return "";
return ch.repeat(n);
}
function padCell(text: string, width: number, align: Align): string {
const w = visibleWidth(text);
if (w >= width) return text;
const pad = width - w;
if (align === "right") return `${repeat(" ", pad)}${text}`;
if (align === "center") {
const left = Math.floor(pad / 2);
const right = pad - left;
return `${repeat(" ", left)}${text}${repeat(" ", right)}`;
}
return `${text}${repeat(" ", pad)}`;
}
function wrapLine(text: string, width: number): string[] {
if (width <= 0) return [text];
const words = text.split(/(\s+)/).filter(Boolean);
const lines: string[] = [];
let current = "";
let currentWidth = 0;
const push = (value: string) => lines.push(value.replace(/\s+$/, ""));
const flush = () => {
if (current.trim().length === 0) return;
push(current);
current = "";
currentWidth = 0;
};
const breakLong = (word: string) => {
const parts: string[] = [];
let buf = "";
let lastBreakAt = 0;
const isBreakChar = (ch: string) =>
ch === "/" || ch === "-" || ch === "_" || ch === ".";
for (const ch of Array.from(word)) {
const next = buf + ch;
if (visibleWidth(next) > width && buf) {
if (lastBreakAt > 0) {
parts.push(buf.slice(0, lastBreakAt));
buf = `${buf.slice(lastBreakAt)}${ch}`;
lastBreakAt = 0;
for (let i = 0; i < buf.length; i += 1) {
const c = buf[i];
if (c && isBreakChar(c)) lastBreakAt = i + 1;
}
} else {
parts.push(buf);
buf = ch;
}
} else {
buf = next;
if (isBreakChar(ch)) lastBreakAt = buf.length;
}
}
if (buf) parts.push(buf);
return parts;
};
for (const token of words) {
const tokenWidth = visibleWidth(token);
const isSpace = /^\s+$/.test(token);
if (tokenWidth > width && !isSpace) {
flush();
for (const part of breakLong(token.replace(/^\s+/, ""))) {
push(part);
}
continue;
}
if (
currentWidth + tokenWidth > width &&
current.trim().length > 0 &&
!isSpace
) {
flush();
}
current += token;
currentWidth = visibleWidth(current);
}
flush();
return lines.length ? lines : [""];
}
function normalizeWidth(n: number | undefined): number | undefined {
if (n == null) return undefined;
if (!Number.isFinite(n) || n <= 0) return undefined;
return Math.floor(n);
}
export function renderTable(opts: RenderTableOptions): string {
const border = opts.border ?? "unicode";
if (border === "none") {
const columns = opts.columns;
const header = columns.map((c) => c.header).join(" | ");
const lines = [
header,
...opts.rows.map((r) => columns.map((c) => r[c.key] ?? "").join(" | ")),
];
return `${lines.join("\n")}\n`;
}
const padding = Math.max(0, opts.padding ?? 1);
const columns = opts.columns;
const metrics = columns.map((c) => {
const headerW = visibleWidth(c.header);
const cellW = Math.max(
0,
...opts.rows.map((r) => visibleWidth(r[c.key] ?? "")),
);
return { headerW, cellW };
});
const widths = columns.map((c, i) => {
const m = metrics[i];
const base = Math.max(m?.headerW ?? 0, m?.cellW ?? 0) + padding * 2;
const capped = c.maxWidth ? Math.min(base, c.maxWidth) : base;
return Math.max(c.minWidth ?? 3, capped);
});
const maxWidth = normalizeWidth(opts.width);
const sepCount = columns.length + 1;
const total = widths.reduce((a, b) => a + b, 0) + sepCount;
const preferredMinWidths = columns.map((c, i) =>
Math.max(c.minWidth ?? 3, (metrics[i]?.headerW ?? 0) + padding * 2, 3),
);
const absoluteMinWidths = columns.map((_c, i) =>
Math.max((metrics[i]?.headerW ?? 0) + padding * 2, 3),
);
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))
.sort((a, b) => b.w - a.w)
.map((x) => x.i);
const nonFlexOrder = columns
.map((_c, i) => ({ i, w: widths[i] ?? 0 }))
.filter(({ i }) => !columns[i]?.flex)
.sort((a, b) => b.w - a.w)
.map((x) => x.i);
const shrink = (order: number[], minWidths: number[]) => {
while (over > 0) {
let progressed = false;
for (const i of order) {
if ((widths[i] ?? 0) <= (minWidths[i] ?? 0)) continue;
widths[i] = (widths[i] ?? 0) - 1;
over -= 1;
progressed = true;
if (over <= 0) break;
}
if (!progressed) break;
}
};
// 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);
}
const box =
border === "ascii"
? {
tl: "+",
tr: "+",
bl: "+",
br: "+",
h: "-",
v: "|",
t: "+",
ml: "+",
m: "+",
mr: "+",
b: "+",
}
: {
tl: "┌",
tr: "┐",
bl: "└",
br: "┘",
h: "─",
v: "│",
t: "┬",
ml: "├",
m: "┼",
mr: "┤",
b: "┴",
};
const hLine = (left: string, mid: string, right: string) =>
`${left}${widths.map((w) => repeat(box.h, w)).join(mid)}${right}`;
const contentWidthFor = (i: number) => Math.max(1, widths[i] - padding * 2);
const padStr = repeat(" ", padding);
const renderRow = (record: Record<string, string>, isHeader = false) => {
const cells = columns.map((c) =>
isHeader ? c.header : (record[c.key] ?? ""),
);
const wrapped = cells.map((cell, i) => wrapLine(cell, contentWidthFor(i)));
const height = Math.max(...wrapped.map((w) => w.length));
const out: string[] = [];
for (let li = 0; li < height; li += 1) {
const parts = wrapped.map((lines, i) => {
const raw = lines[li] ?? "";
const aligned = padCell(
raw,
contentWidthFor(i),
columns[i]?.align ?? "left",
);
return `${padStr}${aligned}${padStr}`;
});
out.push(`${box.v}${parts.join(box.v)}${box.v}`);
}
return out;
};
const lines: string[] = [];
lines.push(hLine(box.tl, box.t, box.tr));
lines.push(...renderRow({}, true));
lines.push(hLine(box.ml, box.m, box.mr));
for (const row of opts.rows) {
lines.push(...renderRow(row, false));
}
lines.push(hLine(box.bl, box.b, box.br));
return `${lines.join("\n")}\n`;
}

View File

@@ -2,7 +2,13 @@ import chalk, { Chalk } from "chalk";
import { LOBSTER_PALETTE } from "./palette.js";
const baseChalk = process.env.NO_COLOR ? new Chalk({ level: 0 }) : chalk;
const hasForceColor =
typeof process.env.FORCE_COLOR === "string" &&
process.env.FORCE_COLOR.trim().length > 0 &&
process.env.FORCE_COLOR.trim() !== "0";
const baseChalk =
process.env.NO_COLOR && !hasForceColor ? new Chalk({ level: 0 }) : chalk;
const hex = (value: string) => baseChalk.hex(value);
@@ -20,8 +26,7 @@ export const theme = {
option: hex(LOBSTER_PALETTE.warn),
} as const;
export const isRich = () =>
Boolean(process.stdout.isTTY && baseChalk.level > 0);
export const isRich = () => Boolean(baseChalk.level > 0);
export const colorize = (
rich: boolean,