@vzn/vx 0.0.649 → 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 (122) 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 +225 -51
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/fold-memo.ts +8 -1
  8. package/src/cache/git-inputs.ts +188 -39
  9. package/src/cache/index.ts +12 -0
  10. package/src/cache/inputs.ts +3 -0
  11. package/src/cache/layer.ts +11 -0
  12. package/src/cache/layered-cache.ts +4 -0
  13. package/src/cache/linked-inputs.ts +3 -0
  14. package/src/cache/lockfile-memo.ts +120 -0
  15. package/src/cache/output-index.ts +5 -2
  16. package/src/cache/remote-hits.ts +9 -1
  17. package/src/cache/remote-lane.ts +2 -1
  18. package/src/cache/run-history.ts +11 -1
  19. package/src/cache/run-notes.ts +123 -0
  20. package/src/cache/schema.ts +54 -1
  21. package/src/cli/cache.ts +26 -16
  22. package/src/cli/completions.ts +4 -2
  23. package/src/cli/docs.ts +10 -11
  24. package/src/cli/help.ts +12 -23
  25. package/src/cli/index.ts +4 -9
  26. package/src/cli/info.ts +15 -7
  27. package/src/cli/init.ts +23 -15
  28. package/src/cli/last.ts +224 -223
  29. package/src/cli/lock.ts +55 -5
  30. package/src/cli/plan-format.ts +100 -118
  31. package/src/cli/plugin-commands.ts +10 -3
  32. package/src/cli/plugin-templates.ts +2 -2
  33. package/src/cli/run.ts +46 -27
  34. package/src/cli/select.ts +43 -5
  35. package/src/cli/show.ts +38 -42
  36. package/src/cli/task-verb.ts +22 -0
  37. package/src/cli/upgrade.ts +3 -1
  38. package/src/cli/watch-fs.ts +6 -1
  39. package/src/cli/watch-judge.ts +2 -1
  40. package/src/cli/watch.ts +83 -44
  41. package/src/cli/why.ts +126 -92
  42. package/src/cli/workspace-config.ts +2 -1
  43. package/src/exec/executor.ts +2 -1
  44. package/src/exec/kill-tree.ts +58 -5
  45. package/src/exec/local-executor.ts +1 -1
  46. package/src/exec/pipes.ts +152 -0
  47. package/src/exec/runner.ts +199 -182
  48. package/src/exec/sandbox-binds.ts +1 -1
  49. package/src/exec/sandbox-runtime.ts +11 -2
  50. package/src/exec/spawn-off.ts +154 -0
  51. package/src/exec/warm-shell.ts +146 -0
  52. package/src/graph/critical-path.ts +40 -28
  53. package/src/graph/priorities.ts +98 -44
  54. package/src/graph/scheduler.ts +12 -2
  55. package/src/graph/task-graph.ts +27 -1
  56. package/src/handoff.ts +10 -7
  57. package/src/history/history.ts +39 -2
  58. package/src/history/index.ts +3 -0
  59. package/src/history/metrics.ts +9 -1
  60. package/src/index.ts +12 -3
  61. package/src/orchestrator/doctor.ts +3 -3
  62. package/src/orchestrator/execute-task.ts +16 -2
  63. package/src/orchestrator/graph-memo.ts +50 -1
  64. package/src/orchestrator/graph-stage.ts +6 -0
  65. package/src/orchestrator/hit-restore.ts +3 -0
  66. package/src/orchestrator/index.ts +3 -1
  67. package/src/orchestrator/live-usage.ts +98 -0
  68. package/src/orchestrator/locked-digests.ts +63 -0
  69. package/src/orchestrator/lockfile-claim.ts +176 -102
  70. package/src/orchestrator/miss-save.ts +31 -3
  71. package/src/orchestrator/persistent-task.ts +3 -1
  72. package/src/orchestrator/plan.ts +5 -1
  73. package/src/orchestrator/plugin-host.ts +61 -0
  74. package/src/orchestrator/plugin.ts +49 -4
  75. package/src/orchestrator/prepare.ts +18 -2
  76. package/src/orchestrator/projects.ts +4 -1
  77. package/src/orchestrator/run-context.ts +29 -10
  78. package/src/orchestrator/run-failures.ts +35 -114
  79. package/src/orchestrator/run-records.ts +10 -0
  80. package/src/orchestrator/run.ts +89 -26
  81. package/src/orchestrator/sandbox-request.ts +2 -2
  82. package/src/orchestrator/signals.ts +8 -5
  83. package/src/orchestrator/steps.ts +2 -2
  84. package/src/orchestrator/task-hash.ts +17 -3
  85. package/src/report/forecast.ts +12 -33
  86. package/src/report/framed-output.ts +388 -161
  87. package/src/report/index.ts +8 -2
  88. package/src/report/logger.ts +100 -36
  89. package/src/report/status-line.ts +81 -365
  90. package/src/report/summary.ts +104 -131
  91. package/src/ui/ansi.ts +48 -0
  92. package/src/ui/components.ts +506 -0
  93. package/src/ui/index.ts +79 -0
  94. package/src/ui/kit.ts +37 -0
  95. package/src/ui/live-region.ts +363 -0
  96. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  97. package/src/ui/terminal.ts +51 -0
  98. package/src/ui/theme.ts +224 -0
  99. package/src/ui/version-line.ts +28 -0
  100. package/src/util/config-frame.ts +65 -10
  101. package/src/util/edit-distance.ts +6 -1
  102. package/src/util/hidden-prompt.ts +8 -2
  103. package/src/util/index.ts +2 -15
  104. package/src/util/paths.ts +20 -2
  105. package/src/util/secret-mask.ts +39 -3
  106. package/src/util/secrets.ts +3 -0
  107. package/src/util/settle.ts +31 -0
  108. package/src/util/timing.ts +4 -16
  109. package/src/workspace/affected.ts +89 -30
  110. package/src/workspace/config-schema.ts +4 -2
  111. package/src/workspace/dependencies-lock.ts +191 -0
  112. package/src/workspace/filter.ts +28 -0
  113. package/src/workspace/index.ts +9 -0
  114. package/src/workspace/lockfile.ts +159 -6
  115. package/src/workspace/migration.ts +4 -3
  116. package/src/workspace/workspace.ts +3 -0
  117. package/src/orchestrator/run-steps.ts +0 -93
  118. package/src/util/colors.ts +0 -130
  119. package/src/util/report.ts +0 -44
  120. package/src/util/ui.ts +0 -258
  121. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  122. /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
