@vzn/vx 0.0.650 → 0.0.651

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.
Files changed (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -7,72 +7,35 @@ import type { TaskOutcome } from '../graph/index.js'
7
7
  import { tallyOutcomes } from './tally.js'
8
8
  import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
9
9
  import { isGroupTask } from '../graph/index.js'
10
+ import { formatBytes, formatElapsed } from '../util/index.js'
11
+ import * as ui from '../ui/index.js'
10
12
  import {
13
+ bar,
11
14
  barWidth,
12
- ERROR,
13
- formatBytes,
14
- formatElapsed,
15
- gradientRule,
16
- identityColor,
15
+ gauge,
16
+ idParts,
17
17
  paint,
18
- SUCCESS,
19
- ui,
20
- WARN,
18
+ panelTop,
21
19
  type ColorSupport,
22
- } from '../util/index.js'
20
+ type Segment,
21
+ type Style,
22
+ } from '../ui/index.js'
23
23
 
24
24
  const NO_COLOR: ColorSupport = { enabled: false }
25
25
 
26
- // Cache provenance hues (owner): local light blue, remote dark blue —
27
- // yellow is skipped's color on both meters.
28
- const LOCAL = '#38bdf8' // sky-400
29
- const REMOTE = '#2563eb' // blue-600
30
- const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
31
- const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
32
- const RUNNING = '#06b6d4' // cyan-500: ui's info
33
- const ACCENT = '#06b6d4' // cyan-500: the live region's running word
34
-
35
- /**
36
- * Stacked state meter: one ▰ run per bucket, proportional cells via
37
- * largest-remainder, every non-zero bucket guaranteed at least one
38
- * cell. Color carries the state; the color-coded numbers ride beside
39
- * the bar.
40
- */
26
+ /** A meter of the run's states; a segment with no style is dim, `▱` the work left. */
41
27
  function segmentBar(
42
- segments: readonly { n: number; color: string; glyph?: string; dim?: boolean }[],
28
+ segments: readonly { n: number; color: Style | ''; glyph?: string; dim?: boolean }[],
43
29
  colors: ColorSupport,
44
30
  ): string {
45
- const total = segments.reduce((sum, seg) => sum + seg.n, 0)
46
- if (total === 0) return ''
47
- const width = barWidth(colors)
48
- const exact = segments.map((seg) => (seg.n / total) * width)
49
- const cells = exact.map(Math.floor)
50
- let leftover = width - cells.reduce((sum, c) => sum + c, 0)
51
- const order = exact
52
- .map((e, i) => ({ i, frac: e - Math.floor(e) }))
53
- .sort((x, y) => y.frac - x.frac)
54
- for (const { i } of order) {
55
- if (leftover === 0) break
56
- cells[i]!++
57
- leftover--
58
- }
59
- for (let i = 0; i < segments.length; i++) {
60
- if (segments[i]!.n > 0 && cells[i] === 0) {
61
- const biggest = cells.indexOf(Math.max(...cells))
62
- cells[biggest]!--
63
- cells[i] = 1
64
- }
65
- }
66
- return segments
67
- .map((seg, i) =>
68
- // An empty segment paints nothing: no escape pair around no cell.
69
- cells[i] === 0
70
- ? ''
71
- : paint(seg.color, (seg.glyph ?? '\u25b0').repeat(cells[i]!), colors, {
72
- ...(seg.dim === true ? { dim: true } : {}),
73
- }),
74
- )
75
- .join('')
31
+ return bar(
32
+ segments.map((seg): Segment => ({
33
+ n: seg.n,
34
+ style: seg.color === '' || seg.dim === true ? 'muted' : seg.color,
35
+ ...(seg.glyph !== undefined ? { cell: seg.glyph } : {}),
36
+ })),
37
+ colors,
38
+ )
76
39
  }
77
40
 
78
41
  /** Aggregate counts feeding the summary section — buildable from a
@@ -227,6 +190,11 @@ export interface RunContext {
227
190
  usage?: (taskId: string) => { cores: number; rssBytes: number } | undefined
228
191
  /** Each task's count of the tasks downstream of it in the run, built on first ask: the rows' `blocks`. */
229
192
  blocks?: () => ReadonlyMap<string, number>
193
+ /**
194
+ * A task's usual duration from history, read once on the first ask;
195
+ * undefined for a task it never timed: every row's `usual`.
196
+ */
197
+ usual?: (taskId: string) => number | undefined
230
198
  /** Worker-pool size for this run; shown on the `info` row. */
