@vlandoss/clibuddy 0.6.1-git-62447c5.0 → 0.6.2-git-e008b4d.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,48 @@ 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
+ /** The task's verdict — typically the wrapped process's exit code === 0. */ok: boolean;
95
+ /**
96
+ * Output to flush, grouped under the task label, once the board settles. The
97
+ * board prints it verbatim whenever it's non-empty (pass or fail) — the caller
98
+ * decides what, if anything, to surface.
99
+ */
100
+ detail?: string;
101
+ };
102
+ type BoardTask = {
103
+ /** Stable identifier rendered on the row, e.g. a package name. */label: string; /** Runs the task. Must resolve to a `TaskOutcome`; rejections render as failed. */
104
+ run: () => Promise<TaskOutcome>;
105
+ };
106
+ type BoardOptions = {
107
+ /** Section header printed above the rows, e.g. `tsc · 16 packages` (framed multi-row only). */title?: string;
108
+ /**
109
+ * Force the framed (`┌ │ └`) layout even for a single task. `rr check` sets
110
+ * this so its sections stay visually divided; a standalone single-task
111
+ * command leaves it unset and renders compactly. Defaults to `tasks.length > 1`.
112
+ */
113
+ frame?: boolean;
114
+ };
115
+ type BoardResult = {
116
+ /** False when any task ended not-ok. */ok: boolean;
117
+ outcomes: TaskOutcome[];
118
+ };
119
+ /**
120
+ * Runs `tasks` in parallel and reports their progress as a board: one row per
121
+ * task with a live spinner that collapses to ✔/✖ on settle. On a TTY the rows
122
+ * update in place; otherwise (CI, pipes) each row prints once when it settles,
123
+ * keeping logs deterministic. After every task settles, each task's captured
124
+ * detail is flushed grouped under its label, followed by a one-line summary.
125
+ * Parallelism is never sacrificed — the renderer only reflects work that is
126
+ * already running.
127
+ */
128
+ declare function runTaskBoard(tasks: BoardTask[], options?: BoardOptions): Promise<BoardResult>;
129
+ //#endregion
91
130
  //#region src/text.d.ts
92
131
  declare const text: {
93
132
  vland: string;
94
133
  version: (version: string) => string;
95
134
  };
96
135
  //#endregion