@@ -223,6 +186,15 @@ export interface RunContext {
223
186
  executorRows?: () => readonly (readonly [string, ui.Inline])[]
224
187
  /** Where a running task runs, `null` while it waits for a machine (`TaskExecutor.running`): its live row. */
225
188
  whereRunning?: (taskId: string) => string | null | undefined
189
+ /** A running task's live CPU and memory: its live row's `cpu` and `mem`. */
190
+ usage?: (taskId: string) => { cores: number; rssBytes: number } | undefined
191
+ /** Each task's count of the tasks downstream of it in the run, built on first ask: the rows' `blocks`. */
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
226
198
  /** Worker-pool size for this run; shown on the `info` row. */
227
199
  concurrency?: number
228
200
  remoteCacheEnabled: boolean
@@ -273,12 +245,12 @@ export function formatSummarySection(
273
245
  const blocked = stats.blocked ?? 0
274
246
  const remainder = {
275
247
  n: Math.max(0, left - running - scheduled - blocked),
276
- color: '',
248
+ color: 'muted' as const,
277
249
  glyph: '\u25b1',
278
250
  dim: true,
279
251
  }
280
252
 
281
- const dim = (txt: string) => paint('', txt, colors, { dim: true })
253
+ const dim = (txt: string) => paint('muted', txt, colors)
282
254
  const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
283
255
  const join = (parts: string[]): string => parts.join(` ${dim('\u00b7')} `)
284
256
 
@@ -287,13 +259,12 @@ export function formatSummarySection(
287
259
  // the run counts, each state its own segment (2026-10-14).
288
260
  const runTotal = stats.total + aborted + notRun
289
261
  const taskParts: string[] = []
290
- if (stats.failed > 0)
291
- taskParts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
292
- if (stats.successful > 0) taskParts.push(paint(SUCCESS, `${stats.successful} success`, colors))
293
- if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
294
- if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
295
- if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
296
- 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))
297
268
  if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
298
269
  if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
299
270
  // What is not done yet, running included: the count the bar's unfilled part shows.
@@ -301,14 +272,14 @@ export function formatSummarySection(
301
272
  if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
302
273
  const taskBar = segmentBar(
303
274
  [
304
- { n: stats.failed, color: ERROR },
305
- { n: stats.successful, color: SUCCESS },
306
- { n: stats.skipped, color: WARN },
307
- { n: aborted, color: ABORTED },
308
- { n: notRun, color: NOT_RUN },
309
- { n: running, color: RUNNING },
310
- { n: scheduled, color: '', dim: true },
311
- { 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 },
312
283
  remainder,
313
284
  ],
314
285
  colors,
@@ -316,12 +287,8 @@ export function formatSummarySection(
316
287
  // Bars on the label row, color-coded legend on its own line below,
317
288
  // indented to the bar column (2 indent + 8 label + 2 gap = 12).
318
289
  const legend = (parts: string[]): string => `${' '.repeat(12)}${join(parts)}`
319
- // Version rides the wordmark rule when run context is present (final
320
- // footer); the live region passes none and keeps a bare `vx`.
321
- const lines: string[] = [
322
- '',
323
- gradientRule(colors, context !== undefined ? `vx ${context.version}` : 'vx'),
324
- ]
290
+ // The panel's body; its box goes round it at the end.
291
+ const lines: string[] = []
325
292
 
326
293
  // Run context (final footer only): the projects bar leads the meter
327
294
  // stack — the projects in the run (yellow) vs the rest of the workspace (dim).
@@ -333,12 +300,12 @@ export function formatSummarySection(
333
300
  context.packageCount === 0
334
301
  ? 0
335
302
  : Math.min(width, Math.max(1, Math.round((context.packageCount / wsTotal) * width)))
336
- const bar =
337
- paint(WARN, '▰'.repeat(cells), colors) +
338
- paint('', '▱'.repeat(width - cells), colors, { dim: true })
339
303
  lines.push(
340
- row('projects', bar),
341
- 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
+ ]),
342
309
  )
343
310
  }
344
311
  }
@@ -367,17 +334,17 @@ export function formatSummarySection(
367
334
  const b = cacheBuckets(stats, layers)
368
335
  const cacheBar = segmentBar(
369
336
  [
370
- { n: b.miss, color: ERROR },
371
- { n: b.notSaved, color: WARN },
372
- { n: b.noCache, color: '', dim: true },
373
- { n: b.upToDate, color: SUCCESS },
374
- { n: b.restoredLocal, color: LOCAL },
375
- { n: b.saved, color: LOCAL },
376
- { n: b.restoredRemote, color: REMOTE },
377
- { n: b.uploaded, color: REMOTE },
378
- { n: b.uploading, color: ACCENT },
379
- { n: b.remoteFailed, color: ERROR },
380
- { 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' },
381
348
  remainder,
382
349
  ],
383
350
  colors,
@@ -388,13 +355,13 @@ export function formatSummarySection(
388
355
  // either layer) and `∅` no cache, both local; `↓` restored from a
389
356
  // layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
390
357
  // uploading. Each count is its bar segment's, glyph and number in its colour.
391
- const n = (count: number, glyph: string, color: string, dim = false): string[] =>
392
- 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)] : []
393
360
  const local = [
394
- ...(layers.local ? n(b.upToDate, '\u2713', SUCCESS) : []),
395
- ...n(b.noCache, '\u2205', '', true),
396
- ...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
397
- ...(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') : []),
398
365
  ]
399
366
  // The layer words are dim; only the counts carry colour (2026-10-15).
400
367
  if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
@@ -404,29 +371,29 @@ export function formatSummarySection(
404
371
  count === 0
405
372
  ? []
406
373
  : bytes === undefined || bytes === 0
407
- ? n(count, glyph, REMOTE)
408
- : [...n(count, glyph, REMOTE), dim(formatBytes(bytes))]
374
+ ? n(count, glyph, 'remote')
375
+ : [...n(count, glyph, 'remote'), dim(formatBytes(bytes))]
409
376
  const remote = [
410
377
  ...sized(b.restoredRemote, '\u2193', layers.downloads?.bytes),
411
378
  ...sized(b.uploaded, '\u2191', layers.uploads?.bytes),
412
- ...n(b.remoteFailed, '\u2717', ERROR),
413
- ...n(b.uploading, '\u21e1', ACCENT),
379
+ ...n(b.remoteFailed, '\u2717', 'error'),
380
+ ...n(b.uploading, '\u21e1', 'info'),
414
381
  ]
415
382
  if (layers.remote && remote.length > 0)
416
383
  cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
417
- if (b.miss > 0) cacheParts.push(paint(ERROR, `${b.miss} miss`, colors))
418
- 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))
419
386
  const notDownloaded = layers.notDownloaded()
420
- if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
387
+ if (notDownloaded > 0)
388
+ cacheParts.push(paint('warn', `${notDownloaded} not downloaded`, colors))
421
389
  } else {
422
- if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
423
- if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
424
- if (stats.upToDate > 0)
425
- 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))
426
393
  if (stats.restoredLocal > 0)
427
- cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
394
+ cacheParts.push(paint('local', `${stats.restoredLocal} local`, colors))
428
395
  if (stats.restoredRemote > 0)
429
- cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
396
+ cacheParts.push(paint('remote', `${stats.restoredRemote} remote`, colors))
430
397
  }