231
199
  concurrency?: number
232
200
  remoteCacheEnabled: boolean
@@ -277,12 +245,12 @@ export function formatSummarySection(
277
245
  const blocked = stats.blocked ?? 0
278
246
  const remainder = {
279
247
  n: Math.max(0, left - running - scheduled - blocked),
280
- color: '',
248
+ color: 'muted' as const,
281
249
  glyph: '\u25b1',
282
250
  dim: true,
283
251
  }
284
252
 
285
- const dim = (txt: string) => paint('', txt, colors, { dim: true })
253
+ const dim = (txt: string) => paint('muted', txt, colors)
286
254
  const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
287
255
  const join = (parts: string[]): string => parts.join(` ${dim('\u00b7')} `)
288
256
 
@@ -291,13 +259,12 @@ export function formatSummarySection(
291
259
  // the run counts, each state its own segment (2026-10-14).
292
260
  const runTotal = stats.total + aborted + notRun
293
261
  const taskParts: string[] = []
294
- if (stats.failed > 0)
295
- taskParts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
296
- if (stats.successful > 0) taskParts.push(paint(SUCCESS, `${stats.successful} success`, colors))
297
- if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
298
- if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
299
- if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
300
- if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
262
+ if (stats.failed > 0) taskParts.push(paint('error', `${stats.failed} failed`, colors, true))
263
+ if (stats.successful > 0) taskParts.push(paint('ok', `${stats.successful} success`, colors))
264
+ if (stats.skipped > 0) taskParts.push(paint('warn', `${stats.skipped} skipped`, colors))
265
+ if (aborted > 0) taskParts.push(paint('aborted', `${aborted} aborted`, colors))
266
+ if (notRun > 0) taskParts.push(paint('notRun', `${notRun} not run`, colors))
267
+ if (running > 0) taskParts.push(paint('info', `${running} running`, colors))
301
268
  if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
302
269
  if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
303
270
  // What is not done yet, running included: the count the bar's unfilled part shows.
@@ -305,14 +272,14 @@ export function formatSummarySection(
305
272
  if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
306
273
  const taskBar = segmentBar(
307
274
  [
308
- { n: stats.failed, color: ERROR },
309
- { n: stats.successful, color: SUCCESS },
310
- { n: stats.skipped, color: WARN },
311
- { n: aborted, color: ABORTED },
312
- { n: notRun, color: NOT_RUN },
313
- { n: running, color: RUNNING },
314
- { n: scheduled, color: '', dim: true },
315
- { n: blocked, color: '', dim: true },
275
+ { n: stats.failed, color: 'error' },
276
+ { n: stats.successful, color: 'ok' },
277
+ { n: stats.skipped, color: 'warn' },
278
+ { n: aborted, color: 'aborted' },
279
+ { n: notRun, color: 'notRun' },
280
+ { n: running, color: 'info' },
281
+ { n: scheduled, color: 'muted', dim: true },
282
+ { n: blocked, color: 'muted', dim: true },
316
283
  remainder,
317
284
  ],
318
285
  colors,
@@ -320,12 +287,8 @@ export function formatSummarySection(
320
287
  // Bars on the label row, color-coded legend on its own line below,
321
288
  // indented to the bar column (2 indent + 8 label + 2 gap = 12).
322
289
  const legend = (parts: string[]): string => `${' '.repeat(12)}${join(parts)}`
323
- // Version rides the wordmark rule when run context is present (final
324
- // footer); the live region passes none and keeps a bare `vx`.
325
- const lines: string[] = [
326
- '',
327
- gradientRule(colors, context !== undefined ? `vx ${context.version}` : 'vx'),
328
- ]
290
+ // The panel's body; its box goes round it at the end.
291
+ const lines: string[] = []
329
292
 
330
293
  // Run context (final footer only): the projects bar leads the meter
331
294
  // stack — the projects in the run (yellow) vs the rest of the workspace (dim).
@@ -337,12 +300,12 @@ export function formatSummarySection(
337
300
  context.packageCount === 0
338
301
  ? 0
339
302
  : Math.min(width, Math.max(1, Math.round((context.packageCount / wsTotal) * width)))
340
- const bar =
341
- paint(WARN, '▰'.repeat(cells), colors) +
342
- paint('', '▱'.repeat(width - cells), colors, { dim: true })
343
303
  lines.push(
344
- row('projects', bar),
345
- legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
304
+ row('projects', gauge(cells, 'warn', colors)),
305
+ legend([
306
+ paint('warn', `${context.packageCount} affected`, colors),
307
+ dim(`${wsTotal} total`),
308
+ ]),
346
309
  )
347
310
  }
348
311
  }
@@ -371,17 +334,17 @@ export function formatSummarySection(
371
334
  const b = cacheBuckets(stats, layers)
372
335
  const cacheBar = segmentBar(
373
336
  [
374
- { n: b.miss, color: ERROR },
375
- { n: b.notSaved, color: WARN },
376
- { n: b.noCache, color: '', dim: true },
377
- { n: b.upToDate, color: SUCCESS },
378
- { n: b.restoredLocal, color: LOCAL },
379
- { n: b.saved, color: LOCAL },
380
- { n: b.restoredRemote, color: REMOTE },
381
- { n: b.uploaded, color: REMOTE },
382
- { n: b.uploading, color: ACCENT },
383
- { n: b.remoteFailed, color: ERROR },
384
- { n: stats.skipped, color: WARN },
337
+ { n: b.miss, color: 'error' },
338
+ { n: b.notSaved, color: 'warn' },
339
+ { n: b.noCache, color: 'muted', dim: true },
340
+ { n: b.upToDate, color: 'ok' },
341
+ { n: b.restoredLocal, color: 'local' },
342
+ { n: b.saved, color: 'local' },
343
+ { n: b.restoredRemote, color: 'remote' },
344
+ { n: b.uploaded, color: 'remote' },
345
+ { n: b.uploading, color: 'info' },
346
+ { n: b.remoteFailed, color: 'error' },
347
+ { n: stats.skipped, color: 'warn' },
385
348
  remainder,
386
349
  ],
387
350
  colors,
@@ -392,13 +355,13 @@ export function formatSummarySection(
392
355
  // either layer) and `∅` no cache, both local; `↓` restored from a
393
356
  // layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
394
357
  // uploading. Each count is its bar segment's, glyph and number in its colour.
395
- const n = (count: number, glyph: string, color: string, dim = false): string[] =>
396
- count > 0 ? [paint(color, `${count}${glyph}`, colors, dim ? { dim: true } : {})] : []
358
+ const n = (count: number, glyph: string, color: Style): string[] =>
359
+ count > 0 ? [paint(color, `${count}${glyph}`, colors)] : []
397
360
  const local = [
398
- ...(layers.local ? n(b.upToDate, '\u2713', SUCCESS) : []),
399
- ...n(b.noCache, '\u2205', '', true),
400
- ...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
401
- ...(layers.localWrite ? n(b.saved, '\u2191', LOCAL) : []),
361
+ ...(layers.local ? n(b.upToDate, '\u2713', 'ok') : []),
362
+ ...n(b.noCache, '\u2205', 'muted'),
363
+ ...(layers.local ? n(b.restoredLocal, '\u2193', 'local') : []),
364
+ ...(layers.localWrite ? n(b.saved, '\u2191', 'local') : []),
402
365
  ]
403
366
  // The layer words are dim; only the counts carry colour (2026-10-15).
404
367
  if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
@@ -408,29 +371,29 @@ export function formatSummarySection(
408
371
  count === 0
409
372
  ? []
410
373
  : bytes === undefined || bytes === 0
411
- ? n(count, glyph, REMOTE)
412
- : [...n(count, glyph, REMOTE), dim(formatBytes(bytes))]
374
+ ? n(count, glyph, 'remote')
375
+ : [...n(count, glyph, 'remote'), dim(formatBytes(bytes))]
413
376
  const remote = [
414
377
  ...sized(b.restoredRemote, '\u2193', layers.downloads?.bytes),
415
378
  ...sized(b.uploaded, '\u2191', layers.uploads?.bytes),
416
- ...n(b.remoteFailed, '\u2717', ERROR),
417
- ...n(b.uploading, '\u21e1', ACCENT),
379
+ ...n(b.remoteFailed, '\u2717', 'error'),
380
+ ...n(b.uploading, '\u21e1', 'info'),
418
381
  ]
419
382
  if (layers.remote && remote.length > 0)
420
383
  cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
421
- if (b.miss > 0) cacheParts.push(paint(ERROR, `${b.miss} miss`, colors))
422
- if (b.notSaved > 0) cacheParts.push(paint(WARN, `${b.notSaved} not saved`, colors))
384
+ if (b.miss > 0) cacheParts.push(paint('error', `${b.miss} miss`, colors))
385
+ if (b.notSaved > 0) cacheParts.push(paint('warn', `${b.notSaved} not saved`, colors))
423
386
  const notDownloaded = layers.notDownloaded()
424
- if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
387
+ if (notDownloaded > 0)
388
+ cacheParts.push(paint('warn', `${notDownloaded} not downloaded`, colors))
425
389
  } else {
426
- if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
427
- if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
428
- if (stats.upToDate > 0)
429
- cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
390
+ if (miss > 0) cacheParts.push(paint('error', `${miss} miss`, colors))
391
+ if (noCache > 0) cacheParts.push(paint('muted', `${noCache} no-cache`, colors))
392
+ if (stats.upToDate > 0) cacheParts.push(paint('ok', `${stats.upToDate} up-to-date`, colors))
430
393
  if (stats.restoredLocal > 0)
431
- cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
394
+ cacheParts.push(paint('local', `${stats.restoredLocal} local`, colors))
432
395
  if (stats.restoredRemote > 0)
433
- cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
396
+ cacheParts.push(paint('remote', `${stats.restoredRemote} remote`, colors))
434
397
  }
435
398
  // Skipped tasks ride the bar, yellow like the tasks meter, so the two
436
399
  // bars fill alike; their count is the tasks legend's alone (the footer
@@ -467,9 +430,9 @@ export function formatSummarySection(
467
430
  // The live run's forecast leads the spread: the one number on the row
468
431
  // that is about the future.
469
432
  if (stats.etaMs !== undefined)
470
- timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
433
+ timeParts.push(paint('info', `${formatEta(stats.etaMs)} left`, colors))
471
434
  else if (stats.overMs !== undefined)
472
- timeParts.push(paint(ACCENT, `${formatOver(stats.overMs)} over`, colors))
435
+ timeParts.push(paint('info', `${formatOver(stats.overMs)} over`, colors))
473
436
  for (const p of spreadParts) timeParts.push(dim(p))
474
437
  const timeRow = row('time', join(timeParts))
475
438
  // Run-shape footer (final summary only): worker pool + cache mode,
@@ -513,7 +476,18 @@ export function formatSummarySection(
513
476
  } else {
514
477
  lines.push('', timeRow)
515
478
  }
516
- return lines
479
+ // The Summary panel; the final one names the vx that ran it (the live
480
+ // one has no context yet).
481
+ return [
482
+ '',
483
+ panelTop(
484
+ 'Summary',
485
+ colors,
486
+ undefined,
487
+ context !== undefined ? `vx ${context.version}` : undefined,
488
+ ),
489
+ ...lines,
490
+ ]
517
491
  }
518
492
 
519
493
  /**
@@ -532,7 +506,7 @@ function warnRows(
532
506
  warnings.map((w) => Bun.stripANSI(w).split('\n')[0]!.trim()).filter((w) => w !== ''),
533
507
  ).size
534
508
  if (n === 0) return []
535
- return [row('warn', paint(WARN, `${n} ${n === 1 ? 'warning' : 'warnings'} above`, colors))]
509
+ return [row('warn', paint('warn', `${n} ${n === 1 ? 'warning' : 'warnings'} above`, colors))]
536
510
  }
537
511
 
538
512
  /**
@@ -576,18 +550,16 @@ export function formatOutcomeTable(
576
550
  }
577
551
  const pad = (text: string, width: number) => ' '.repeat(Math.max(0, width - text.length))
578
552
  const header = `${'TASK'.padEnd(widths.task)} ${'STATUS'.padEnd(widths.status)} ${'DURATION'.padStart(widths.duration)}`
579
- const dim = (t: string) => paint('', t, colors, { dim: true })
553
+ const dim = (t: string) => paint('muted', t, colors)
580
554
  return [
581
555
  '',
582
- paint(TABLE_HEADER, header, colors, { bold: true }),
556
+ paint('heading', header, colors, true),
583
557
  dim('-'.repeat(header.length)),
584
558
  ...rows.map((r) => {
585
559
  const { node } = r.o
586
560
  const id =
587
561
  node.projectName !== undefined && node.taskName !== undefined
588
- ? paint(identityColor(node.projectName), node.projectName, colors) +
589
- dim('#') +
590
- paint(identityColor(node.taskName), node.taskName, colors)
562
+ ? idParts(node.projectName, node.projectName, node.taskName, colors)
591
563
  : r.task
592
564
  const status = paint(tableStatusColor(r.o), r.status, colors)
593
565
  return `${id}${pad(r.task, widths.task)} ${status}${pad(r.status, widths.status)} ${pad(r.duration, widths.duration)}${dim(r.duration)}`
@@ -595,19 +567,16 @@ export function formatOutcomeTable(
595
567
  ]
596
568
  }
597
569
 
598
- /** violet-400, as the task list's header. */
599
- const TABLE_HEADER = '#a78bfa'
600
-
601
- function tableStatusColor(o: TaskOutcome): string {
570
+ function tableStatusColor(o: TaskOutcome): Style {
602
571
  switch (o.status) {
603
572
  case 'failed':
604
- return ERROR
573
+ return 'error'
605
574
  case 'skipped':
606
- return WARN
575
+ return 'warn'
607
576
  case 'aborted':
608
- return neverStarted(o) ? NOT_RUN : ABORTED
577
+ return neverStarted(o) ? 'notRun' : 'aborted'
609
578
  default:
610
- return SUCCESS
579
+ return 'ok'
611
580
  }
612
581
  }
613
582
 
package/src/ui/ansi.ts ADDED
@@ -0,0 +1,48 @@
1
+ // A task's own bytes, as a screen shows them: vx never restyles them, but
2
+ // it ends a colour they leave on and plays a redraw they hold before its
3
+ // own lines go below or beside them.
4
+
5
+ import { RESET } from './theme.js'
6
+
7
+ /** Whether `text` holds a terminal escape. */
8
+ export const hasEscape = (text: string): boolean => text.includes('\x1b')
9
+
10
+ /** Whether `text`'s last colour code sets a style rather than resetting it. */
11
+ export function leavesStyle(text: string): boolean {
12
+ const at = text.lastIndexOf('\x1b[')
13
+ if (at < 0) return false
14
+ const sgr = /^\[([\d;]*)m/.exec(text.slice(at + 1, at + 64))
15
+ // Not a colour code (a cursor move): look no further, as a reset costs nothing.
16
+ if (sgr === null) return true
17
+ return sgr[1] !== '' && sgr[1] !== '0'
18
+ }
19
+
20
+ /** `text`, with any colour it leaves on ended. */
21
+ export const endStyle = (text: string): string => (leavesStyle(text) ? `${text}${RESET}` : text)
22
+
23
+ /** The bytes that end any colour left on: a task killed mid-write leaves one open. */
24
+ export const STYLE_END = RESET
25
+
26
+ // oxlint-disable-next-line no-control-regex -- ESC is the point
27
+ const LINE_RESET = /\r|\x1b\[[12]K|\x1b\[[01]?G/g
28
+ // oxlint-disable-next-line no-control-regex -- ESC is the point
29
+ const TRAILING_RESETS = /(?:\r|\x1b\[[12]K|\x1b\[[01]?G)+$/
30
+ // oxlint-disable-next-line no-control-regex -- ESC is the point
31
+ const SGR = /\x1b\[[0-9;]*m/g
32
+
33
+ /**
34
+ * What a line shows once its redraws have played: the text after its last
35
+ * `\r`, line erase (`CSI 1K`/`2K`) or move to column 1 (`CSI G`), with the
36
+ * colours set before it. Written after an id prefix, a dev server's redraw
37
+ * (`\x1b[2K\x1b[1Gbuilt in 30ms`, a `10%\r100%` progress line) erased or
38
+ * overwrote the prefix on a terminal. A reset that ends the line, as in
39
+ * CRLF, redraws nothing.
40
+ */
41
+ export function lastRedraw(line: string): string {
42
+ if (!line.includes('\r') && !line.includes('\x1b')) return line
43
+ const text = line.replace(TRAILING_RESETS, '')
44
+ let cut = -1
45
+ for (const m of text.matchAll(LINE_RESET)) cut = m.index + m[0].length
46
+ if (cut === -1) return text
47
+ return (text.slice(0, cut).match(SGR) ?? []).join('') + text.slice(cut)
48
+ }