97
- export { NonZeroExitError, Pkg, type Project, RunOptions, ShellOptions, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, text };
136
+ 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,218 @@ 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 and reports their progress as a board: one row per
241
+ * task with a live spinner that collapses to ✔/✖ on settle. On a TTY the rows
242
+ * update in place; otherwise (CI, pipes) each row prints once when it settles,
243
+ * keeping logs deterministic. After every task settles, each task's captured
244
+ * detail is flushed grouped under its label, followed by a one-line summary.
245
+ * Parallelism is never sacrificed — the renderer only reflects work that is
246
+ * already running.
247
+ */
248
+ async function runTaskBoard(tasks, options = {}) {
249
+ const live = hasTTY && !isCI;
250
+ const framed = options.frame ?? false;
251
+ return live ? runLive(tasks, options, framed) : runStatic(tasks, options, framed);
252
+ }
253
+ /** A multi-row board gets a header line — `┌ title` when framed, a plain bold title otherwise. */
254
+ function writeTitle(out, options, framed, multi) {
255
+ if (!multi || !options.title) return;
256
+ out.write(framed ? `${BAR_START} ${palette.bold(options.title)}\n` : `${palette.bold(options.title)}\n`);
257
+ }
258
+ async function runLive(tasks, options, framed) {
259
+ const out = process.stderr;
260
+ const multi = tasks.length > 1;
261
+ writeTitle(out, options, framed, multi);
262
+ const rows = tasks.map((t) => ({
263
+ label: t.label,
264
+ startedAt: Date.now()
265
+ }));
266
+ const width = labelWidth(tasks);
267
+ const prefix = rowPrefix(framed, multi);
268
+ let frame = 0;
269
+ out.write("\x1B[?25l");
270
+ for (const _ of rows) out.write("\n");
271
+ const render = () => {
272
+ out.write(`\x1b[${rows.length}A`);
273
+ for (const row of rows) out.write(`\x1b[2K${renderRow(row, width, frame, prefix)}\n`);
274
+ };
275
+ const settled = Promise.allSettled(tasks.map(async (task, i) => {
276
+ const outcome = await runTask(task);
277
+ const row = rows[i];
278
+ row.finishedAt = Date.now();
279
+ row.outcome = outcome;
280
+ return outcome;
281
+ }));
282
+ try {
283
+ render();
284
+ while (rows.some((r) => !r.outcome)) {
285
+ await delay(TICK_MS);
286
+ frame = (frame + 1) % FRAMES.length;
287
+ render();
288
+ }
289
+ render();
290
+ } finally {
291
+ out.write("\x1B[?25h");
292
+ }
293
+ await settled;
294
+ return finish(rows, out, framed, multi);
295
+ }
296
+ async function runStatic(tasks, options, framed) {
297
+ const out = process.stderr;
298
+ const multi = tasks.length > 1;
299
+ writeTitle(out, options, framed, multi);
300
+ const width = labelWidth(tasks);
301
+ const prefix = rowPrefix(framed, multi);
302
+ const rows = await Promise.all(tasks.map(async (task) => {
303
+ const startedAt = Date.now();
304
+ const outcome = await runTask(task);
305
+ return {
306
+ label: task.label,
307
+ startedAt,
308
+ finishedAt: Date.now(),
309
+ outcome
310
+ };
311
+ }));
312
+ for (const row of rows) out.write(`${renderRow(row, width, 0, prefix)}\n`);
313
+ return finish(rows, out, framed, multi);
314
+ }
315
+ async function runTask(task) {
316
+ try {
317
+ return await task.run();
318
+ } catch (error) {
319
+ return {
320
+ ok: false,
321
+ detail: error instanceof Error ? error.message : String(error)
322
+ };
323
+ }
324
+ }
325
+ /** Flushes each task's detail, prints the summary (framed only), returns the result. */
326
+ function finish(rows, out, framed, multi) {
327
+ const outcomes = rows.map((r) => r.outcome ?? { ok: false });
328
+ const blocks = rows.map((row) => ({
329
+ ok: row.outcome?.ok ?? false,
330
+ label: row.label,
331
+ detail: clampDetail(row.outcome?.detail?.trim())
332
+ })).filter((b) => Boolean(b.detail));
333
+ const shared = blocks.length > 1 ? sharedLeadingLine(blocks.map((b) => b.detail)) : void 0;
334
+ const block = (text) => framed ? gutter(text) : indent(text);
335
+ const spacer = () => out.write(framed ? `${BAR}\n` : "\n");
336
+ let flushed = false;
337
+ if (shared) {
338
+ spacer();
339
+ out.write(`${block(shared)}\n`);
340
+ flushed = true;
341
+ }
342
+ for (const b of blocks) {
343
+ const rest = shared ? stripLeadingLine(b.detail, shared) : b.detail;
344
+ if (!rest.trim()) continue;
345
+ const body = b.ok ? palette.dim : void 0;
346
+ if (multi) {
347
+ spacer();
348
+ const header = b.ok ? palette.bold(b.label) : red(palette.bold(b.label));
349
+ out.write(`${block(header)}\n${framed ? gutter(rest, body) : indent(rest, body)}\n`);
350
+ } else out.write(`${framed ? gutter(rest, body) : indent(rest, body)}\n`);
351
+ flushed = true;
352
+ }
353
+ if (framed) {
354
+ if (flushed || multi) spacer();
355
+ out.write(`${BAR_END} ${summary(rows)}\n`);
356
+ } else if (multi) out.write(`\n${summary(rows)}\n`);
357
+ return {
358
+ ok: outcomes.every((o) => o.ok),
359
+ outcomes
360
+ };
361
+ }
362
+ /** The first line, if every block starts with the same one (e.g. an identical `$ <cmd>`). */
363
+ function sharedLeadingLine(details) {
364
+ const first = details[0]?.split("\n", 1)[0];
365
+ if (!first) return void 0;
366
+ return details.every((d) => d.split("\n", 1)[0] === first) ? first : void 0;
367
+ }
368
+ /** Drops `line` from the front of `detail` (with its trailing newline) if present. */
369
+ function stripLeadingLine(detail, line) {
370
+ return detail.startsWith(line) ? detail.slice(line.length).replace(/^\n/, "") : detail;
371
+ }
372
+ /**
373
+ * The leading decoration for a row. Framed: `│` per row, or `┌` for a framed
374
+ * single task (status rides the opening corner). Unframed: a 2-space indent
375
+ * under the title for a multi-row board, nothing for a lone compact row.
376
+ */
377
+ function rowPrefix(framed, multi) {
378
+ if (framed) return multi ? `${BAR} ` : `${BAR_START} `;
379
+ return multi ? " " : "";
380
+ }
381
+ function renderRow(row, width, frame, prefix) {
382
+ const label = padLabel(row.label, width);
383
+ if (!row.outcome) return `${prefix}${magenta(FRAMES[frame])} ${label}`;
384
+ const duration = row.finishedAt ? palette.dim(fmtDuration(row.finishedAt - row.startedAt)) : "";
385
+ return `${prefix}${row.outcome.ok ? PASS : FAIL} ${label}${duration ? ` ${duration}` : ""}`;
386
+ }
387
+ function summary(rows) {
388
+ const outcomes = rows.map((r) => r.outcome).filter((o) => Boolean(o));
389
+ const failed = outcomes.filter((o) => !o.ok).length;
390
+ const ok = outcomes.length - failed;
391
+ const elapsed = rows.length ? Math.max(...rows.map((r) => r.finishedAt ?? r.startedAt)) - Math.min(...rows.map((r) => r.startedAt)) : 0;
392
+ const parts = failed > 0 ? [`${failed} failed`, `${ok} ok`] : [`${ok} ok`];
393
+ parts.push(fmtDuration(elapsed));
394
+ return `${failed > 0 ? FAIL : PASS} ${parts.join(SEP)}`;
395
+ }
396
+ /** Caps long output so one broken package can't bury the board; the rest is one-lined. */
397
+ function clampDetail(text) {
398
+ if (!text) return text;
399
+ const lines = text.split("\n");
400
+ if (lines.length <= MAX_DETAIL_LINES) return text;
401
+ const hidden = lines.length - MAX_DETAIL_LINES;
402
+ return `${lines.slice(0, MAX_DETAIL_LINES).join("\n")}\n${palette.dim(`… +${hidden} more lines`)}`;
403
+ }
404
+ /** Prefixes every line of `text` with the gutter (keeping the side frame intact), optionally styling each line. */
405
+ function gutter(text, style) {
406
+ return text.split("\n").map((line) => `${BAR} ${style ? style(line) : line}`).join("\n");
407
+ }
408
+ /** Indents every line of `text` by two spaces (compact mode, no gutter), optionally styling each line. */
409
+ function indent(text, style) {
410
+ return text.split("\n").map((line) => ` ${style ? style(line) : line}`).join("\n");
411
+ }
412
+ function fmtDuration(ms) {
413
+ return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(1)}s`;
414
+ }
415
+ function labelWidth(tasks) {
416
+ return tasks.reduce((max, t) => Math.max(max, visibleWidth(t.label)), 0);
417
+ }
418
+ /** Right-pads `label` to `width` printable columns, ignoring its ANSI escapes. */
419
+ function padLabel(label, width) {
420
+ return label + " ".repeat(Math.max(0, width - visibleWidth(label)));
421
+ }
422
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
423
+ /** A string's printable column count — its length with SGR color escapes removed. */
424
+ function visibleWidth(text) {
425
+ return text.replace(ANSI, "").length;
426
+ }
427
+ function delay(ms) {
428
+ return new Promise((resolve) => setTimeout(resolve, ms));
429
+ }
430
+ //#endregion
222
431
  //#region src/text.ts
223
432
  const text = {
224
433
  vland: palette.link(palette.primary("https://variable.land")),
225
434
  version: (version) => palette.muted(`v${version}`)
226
435
  };
227
436
  //#endregion
228
- export { NonZeroExitError, Pkg, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, text };
437
+ 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-git-62447c5.0",
3
+ "version": "0.6.2-git-e008b4d.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-git-62447c5.0"
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,309 @@
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
+ /** The task's verdict — typically the wrapped process's exit code === 0. */
7
+ ok: boolean;
8
+ /**
9
+ * Output to flush, grouped under the task label, once the board settles. The
10
+ * board prints it verbatim whenever it's non-empty (pass or fail) — the caller
11
+ * decides what, if anything, to surface.
12
+ */
13
+ detail?: string;
14
+ };
15
+
16
+ export type BoardTask = {
17
+ /** Stable identifier rendered on the row, e.g. a package name. */
18
+ label: string;
19
+ /** Runs the task. Must resolve to a `TaskOutcome`; rejections render as failed. */
20
+ run: () => Promise<TaskOutcome>;
21
+ };
22
+
23
+ export type BoardOptions = {
24
+ /** Section header printed above the rows, e.g. `tsc · 16 packages` (framed multi-row only). */
25
+ title?: string;
26
+ /**
27
+ * Force the framed (`┌ │ └`) layout even for a single task. `rr check` sets
28
+ * this so its sections stay visually divided; a standalone single-task
29
+ * command leaves it unset and renders compactly. Defaults to `tasks.length > 1`.
30
+ */
31
+ frame?: boolean;
32
+ };
33
+
34
+ export type BoardResult = {
35
+ /** False when any task ended not-ok. */
36
+ ok: boolean;
37
+ outcomes: TaskOutcome[];
38
+ };
39
+
40
+ // Frame + gutter mirror @clack/prompts (used by `rr plugins`) so the two flows
41
+ // read as one family: the ◒◐◓◑ spinner and the gray │ ┌ └ gutter. The settled
42
+ // glyph is ✔/✖ — the verdict is the tool's exit code, never parsed from output
43
+ // (we can't tell a clean "Found 0 warnings" trailer from a real warning without
44
+ // parsing, so we don't pretend to — the tool's own output says which it is).
45
+ // The gutter uses the 16-color `gray` (not a fixed hex) so it adapts to the
46
+ // terminal theme and degrades on non-truecolor surfaces / CI log viewers.
47
+ const FRAMES = ["◒", "◐", "◓", "◑"];
48
+ const TICK_MS = 80;
49
+ const PASS = green("✔");
50
+ const FAIL = red("✖");
51
+ const SEP = palette.dim(" · ");
52
+ const BAR = gray("│");
53
+ const BAR_START = gray("┌");
54
+ const BAR_END = gray("└");
55
+ /** Failing output past this many lines is truncated with a "+N more" note. */
56
+ const MAX_DETAIL_LINES = 60;
57
+
58
+ type RowState = {
59
+ label: string;
60
+ startedAt: number;
61
+ finishedAt?: number;
62
+ outcome?: TaskOutcome;
63
+ };
64
+
65
+ /**
66
+ * Runs `tasks` in parallel and reports their progress as a board: one row per
67
+ * task with a live spinner that collapses to ✔/✖ on settle. On a TTY the rows
68
+ * update in place; otherwise (CI, pipes) each row prints once when it settles,
69
+ * keeping logs deterministic. After every task settles, each task's captured
70
+ * detail is flushed grouped under its label, followed by a one-line summary.
71
+ * Parallelism is never sacrificed — the renderer only reflects work that is
72
+ * already running.
73
+ */
74
+ export async function runTaskBoard(tasks: BoardTask[], options: BoardOptions = {}): Promise<BoardResult> {
75
+ const live = hasTTY && !isCI;
76
+ // The `┌ │ └` frame is reserved for composition — `rr check` sets `frame: true`
77
+ // to divide its sections. A standalone command never frames, even a monorepo
78
+ // run with many rows (it's still one command): it gets a plain title + summary.
79
+ const framed = options.frame ?? false;
80
+ return live ? runLive(tasks, options, framed) : runStatic(tasks, options, framed);
81
+ }
82
+
83
+ /** A multi-row board gets a header line — `┌ title` when framed, a plain bold title otherwise. */
84
+ function writeTitle(out: NodeJS.WriteStream, options: BoardOptions, framed: boolean, multi: boolean): void {
85
+ if (!multi || !options.title) return;
86
+ out.write(framed ? `${BAR_START} ${palette.bold(options.title)}\n` : `${palette.bold(options.title)}\n`);
87
+ }
88
+
89
+ async function runLive(tasks: BoardTask[], options: BoardOptions, framed: boolean): Promise<BoardResult> {
90
+ const out = process.stderr;
91
+ const multi = tasks.length > 1;
92
+ writeTitle(out, options, framed, multi);
93
+
94
+ const rows: RowState[] = tasks.map((t) => ({ label: t.label, startedAt: Date.now() }));
95
+ const width = labelWidth(tasks);
96
+ const prefix = rowPrefix(framed, multi);
97
+ let frame = 0;
98
+
99
+ out.write("\x1b[?25l"); // hide cursor
100
+ for (const _ of rows) out.write("\n"); // reserve one line per row
101
+
102
+ const render = () => {
103
+ out.write(`\x1b[${rows.length}A`); // jump to the first row
104
+ for (const row of rows) out.write(`\x1b[2K${renderRow(row, width, frame, prefix)}\n`);
105
+ };
106
+
107
+ const settled = Promise.allSettled(
108
+ tasks.map(async (task, i) => {
109
+ const outcome = await runTask(task);
110
+ // biome-ignore lint/style/noNonNullAssertion: rows mirror tasks 1:1
111
+ const row = rows[i]!;
112
+ row.finishedAt = Date.now();
113
+ row.outcome = outcome;
114
+ return outcome;
115
+ }),
116
+ );
117
+
118
+ try {
119
+ render();
120
+ while (rows.some((r) => !r.outcome)) {
121
+ await delay(TICK_MS);
122
+ frame = (frame + 1) % FRAMES.length;
123
+ render();
124
+ }
125
+ render(); // final frame with every row collapsed
126
+ } finally {
127
+ out.write("\x1b[?25h"); // restore cursor
128
+ }
129
+
130
+ await settled;
131
+ return finish(rows, out, framed, multi);
132
+ }
133
+
134
+ async function runStatic(tasks: BoardTask[], options: BoardOptions, framed: boolean): Promise<BoardResult> {
135
+ const out = process.stderr;
136
+ const multi = tasks.length > 1;
137
+ writeTitle(out, options, framed, multi);
138
+
139
+ const width = labelWidth(tasks);
140
+ const prefix = rowPrefix(framed, multi);
141
+ const rows: RowState[] = await Promise.all(
142
+ tasks.map(async (task) => {
143
+ const startedAt = Date.now();
144
+ const outcome = await runTask(task);
145
+ return { label: task.label, startedAt, finishedAt: Date.now(), outcome };
146
+ }),
147
+ );
148
+
149
+ // Print in input order so non-TTY logs are deterministic.
150
+ for (const row of rows) out.write(`${renderRow(row, width, 0, prefix)}\n`);
151
+ return finish(rows, out, framed, multi);
152
+ }
153
+
154
+ async function runTask(task: BoardTask): Promise<TaskOutcome> {
155
+ try {
156
+ return await task.run();
157
+ } catch (error) {
158
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
159
+ }
160
+ }
161
+
162
+ /** Flushes each task's detail, prints the summary (framed only), returns the result. */
163
+ function finish(rows: RowState[], out: NodeJS.WriteStream, framed: boolean, multi: boolean): BoardResult {
164
+ const outcomes = rows.map((r) => r.outcome ?? { ok: false });
165
+
166
+ const blocks = rows
167
+ .map((row) => ({ ok: row.outcome?.ok ?? false, label: row.label, detail: clampDetail(row.outcome?.detail?.trim()) }))
168
+ .filter((b): b is { ok: boolean; label: string; detail: string } => Boolean(b.detail));
169
+
170
+ // Hoist a line shared by every block — typically the identical `$ <cmd>` each
171
+ // package ran — so a monorepo shows the command once instead of per package.
172
+ const shared = blocks.length > 1 ? sharedLeadingLine(blocks.map((b) => b.detail)) : undefined;
173
+
174
+ // Framed sections lay their body inside the gutter (`│`); a plain board (no
175
+ // frame) indents it. The spacer above each is the gutter bar or a blank line.
176
+ const block = (text: string) => (framed ? gutter(text) : indent(text));
177
+ const spacer = () => out.write(framed ? `${BAR}\n` : "\n");
178
+
179
+ let flushed = false;
180
+ if (shared) {
181
+ spacer();
182
+ out.write(`${block(shared)}\n`); // already dim (it's the command), shown once
183
+ flushed = true;
184
+ }
185
+
186
+ // A passing task's output is dimmed — it's the tool's proof-of-work that
187
+ // should recede but stay visible; a failing task keeps full brightness so the
188
+ // diagnostic reads. A per-task header keeps each block attributable, except a
189
+ // single task (the row above already names it).
190
+ for (const b of blocks) {
191
+ const rest = shared ? stripLeadingLine(b.detail, shared) : b.detail;
192
+ if (!rest.trim()) continue; // only the shared command → nothing package-specific
193
+ const body = b.ok ? palette.dim : undefined;
194
+ if (multi) {
195
+ spacer();
196
+ const header = b.ok ? palette.bold(b.label) : red(palette.bold(b.label));
197
+ out.write(`${block(header)}\n${framed ? gutter(rest, body) : indent(rest, body)}\n`);
198
+ } else {
199
+ out.write(`${framed ? gutter(rest, body) : indent(rest, body)}\n`);
200
+ }
201
+ flushed = true;
202
+ }
203
+
204
+ // Summary closes a framed section (└) or a plain multi-row board (a single
205
+ // command's compact output needs none — the row already carried the verdict).
206
+ if (framed) {
207
+ if (flushed || multi) spacer();
208
+ out.write(`${BAR_END} ${summary(rows)}\n`);
209
+ } else if (multi) {
210
+ out.write(`\n${summary(rows)}\n`);
211
+ }
212
+ return { ok: outcomes.every((o) => o.ok), outcomes };
213
+ }
214
+
215
+ /** The first line, if every block starts with the same one (e.g. an identical `$ <cmd>`). */
216
+ function sharedLeadingLine(details: string[]): string | undefined {
217
+ const first = details[0]?.split("\n", 1)[0];
218
+ if (!first) return undefined;
219
+ return details.every((d) => d.split("\n", 1)[0] === first) ? first : undefined;
220
+ }
221
+
222
+ /** Drops `line` from the front of `detail` (with its trailing newline) if present. */
223
+ function stripLeadingLine(detail: string, line: string): string {
224
+ return detail.startsWith(line) ? detail.slice(line.length).replace(/^\n/, "") : detail;
225
+ }
226
+
227
+ /**
228
+ * The leading decoration for a row. Framed: `│` per row, or `┌` for a framed
229
+ * single task (status rides the opening corner). Unframed: a 2-space indent
230
+ * under the title for a multi-row board, nothing for a lone compact row.
231
+ */
232
+ function rowPrefix(framed: boolean, multi: boolean): string {
233
+ if (framed) return multi ? `${BAR} ` : `${BAR_START} `;
234
+ return multi ? " " : "";
235
+ }
236
+
237
+ function renderRow(row: RowState, width: number, frame: number, prefix: string): string {
238
+ // Pad by visible width so colored labels (e.g. a tool's branded ui) still
239
+ // align — `padEnd` would count the invisible ANSI bytes.
240
+ const label = padLabel(row.label, width);
241
+ if (!row.outcome) return `${prefix}${magenta(FRAMES[frame])} ${label}`;
242
+ const duration = row.finishedAt ? palette.dim(fmtDuration(row.finishedAt - row.startedAt)) : "";
243
+ return `${prefix}${row.outcome.ok ? PASS : FAIL} ${label}${duration ? ` ${duration}` : ""}`;
244
+ }
245
+
246
+ function summary(rows: RowState[]): string {
247
+ const outcomes = rows.map((r) => r.outcome).filter((o): o is TaskOutcome => Boolean(o));
248
+ const failed = outcomes.filter((o) => !o.ok).length;
249
+ const ok = outcomes.length - failed;
250
+ // Wall-clock span (first task started → last settled), not a single task's
251
+ // time. Guard the empty board so Math.min/max don't yield ±Infinity.
252
+ const elapsed = rows.length
253
+ ? Math.max(...rows.map((r) => r.finishedAt ?? r.startedAt)) - Math.min(...rows.map((r) => r.startedAt))
254
+ : 0;
255
+
256
+ const parts = failed > 0 ? [`${failed} failed`, `${ok} ok`] : [`${ok} ok`];
257
+ parts.push(fmtDuration(elapsed));
258
+ return `${failed > 0 ? FAIL : PASS} ${parts.join(SEP)}`;
259
+ }
260
+
261
+ /** Caps long output so one broken package can't bury the board; the rest is one-lined. */
262
+ function clampDetail(text: string | undefined): string | undefined {
263
+ if (!text) return text;
264
+ const lines = text.split("\n");
265
+ if (lines.length <= MAX_DETAIL_LINES) return text;
266
+ const hidden = lines.length - MAX_DETAIL_LINES;
267
+ return `${lines.slice(0, MAX_DETAIL_LINES).join("\n")}\n${palette.dim(`… +${hidden} more lines`)}`;
268
+ }
269
+
270
+ /** Prefixes every line of `text` with the gutter (keeping the side frame intact), optionally styling each line. */
271
+ function gutter(text: string, style?: (line: string) => string): string {
272
+ return text
273
+ .split("\n")
274
+ .map((line) => `${BAR} ${style ? style(line) : line}`)
275
+ .join("\n");
276
+ }
277
+
278
+ /** Indents every line of `text` by two spaces (compact mode, no gutter), optionally styling each line. */
279
+ function indent(text: string, style?: (line: string) => string): string {
280
+ return text
281
+ .split("\n")
282
+ .map((line) => ` ${style ? style(line) : line}`)
283
+ .join("\n");
284
+ }
285
+
286
+ function fmtDuration(ms: number): string {
287
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
288
+ }
289
+
290
+ function labelWidth(tasks: BoardTask[]): number {
291
+ return tasks.reduce((max, t) => Math.max(max, visibleWidth(t.label)), 0);
292
+ }
293
+
294
+ /** Right-pads `label` to `width` printable columns, ignoring its ANSI escapes. */
295
+ function padLabel(label: string, width: number): string {
296
+ return label + " ".repeat(Math.max(0, width - visibleWidth(label)));
297
+ }
298
+
299
+ // SGR color escapes (`ESC [ … m`); built via fromCharCode so no literal control char in source.
300
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
301
+
302
+ /** A string's printable column count — its length with SGR color escapes removed. */
303
+ function visibleWidth(text: string): number {
304
+ return text.replace(ANSI, "").length;
305
+ }
306
+
307
+ function delay(ms: number): Promise<void> {
308
+ return new Promise((resolve) => setTimeout(resolve, ms));
309
+ }