431
398
  // Skipped tasks ride the bar, yellow like the tasks meter, so the two
432
399
  // bars fill alike; their count is the tasks legend's alone (the footer
@@ -463,9 +430,9 @@ export function formatSummarySection(
463
430
  // The live run's forecast leads the spread: the one number on the row
464
431
  // that is about the future.
465
432
  if (stats.etaMs !== undefined)
466
- timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
433
+ timeParts.push(paint('info', `${formatEta(stats.etaMs)} left`, colors))
467
434
  else if (stats.overMs !== undefined)
468
- timeParts.push(paint(ACCENT, `${formatOver(stats.overMs)} over`, colors))
435
+ timeParts.push(paint('info', `${formatOver(stats.overMs)} over`, colors))
469
436
  for (const p of spreadParts) timeParts.push(dim(p))
470
437
  const timeRow = row('time', join(timeParts))
471
438
  // Run-shape footer (final summary only): worker pool + cache mode,
@@ -509,7 +476,18 @@ export function formatSummarySection(
509
476
  } else {
510
477
  lines.push('', timeRow)
511
478
  }
512
- 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
+ ]
513
491
  }
514
492
 
515
493
  /**
@@ -528,7 +506,7 @@ function warnRows(
528
506
  warnings.map((w) => Bun.stripANSI(w).split('\n')[0]!.trim()).filter((w) => w !== ''),
529
507
  ).size
530
508
  if (n === 0) return []
531
- 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))]
532
510
  }
533
511
 
534
512
  /**
@@ -572,18 +550,16 @@ export function formatOutcomeTable(
572
550
  }
573
551
  const pad = (text: string, width: number) => ' '.repeat(Math.max(0, width - text.length))
574
552
  const header = `${'TASK'.padEnd(widths.task)} ${'STATUS'.padEnd(widths.status)} ${'DURATION'.padStart(widths.duration)}`
575
- const dim = (t: string) => paint('', t, colors, { dim: true })
553
+ const dim = (t: string) => paint('muted', t, colors)
576
554
  return [
577
555
  '',
578
- paint(TABLE_HEADER, header, colors, { bold: true }),
556
+ paint('heading', header, colors, true),
579
557
  dim('-'.repeat(header.length)),
580
558
  ...rows.map((r) => {
581
559
  const { node } = r.o
582
560
  const id =
583
561
  node.projectName !== undefined && node.taskName !== undefined
584
- ? paint(identityColor(node.projectName), node.projectName, colors) +
585
- dim('#') +
586
- paint(identityColor(node.taskName), node.taskName, colors)
562
+ ? idParts(node.projectName, node.projectName, node.taskName, colors)
587
563
  : r.task
588
564
  const status = paint(tableStatusColor(r.o), r.status, colors)
589
565
  return `${id}${pad(r.task, widths.task)} ${status}${pad(r.status, widths.status)} ${pad(r.duration, widths.duration)}${dim(r.duration)}`
@@ -591,19 +567,16 @@ export function formatOutcomeTable(
591
567
  ]
592
568
  }
593
569
 
594
- /** violet-400, as the task list's header. */
595
- const TABLE_HEADER = '#a78bfa'
596
-
597
- function tableStatusColor(o: TaskOutcome): string {
570
+ function tableStatusColor(o: TaskOutcome): Style {
598
571
  switch (o.status) {
599
572
  case 'failed':
600
- return ERROR
573
+ return 'error'
601
574
  case 'skipped':
602
- return WARN
575
+ return 'warn'
603
576
  case 'aborted':
604
- return neverStarted(o) ? NOT_RUN : ABORTED
577
+ return neverStarted(o) ? 'notRun' : 'aborted'
605
578
  default:
606
- return SUCCESS
579
+ return 'ok'
607
580
  }
608
581
  }
609
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
+ }