@vlandoss/clibuddy 0.6.1 → 0.6.2-git-908d2c0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -14,6 +14,7 @@ declare const palette: {
14
14
  dim: import("ansis").Ansis;
15
15
  highlight: import("ansis").Ansis;
16
16
  success: import("ansis").Ansis;
17
+ error: import("ansis").Ansis;
17
18
  label: (s: string) => string;
18
19
  };
19
20
  //#endregion
@@ -88,10 +89,38 @@ declare const resolvePackageBin: typeof _resolvePackageBin;
88
89
  //#region src/shell/utils.d.ts
89
90
  declare function isNonZeroExitError(value: unknown): value is NonZeroExitError;
90
91
  //#endregion
92
+ //#region src/task-board.d.ts
93
+ type TaskOutcome = {
94
+ ok: boolean; /** Output flushed (grouped under the label) once the board settles. */
95
+ detail?: string;
96
+ };
97
+ type BoardTask = {
98
+ label: string; /** Resolve to a `TaskOutcome`; a rejection renders as a failed row. */
99
+ run: () => Promise<TaskOutcome>;
100
+ };
101
+ type BoardOptions = {
102
+ /** Section header above the rows, e.g. `tsc · 16 packages` (multi-row only). */title?: string;
103
+ /**
104
+ * Force the `┌ │ └` frame even for one task. `rr check` sets it to divide its
105
+ * sections; otherwise a board is unframed (compact for one task, plain for many).
106
+ */
107
+ frame?: boolean;
108
+ };
109
+ type BoardResult = {
110
+ ok: boolean;
111
+ outcomes: TaskOutcome[];
112
+ };
113
+ /**
114
+ * Runs `tasks` in parallel, rendering one row each that collapses to ✔/✖, then
115
+ * flushes their captured detail and a one-line summary. On a TTY the rows update
116
+ * in place; otherwise each prints once on settle, keeping logs deterministic.
117
+ */
118
+ declare function runTaskBoard(tasks: BoardTask[], options?: BoardOptions): Promise<BoardResult>;
119
+ //#endregion
91
120
  //#region src/text.d.ts
92
121
  declare const text: {
93
122
  vland: string;
94
123
  version: (version: string) => string;
95
124
  };
96
125
  //#endregion
97
- export { NonZeroExitError, Pkg, type Project, RunOptions, ShellOptions, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, text };
126
+ export { BoardOptions, BoardResult, BoardTask, NonZeroExitError, Pkg, type Project, RunOptions, ShellOptions, ShellService, TaskOutcome, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, runTaskBoard, text };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import ansis, { bold, cyan, dim, green, italic, underline } from "ansis";
2
+ import ansis, { bold, cyan, dim, gray, green, italic, magenta, red, underline } from "ansis";
3
3
  import { hasTTY, isCI } from "std-env";
