@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /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
@@ -99,11 +62,11 @@ export interface SummaryStats {
99
62
  restoredRemote: number
100
63
  /** Ran here and saved to the cache: the legend's `↑`. */
101
64
  saved?: number
102
- /** Ran here with a `cache` block, succeeded, and saved nothing. */
65
+ /** Ran here with `cache: true`, succeeded, and saved nothing. */
103
66
  notSaved?: number
104
- /** Tasks that executed (success + failed) with a `cache` block — the misses. */
67
+ /** Tasks that executed (success + failed) with `cache: true` — the misses. */
105
68
  miss: number
106
- /** Executed with no `cache` block: never consulted the cache, not a miss. */
69
+ /** Executed without `cache: true`: never consulted the cache, not a miss. */
107
70
  noCache?: number
108
71
  /** Still to run (live section only; 0 in the final summary) — renders
109
72
  * as a gray ▱ remainder so the live meters FILL toward the final. */
@@ -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,25 +567,22 @@ 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
 
614
583
  // Local byte formatter — the orchestrator can't import cli/format.ts
615
584
  // (module boundary), and the footer only needs one compact form.
616
- /** Ran here with a `cache` block, succeeded, and its result was not saved. */
585
+ /** Ran here with `cache: true`, succeeded, and its result was not saved. */
617
586
  export function ranUnsaved(o: TaskOutcome): boolean {
618
587
  return o.status === 'success' && !ranNoCache(o) && o.saved !== true && o.where === undefined
619
588
  }
@@ -624,7 +593,7 @@ export function formatRunSummary(
624
593
  colors: ColorSupport = NO_COLOR,
625
594
  context?: RunContext,
626
595
  ): string[] {
627
- // Counted here, not in the Tally: a task with no `cache` block never
596
+ // Counted here, not in the Tally: a task without `cache: true` never
628
597
  // consulted the cache, so it is not a miss.
629
598
  const noCache = outcomes.filter(
630
599
  (o) =>
package/src/settings.ts CHANGED
@@ -118,7 +118,7 @@ export const SETTINGS: readonly Setting[] = [
118
118
  {
119
119
  path: 'cache.dir',
120
120
  kind: { type: 'string' },
121
- default: '.vx/cache, entries in ~/.vx/<repo>/cache',
121
+ default: '.vx/cache, entries in ~/.vx/<id>/cache',
122
122
  doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
123
123
  flag: { verb: 'run', name: '--cache-dir' },
124
124
  },
@@ -165,25 +165,25 @@ export const SETTINGS: readonly Setting[] = [
165
165
  flag: { verb: 'run', name: '--cache-download' },
166
166
  },
167
167
  {
168
- path: 'cache.inputStability.strategy',
168
+ path: 'inputs.stability.strategy',
169
169
  kind: { type: 'enum', values: ['immutable', 'mutable', 'unchecked'] },
170
170
  default: 'immutable',
171
171
  doc: 'What a write to an input during the command means for the save, for tasks without their own.',
172
- task: 'cache.inputStability.strategy',
172
+ task: 'inputs.stability.strategy',
173
173
  },
174
174
  {
175
- path: 'cache.inputStability.severity',
175
+ path: 'inputs.stability.severity',
176
176
  kind: { type: 'enum', values: ['warn', 'error'] },
177
177
  default: 'warn',
178
178
  doc: 'Whether a skipped save warns or fails the task, for tasks without their own.',
179
- task: 'cache.inputStability.severity',
179
+ task: 'inputs.stability.severity',
180
180
  },
181
181
  {
182
- path: 'cache.outputs.clean',
182
+ path: 'outputs.clean',
183
183
  kind: { type: 'bool' },
184
184
  default: 'true',
185
185
  doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
186
- task: 'cache.outputs.clean',
186
+ task: 'outputs.clean',
187
187
  env: false,
188
188
  },
189
189
  {
@@ -249,7 +249,7 @@ export const SETTINGS: readonly Setting[] = [
249
249
  ]
250
250
 
251
251
  /** The `.vx/workspace` groups that hold settings; `baseBranch` and `plugins` are the other keys. */
252
- export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
252
+ export const SETTING_GROUPS = ['run', 'cache', 'inputs', 'outputs', 'lock', 'watch'] as const
253
253
 
254
254
  const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
255
255
 
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
+ }