4
4
  import path, { dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -21,6 +21,7 @@ const palette = {
21
21
  dim,
22
22
  highlight: cyan,
23
23
  success: green,
24
+ error: red,
24
25
  label: (s) => ansis.bgMagenta.black(s)
25
26
  };
26
27
  //#endregion
@@ -219,10 +220,210 @@ async function _resolvePackageBin(pkg, { from, binName }) {
219
220
  }
220
221
  const resolvePackageBin = memoize(_resolvePackageBin, { cacheKey: ([pkg, opts]) => `${pkg}|${opts.from}|${opts.binName ?? ""}` });
221
222
  //#endregion
223
+ //#region src/task-board.ts
224
+ const FRAMES = [
225
+ "◒",
226
+ "◐",
227
+ "◓",
228
+ "◑"
229
+ ];
230
+ const TICK_MS = 80;
231
+ const PASS = green("✔");
232
+ const FAIL = red("✖");
233
+ const SEP = palette.dim(" · ");
234
+ const BAR = gray("│");
235
+ const BAR_START = gray("┌");
236
+ const BAR_END = gray("└");
237
+ /** Failing output past this many lines is truncated with a "+N more" note. */
238
+ const MAX_DETAIL_LINES = 60;
239
+ /**
240
+ * Runs `tasks` in parallel, rendering one row each that collapses to ✔/✖, then
241
+ * flushes their captured detail and a one-line summary. On a TTY the rows update
242
+ * in place; otherwise each prints once on settle, keeping logs deterministic.
243
+ */
244
+ async function runTaskBoard(tasks, options = {}) {
245
+ const live = hasTTY && !isCI;
246
+ const framed = options.frame ?? false;
247
+ return live ? runLive(tasks, options, framed) : runStatic(tasks, options, framed);
248
+ }
249
+ /** A multi-row board gets a header line — `┌ title` when framed, a plain bold title otherwise. */
250
+ function writeTitle(out, options, framed, multi) {
251
+ if (!multi || !options.title) return;
252
+ out.write(framed ? `${BAR_START} ${palette.bold(options.title)}\n` : `${palette.bold(options.title)}\n`);
253
+ }
254
+ async function runLive(tasks, options, framed) {
255
+ const out = process.stderr;
256
+ const multi = tasks.length > 1;
257
+ writeTitle(out, options, framed, multi);
258
+ const rows = tasks.map((t) => ({
259
+ label: t.label,
260
+ startedAt: Date.now()
261
+ }));
262
+ const width = labelWidth(tasks);
263
+ const prefix = rowPrefix(framed, multi);
264
+ let frame = 0;
265
+ out.write("\x1B[?25l");
266
+ for (const _ of rows) out.write("\n");
267
+ const render = () => {
268
+ out.write(`\x1b[${rows.length}A`);
269
+ for (const row of rows) out.write(`\x1b[2K${renderRow(row, width, frame, prefix)}\n`);
270
+ };
271
+ const settled = Promise.allSettled(tasks.map(async (task, i) => {
272
+ const outcome = await runTask(task);
273
+ const row = rows[i];
274
+ row.finishedAt = Date.now();
275
+ row.outcome = outcome;
276
+ return outcome;
277
+ }));
278
+ try {
279
+ render();
280
+ while (rows.some((r) => !r.outcome)) {
281
+ await delay(TICK_MS);
282
+ frame = (frame + 1) % FRAMES.length;
283
+ render();
284
+ }
285
+ render();
286
+ } finally {
287
+ out.write("\x1B[?25h");
288
+ }
289
+ await settled;
290
+ return finish(rows, out, framed, multi);
291
+ }
292
+ async function runStatic(tasks, options, framed) {
293
+ const out = process.stderr;
294
+ const multi = tasks.length > 1;
295
+ writeTitle(out, options, framed, multi);
296
+ const width = labelWidth(tasks);
297
+ const prefix = rowPrefix(framed, multi);
298
+ const rows = await Promise.all(tasks.map(async (task) => {
299
+ const startedAt = Date.now();
300
+ const outcome = await runTask(task);
301
+ return {
302
+ label: task.label,
303
+ startedAt,
304
+ finishedAt: Date.now(),
305
+ outcome
306
+ };
307
+ }));
308
+ for (const row of rows) out.write(`${renderRow(row, width, 0, prefix)}\n`);
309
+ return finish(rows, out, framed, multi);
310
+ }
311
+ async function runTask(task) {
312
+ try {
313
+ return await task.run();
314
+ } catch (error) {
315
+ return {
316
+ ok: false,
317
+ detail: error instanceof Error ? error.message : String(error)
318
+ };
319
+ }
320
+ }
321
+ /** Flushes each task's detail, prints the summary (framed only), returns the result. */
322
+ function finish(rows, out, framed, multi) {
323
+ const outcomes = rows.map((r) => r.outcome ?? { ok: false });
324
+ const blocks = rows.map((row) => ({
325
+ ok: row.outcome?.ok ?? false,
326
+ label: row.label,
327
+ detail: clampDetail(row.outcome?.detail?.trim())
328
+ })).filter((b) => Boolean(b.detail));
329
+ const shared = blocks.length > 1 ? sharedLeadingLine(blocks.map((b) => b.detail)) : void 0;
330
+ const block = (text) => framed ? gutter(text) : indent(text);
331
+ const spacer = () => out.write(framed ? `${BAR}\n` : "\n");
332
+ let flushed = false;
333
+ if (shared) {
334
+ spacer();
335
+ out.write(`${block(shared)}\n`);
336
+ flushed = true;
337
+ }
338
+ for (const b of blocks) {
339
+ const rest = shared ? stripLeadingLine(b.detail, shared) : b.detail;
340
+ if (!rest.trim()) continue;
341
+ const body = b.ok ? palette.dim : void 0;
342
+ if (multi) {
343
+ spacer();
344
+ const header = b.ok ? palette.bold(b.label) : red(palette.bold(b.label));
345
+ out.write(`${block(header)}\n${framed ? gutter(rest, body) : indent(rest, body)}\n`);
346
+ } else out.write(`${framed ? gutter(rest, body) : indent(rest, body)}\n`);
347
+ flushed = true;
348
+ }
349
+ if (framed) {
350
+ if (flushed || multi) spacer();
351
+ out.write(`${BAR_END} ${summary(rows)}\n`);
352
+ } else if (multi) out.write(`\n${summary(rows)}\n`);
353
+ return {
354
+ ok: outcomes.every((o) => o.ok),
355
+ outcomes
356
+ };
357
+ }
358
+ /** The first line, if every block starts with the same one (e.g. an identical `$ <cmd>`). */
359
+ function sharedLeadingLine(details) {
360
+ const first = details[0]?.split("\n", 1)[0];
361
+ if (!first) return void 0;
362
+ return details.every((d) => d.split("\n", 1)[0] === first) ? first : void 0;
363
+ }
364
+ /** Drops `line` from the front of `detail` (with its trailing newline) if present. */
365
+ function stripLeadingLine(detail, line) {
366
+ return detail.startsWith(line) ? detail.slice(line.length).replace(/^\n/, "") : detail;
367
+ }
368
+ /** A row's leading decoration: `│` (framed multi), `┌` (framed single, status rides the corner), or a plain indent. */
369
+ function rowPrefix(framed, multi) {
370
+ if (framed) return multi ? `${BAR} ` : `${BAR_START} `;
371
+ return multi ? " " : "";
372
+ }
373
+ function renderRow(row, width, frame, prefix) {
374
+ const label = padLabel(row.label, width);
375
+ if (!row.outcome) return `${prefix}${magenta(FRAMES[frame])} ${label}`;
376
+ const duration = row.finishedAt ? palette.dim(fmtDuration(row.finishedAt - row.startedAt)) : "";
377
+ return `${prefix}${row.outcome.ok ? PASS : FAIL} ${label}${duration ? ` ${duration}` : ""}`;
378
+ }
379
+ function summary(rows) {
380
+ const outcomes = rows.map((r) => r.outcome).filter((o) => Boolean(o));
381
+ const failed = outcomes.filter((o) => !o.ok).length;
382
+ const ok = outcomes.length - failed;
383
+ const elapsed = rows.length ? Math.max(...rows.map((r) => r.finishedAt ?? r.startedAt)) - Math.min(...rows.map((r) => r.startedAt)) : 0;
384
+ const parts = failed > 0 ? [`${failed} failed`, `${ok} ok`] : [`${ok} ok`];
385
+ parts.push(fmtDuration(elapsed));
386
+ return `${failed > 0 ? FAIL : PASS} ${parts.join(SEP)}`;
387
+ }
388
+ /** Caps long output so one broken package can't bury the board; the rest is one-lined. */
389
+ function clampDetail(text) {
390
+ if (!text) return text;
391
+ const lines = text.split("\n");
392
+ if (lines.length <= MAX_DETAIL_LINES) return text;
393
+ const hidden = lines.length - MAX_DETAIL_LINES;
394
+ return `${lines.slice(0, MAX_DETAIL_LINES).join("\n")}\n${palette.dim(`… +${hidden} more lines`)}`;
395
+ }
396
+ /** Prefixes every line of `text` with the gutter (keeping the side frame intact), optionally styling each line. */
397
+ function gutter(text, style) {
398
+ return text.split("\n").map((line) => `${BAR} ${style ? style(line) : line}`).join("\n");
399
+ }
400
+ /** Indents every line of `text` by two spaces (compact mode, no gutter), optionally styling each line. */
401
+ function indent(text, style) {
402
+ return text.split("\n").map((line) => ` ${style ? style(line) : line}`).join("\n");
403
+ }
404
+ function fmtDuration(ms) {
405
+ return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(1)}s`;
406
+ }
407
+ function labelWidth(tasks) {
408
+ return tasks.reduce((max, t) => Math.max(max, visibleWidth(t.label)), 0);
409
+ }
410
+ /** Right-pads `label` to `width` printable columns, ignoring its ANSI escapes. */
411
+ function padLabel(label, width) {
412
+ return label + " ".repeat(Math.max(0, width - visibleWidth(label)));
413
+ }
414
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
415
+ /** A string's printable column count — its length with SGR color escapes removed. */
416
+ function visibleWidth(text) {
417
+ return text.replace(ANSI, "").length;
418
+ }
419
+ function delay(ms) {
420
+ return new Promise((resolve) => setTimeout(resolve, ms));
421
+ }
422
+ //#endregion
222
423
  //#region src/text.ts
223
424
  const text = {
224
425
  vland: palette.link(palette.primary("https://variable.land")),
225
426
  version: (version) => palette.muted(`v${version}`)
226
427
  };
227
428
  //#endregion
228
- export { NonZeroExitError, Pkg, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, text };
429
+ export { NonZeroExitError, Pkg, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, runTaskBoard, text };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vlandoss/clibuddy",
3
- "version": "0.6.1",
3
+ "version": "0.6.2-git-908d2c0.0",
4
4
  "description": "A helper library to create CLIs in Variable Land",
5
5
  "homepage": "https://github.com/variableland/dx/tree/main/shared/clibuddy#readme",
6
6
  "bugs": {
@@ -43,10 +43,11 @@
43
43
  "node": ">=20.0.0"
44
44
  },
45
45
  "devDependencies": {
46
- "@rrlab/tsdown-config": "^0.0.1"
46
+ "@rrlab/tsdown-config": "^0.1.0"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsdown",
50
+ "test": "vitest run",
50
51
  "test:types": "rr tsc"
51
52
  }
52
53
  }
package/src/colors.ts CHANGED
@@ -1,4 +1,4 @@
1
- import ansis, { bold, cyan, dim, green, italic, underline } from "ansis";
1
+ import ansis, { bold, cyan, dim, green, italic, red, underline } from "ansis";
2
2
 
3
3
  // hex-from-string factory; matches the previous public API.
4
4
  export const colorize = (hex: string) => ansis.hex(hex);
@@ -16,5 +16,6 @@ export const palette = {
16
16
  // semantic
17
17
  highlight: cyan,
18
18
  success: green,
19
+ error: red,
19
20
  label: (s: string) => ansis.bgMagenta.black(s),
20
21
  };
package/src/index.ts CHANGED
@@ -4,4 +4,5 @@ export * from "./meta.ts";
4
4
  export * from "./pkg.ts";
5
5
  export * from "./run.ts";
6
6
  export * from "./shell/index.ts";
7
+ export * from "./task-board.ts";
7
8
  export * from "./text.ts";
@@ -0,0 +1,283 @@
1
+ import { gray, green, magenta, red } from "ansis";
2
+ import { palette } from "./colors.ts";
3
+ import { hasTTY, isCI } from "./env.ts";
4
+
5
+ export type TaskOutcome = {
6
+ ok: boolean;
7
+ /** Output flushed (grouped under the label) once the board settles. */
8
+ detail?: string;
9
+ };
10
+
11
+ export type BoardTask = {
12
+ label: string;
13
+ /** Resolve to a `TaskOutcome`; a rejection renders as a failed row. */
14
+ run: () => Promise<TaskOutcome>;
15
+ };
16
+
17
+ export type BoardOptions = {
18
+ /** Section header above the rows, e.g. `tsc · 16 packages` (multi-row only). */
19
+ title?: string;
20
+ /**
21
+ * Force the `┌ │ └` frame even for one task. `rr check` sets it to divide its
22
+ * sections; otherwise a board is unframed (compact for one task, plain for many).
23
+ */
24
+ frame?: boolean;
25
+ };
26
+
27
+ export type BoardResult = {
28
+ ok: boolean;
29
+ outcomes: TaskOutcome[];
30
+ };
31
+
32
+ // Glyphs mirror @clack/prompts (used by `rr plugins`) so the two flows read as
33
+ // one family: the ◒◐◓◑ spinner and a gray `│ ┌ └` gutter, settling to ✔/✖. The
34
+ // gray is 16-color (not a fixed hex) so it adapts to the terminal theme.
35
+ const FRAMES = ["◒", "◐", "◓", "◑"];
36
+ const TICK_MS = 80;
37
+ const PASS = green("✔");
38
+ const FAIL = red("✖");
39
+ const SEP = palette.dim(" · ");
40
+ const BAR = gray("│");
41
+ const BAR_START = gray("┌");
42
+ const BAR_END = gray("└");
43
+ /** Failing output past this many lines is truncated with a "+N more" note. */
44
+ const MAX_DETAIL_LINES = 60;
45
+
46
+ type RowState = {
47
+ label: string;
48
+ startedAt: number;
49
+ finishedAt?: number;
50
+ outcome?: TaskOutcome;
51
+ };
52
+
53
+ /**
54
+ * Runs `tasks` in parallel, rendering one row each that collapses to ✔/✖, then
55
+ * flushes their captured detail and a one-line summary. On a TTY the rows update
56
+ * in place; otherwise each prints once on settle, keeping logs deterministic.
57
+ */
58
+ export async function runTaskBoard(tasks: BoardTask[], options: BoardOptions = {}): Promise<BoardResult> {
59
+ const live = hasTTY && !isCI;
60
+ const framed = options.frame ?? false;
61
+ return live ? runLive(tasks, options, framed) : runStatic(tasks, options, framed);
62
+ }
63
+
64
+ /** A multi-row board gets a header line — `┌ title` when framed, a plain bold title otherwise. */
65
+ function writeTitle(out: NodeJS.WriteStream, options: BoardOptions, framed: boolean, multi: boolean): void {
66
+ if (!multi || !options.title) return;
67
+ out.write(framed ? `${BAR_START} ${palette.bold(options.title)}\n` : `${palette.bold(options.title)}\n`);
68
+ }
69
+
70
+ async function runLive(tasks: BoardTask[], options: BoardOptions, framed: boolean): Promise<BoardResult> {
71
+ const out = process.stderr;
72
+ const multi = tasks.length > 1;
73
+ writeTitle(out, options, framed, multi);
74
+
75
+ const rows: RowState[] = tasks.map((t) => ({ label: t.label, startedAt: Date.now() }));
76
+ const width = labelWidth(tasks);
77
+ const prefix = rowPrefix(framed, multi);
78
+ let frame = 0;
79
+
80
+ out.write("\x1b[?25l"); // hide cursor
81
+ for (const _ of rows) out.write("\n"); // reserve one line per row
82
+
83
+ const render = () => {
84
+ out.write(`\x1b[${rows.length}A`); // jump to the first row
85
+ for (const row of rows) out.write(`\x1b[2K${renderRow(row, width, frame, prefix)}\n`);
86
+ };
87
+
88
+ const settled = Promise.allSettled(
89
+ tasks.map(async (task, i) => {
90
+ const outcome = await runTask(task);
91
+ // biome-ignore lint/style/noNonNullAssertion: rows mirror tasks 1:1
92
+ const row = rows[i]!;
93
+ row.finishedAt = Date.now();
94
+ row.outcome = outcome;
95
+ return outcome;
96
+ }),
97
+ );
98
+
99
+ try {
100
+ render();
101
+ while (rows.some((r) => !r.outcome)) {
102
+ await delay(TICK_MS);
103
+ frame = (frame + 1) % FRAMES.length;
104
+ render();
105
+ }
106
+ render(); // collapse every row to its final glyph
107
+ } finally {
108
+ out.write("\x1b[?25h"); // restore cursor
109
+ }
110
+
111
+ await settled;
112
+ return finish(rows, out, framed, multi);
113
+ }
114
+
115
+ async function runStatic(tasks: BoardTask[], options: BoardOptions, framed: boolean): Promise<BoardResult> {
116
+ const out = process.stderr;
117
+ const multi = tasks.length > 1;
118
+ writeTitle(out, options, framed, multi);
119
+
120
+ const width = labelWidth(tasks);
121
+ const prefix = rowPrefix(framed, multi);
122
+ const rows: RowState[] = await Promise.all(
123
+ tasks.map(async (task) => {
124
+ const startedAt = Date.now();
125
+ const outcome = await runTask(task);
126
+ return { label: task.label, startedAt, finishedAt: Date.now(), outcome };
127
+ }),
128
+ );
129
+
130
+ // Print in input order so non-TTY logs are deterministic.
131
+ for (const row of rows) out.write(`${renderRow(row, width, 0, prefix)}\n`);
132
+ return finish(rows, out, framed, multi);
133
+ }
134
+
135
+ async function runTask(task: BoardTask): Promise<TaskOutcome> {
136
+ try {
137
+ return await task.run();
138
+ } catch (error) {
139
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
140
+ }
141
+ }
142
+
143
+ /** Flushes each task's detail, prints the summary (framed only), returns the result. */
144
+ function finish(rows: RowState[], out: NodeJS.WriteStream, framed: boolean, multi: boolean): BoardResult {
145
+ const outcomes = rows.map((r) => r.outcome ?? { ok: false });
146
+
147
+ const blocks = rows
148
+ .map((row) => ({ ok: row.outcome?.ok ?? false, label: row.label, detail: clampDetail(row.outcome?.detail?.trim()) }))
149
+ .filter((b): b is { ok: boolean; label: string; detail: string } => Boolean(b.detail));
150
+
151
+ // Hoist a line shared by every block — typically the identical `$ <cmd>` each
152
+ // package ran — so a monorepo shows the command once instead of per package.
153
+ const shared = blocks.length > 1 ? sharedLeadingLine(blocks.map((b) => b.detail)) : undefined;
154
+
155
+ // Framed bodies sit inside the `│` gutter; a plain board indents instead.
156
+ const block = (text: string) => (framed ? gutter(text) : indent(text));
157
+ const spacer = () => out.write(framed ? `${BAR}\n` : "\n");
158
+
159
+ let flushed = false;
160
+ if (shared) {
161
+ spacer();
162
+ out.write(`${block(shared)}\n`);
163
+ flushed = true;
164
+ }
165
+
166
+ // Passing output is dimmed (proof-of-work that recedes); a failure stays bright
167
+ // so the diagnostic reads. Multi-row blocks get a per-task header to attribute them.
168
+ for (const b of blocks) {
169
+ const rest = shared ? stripLeadingLine(b.detail, shared) : b.detail;
170
+ if (!rest.trim()) continue; // only the shared command → nothing package-specific
171
+ const body = b.ok ? palette.dim : undefined;
172
+ if (multi) {
173
+ spacer();
174
+ const header = b.ok ? palette.bold(b.label) : red(palette.bold(b.label));
175
+ out.write(`${block(header)}\n${framed ? gutter(rest, body) : indent(rest, body)}\n`);
176
+ } else {
177
+ out.write(`${framed ? gutter(rest, body) : indent(rest, body)}\n`);
178
+ }
179
+ flushed = true;
180
+ }
181
+
182
+ // A framed section closes with `└ summary`; a plain multi-row board with a bare
183
+ // summary line. One compact task needs neither — its row already showed the verdict.
184
+ if (framed) {
185
+ if (flushed || multi) spacer();
186
+ out.write(`${BAR_END} ${summary(rows)}\n`);
187
+ } else if (multi) {
188
+ out.write(`\n${summary(rows)}\n`);
189
+ }
190
+ return { ok: outcomes.every((o) => o.ok), outcomes };
191
+ }
192
+
193
+ /** The first line, if every block starts with the same one (e.g. an identical `$ <cmd>`). */
194
+ function sharedLeadingLine(details: string[]): string | undefined {
195
+ const first = details[0]?.split("\n", 1)[0];
196
+ if (!first) return undefined;
197
+ return details.every((d) => d.split("\n", 1)[0] === first) ? first : undefined;
198
+ }
199
+
200
+ /** Drops `line` from the front of `detail` (with its trailing newline) if present. */
201
+ function stripLeadingLine(detail: string, line: string): string {
202
+ return detail.startsWith(line) ? detail.slice(line.length).replace(/^\n/, "") : detail;
203
+ }
204
+
205
+ /** A row's leading decoration: `│` (framed multi), `┌` (framed single, status rides the corner), or a plain indent. */
206
+ function rowPrefix(framed: boolean, multi: boolean): string {
207
+ if (framed) return multi ? `${BAR} ` : `${BAR_START} `;
208
+ return multi ? " " : "";
209
+ }
210
+
211
+ function renderRow(row: RowState, width: number, frame: number, prefix: string): string {
212
+ // Pad by visible width so colored labels (e.g. a tool's branded ui) still
213
+ // align — `padEnd` would count the invisible ANSI bytes.
214
+ const label = padLabel(row.label, width);
215
+ if (!row.outcome) return `${prefix}${magenta(FRAMES[frame])} ${label}`;
216
+ const duration = row.finishedAt ? palette.dim(fmtDuration(row.finishedAt - row.startedAt)) : "";
217
+ return `${prefix}${row.outcome.ok ? PASS : FAIL} ${label}${duration ? ` ${duration}` : ""}`;
218
+ }
219
+
220
+ function summary(rows: RowState[]): string {
221
+ const outcomes = rows.map((r) => r.outcome).filter((o): o is TaskOutcome => Boolean(o));
222
+ const failed = outcomes.filter((o) => !o.ok).length;
223
+ const ok = outcomes.length - failed;
224
+ // Wall-clock span (first task started → last settled), not a single task's
225
+ // time. Guard the empty board so Math.min/max don't yield ±Infinity.
226
+ const elapsed = rows.length
227
+ ? Math.max(...rows.map((r) => r.finishedAt ?? r.startedAt)) - Math.min(...rows.map((r) => r.startedAt))
228
+ : 0;
229
+
230
+ const parts = failed > 0 ? [`${failed} failed`, `${ok} ok`] : [`${ok} ok`];
231
+ parts.push(fmtDuration(elapsed));
232
+ return `${failed > 0 ? FAIL : PASS} ${parts.join(SEP)}`;
233
+ }
234
+
235
+ /** Caps long output so one broken package can't bury the board; the rest is one-lined. */
236
+ function clampDetail(text: string | undefined): string | undefined {
237
+ if (!text) return text;
238
+ const lines = text.split("\n");
239
+ if (lines.length <= MAX_DETAIL_LINES) return text;
240
+ const hidden = lines.length - MAX_DETAIL_LINES;
241
+ return `${lines.slice(0, MAX_DETAIL_LINES).join("\n")}\n${palette.dim(`… +${hidden} more lines`)}`;
242
+ }
243
+
244
+ /** Prefixes every line of `text` with the gutter (keeping the side frame intact), optionally styling each line. */
245
+ function gutter(text: string, style?: (line: string) => string): string {
246
+ return text
247
+ .split("\n")
248
+ .map((line) => `${BAR} ${style ? style(line) : line}`)
249
+ .join("\n");
250
+ }
251
+
252
+ /** Indents every line of `text` by two spaces (compact mode, no gutter), optionally styling each line. */
253
+ function indent(text: string, style?: (line: string) => string): string {
254
+ return text
255
+ .split("\n")
256
+ .map((line) => ` ${style ? style(line) : line}`)
257
+ .join("\n");
258
+ }
259
+
260
+ function fmtDuration(ms: number): string {
261
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
262
+ }
263
+
264
+ function labelWidth(tasks: BoardTask[]): number {
265
+ return tasks.reduce((max, t) => Math.max(max, visibleWidth(t.label)), 0);
266
+ }
267
+
268
+ /** Right-pads `label` to `width` printable columns, ignoring its ANSI escapes. */
269
+ function padLabel(label: string, width: number): string {
270
+ return label + " ".repeat(Math.max(0, width - visibleWidth(label)));
271
+ }
272
+
273
+ // SGR color escapes (`ESC [ … m`); built via fromCharCode so no literal control char in source.
274
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
275
+
276
+ /** A string's printable column count — its length with SGR color escapes removed. */
277
+ function visibleWidth(text: string): number {
278
+ return text.replace(ANSI, "").length;
279
+ }
280
+
281
+ function delay(ms: number): Promise<void> {
282
+ return new Promise((resolve) => setTimeout(resolve, ms));
283
+ }