@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
@@ -26,49 +26,26 @@
26
26
  // `--ui=stream` mode.
27
27
 
28
28
  import { withForwardArgs } from '../exec/index.js'
29
- import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
+ import { isCacheHit, isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
30
+ import { formatElapsed, maskedCommand, printable } from '../util/index.js'
31
+ import * as ui from '../ui/index.js'
30
32
  import {
31
- formatElapsed,
32
- identityColor,
33
- maskedCommand,
33
+ fileMemo,
34
+ hasEscape,
35
+ idParts,
36
+ lastRedraw,
37
+ leavesStyle,
38
+ linkPaths,
34
39
  paint,
35
- printable,
36
40
  ruleWidth,
37
- ui,
41
+ STYLE_END,
38
42
  type ColorSupport,
39
- } from '../util/index.js'
43
+ type Style,
44
+ } from '../ui/index.js'
40
45
  import { neverStarted } from './summary.js'
41
46
  import { outcomeLabel, outcomeWord, ranNoCache, skippedReason } from './events.js'
42
- import { fileMemo, linkPaths } from './path-links.js'
43
47
 
44
48
  const NO_COLOR: ColorSupport = { enabled: false }
45
- // Ends any colour a task's output left on, so vx's own lines below never
46
- // take it (a task killed mid-write leaves one open).
47
- export const RESET = '\x1b[0m'
48
-
49
- /** Whether `text`'s last colour code sets a style rather than resetting it. */
50
- export function leavesStyle(text: string): boolean {
51
- const at = text.lastIndexOf('\x1b[')
52
- if (at < 0) return false
53
- const sgr = /^\[([\d;]*)m/.exec(text.slice(at + 1, at + 64))
54
- // Not a colour code (a cursor move): look no further, as a reset costs nothing.
55
- if (sgr === null) return true
56
- return sgr[1] !== '' && sgr[1] !== '0'
57
- }
58
-
59
- const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
60
- // Identity hues (`identityColor`) are outside the status palette (green /
61
- // red / yellow / cyan) so a task id can never read as an outcome. Each
62
- // half of an id hashes its own name: same project, same hue; same target,
63
- // same hue, in every run, list and region row.
64
-
65
- const SUCCESS = '#22c55e' // green-500 — success / fresh
66
- const WARN = '#eab308' // yellow-500 — skipped
67
- const ERROR = '#ef4444' // red-500 — failed
68
- const ABORTED = '#f97316' // orange-500 — killed by a shutdown signal
69
- const LOCAL = '#38bdf8' // sky-400 — local cache hit
70
- const REMOTE = '#2563eb' // blue-600 — remote cache hit
71
-
72
49
  /** A piece of a task's output, as written: `err` for stderr. */
73
50
  export interface OutputChunk {
74
51
  text: string
@@ -111,11 +88,11 @@ export function formatTaskBlock(
111
88
  const output = body.output ?? []
112
89
 
113
90
  const idPainted = paintTaskId(node, colors, { bold: true })
114
- const corner = (s: string) => paint('', s, colors, { dim: true })
91
+ const corner = (s: string) => paint('muted', s, colors)
115
92
  // A ready server's frame reads as the live one does: marked, and running.
116
93
  const mark = persistentMark(node, colors)
117
94
  const running = mark !== '' && outcome.status === 'success' && outcome.serverExit === undefined
118
- const header = running ? paint(ACCENT, 'running', colors) : formatBlockHeader(outcome, colors)
95
+ const header = running ? paint('info', 'running', colors) : formatBlockHeader(outcome, colors)
119
96
  const lines: string[] = [`${corner('┌─')} ${mark}${idPainted} ${corner('>')} ${header}`]
120
97
 
121
98
  // The command section shows what actually ran — executed tasks
@@ -149,9 +126,7 @@ export function formatTaskBlock(
149
126
 
150
127
  /** What a bounded capture dropped from its head, so a capped log never reads as whole. */
151
128
  function droppedNote(lost: number, colors: ColorSupport): string {
152
- return paint('', `… ${lost} earlier character${lost === 1 ? '' : 's'} dropped`, colors, {
153
- dim: true,
154
- })
129
+ return paint('muted', `… ${lost} earlier character${lost === 1 ? '' : 's'} dropped`, colors)
155
130
  }
156
131
 
157
132
  /**
@@ -163,7 +138,7 @@ function pushStreamSection(
163
138
  lines: string[],
164
139
  text: string,
165
140
  title: string,
166
- color: string,
141
+ color: Style,
167
142
  lost: number,
168
143
  colors: ColorSupport,
169
144
  ): void {
@@ -173,7 +148,7 @@ function pushStreamSection(
173
148
  lines.push(droppedNote(lost, colors))
174
149
  }
175
150
  pushBodyLines(lines, text)
176
- if (leavesStyle(text)) lines.push(`${lines.pop()}${RESET}`)
151
+ if (leavesStyle(text)) lines.push(`${lines.pop()}${STYLE_END}`)
177
152
  lines.push('')
178
153
  }
179
154
 
@@ -193,7 +168,7 @@ function pushOutputSection(
193
168
  link?: (line: string) => string,
194
169
  ): void {
195
170
  if (output.every((c) => c.text.trim().length === 0)) return
196
- lines.push(sectionLine('OUTPUT', '', colors), '')
171
+ lines.push(sectionLine('OUTPUT', 'strong', colors), '')
197
172
  if (lost > 0) {
198
173
  lines.push(droppedNote(lost, colors))
199
174
  }
@@ -216,10 +191,10 @@ function pushOutputSection(
216
191
  for (const l of body) {
217
192
  const text = link === undefined ? l.text : link(l.text)
218
193
  if (!l.err || !colors.enabled) lines.push(text)
219
- else lines.push(l.text.includes('\x1b') ? text : paint(ERROR, text, colors))
194
+ else lines.push(hasEscape(l.text) ? text : paint('error', text, colors))
220
195
  }
221
- const styled = output.findLast((c) => c.text.includes('\x1b['))
222
- if (styled !== undefined && leavesStyle(styled.text)) lines.push(`${lines.pop()}${RESET}`)
196
+ const styled = output.findLast((c) => hasEscape(c.text))
197
+ if (styled !== undefined && leavesStyle(styled.text)) lines.push(`${lines.pop()}${STYLE_END}`)
223
198
  lines.push('')
224
199
  }
225
200
 
@@ -230,9 +205,9 @@ function pushOutputSection(
230
205
  * the frame width, and sections get vertical margins so content stands
231
206
  * clear of the furniture.
232
207
  */
233
- function sectionLine(title: string, color: string, colors: ColorSupport): string {
234
- const corner = (s: string) => paint('', s, colors, { dim: true })
235
- const label = paint(color, title, colors, { bold: true })
208
+ function sectionLine(title: string, color: Style, colors: ColorSupport): string {
209
+ const corner = (s: string) => paint('muted', s, colors)
210
+ const label = paint(color, title, colors, true)
236
211
  // As wide as the footer's rule, so the frames and the footer line up.
237
212
  const rule = corner('─'.repeat(Math.max(1, ruleWidth(colors) - 4 - title.length)))
238
213
  return `${corner('├─')} ${label} ${rule}`
@@ -260,13 +235,7 @@ export function paintIdParts(
260
235
  colors: ColorSupport,
261
236
  opts: { bold?: boolean; sep?: '#' | ':' } = {},
262
237
  ): string {
263
- const dim = (t: string) => paint('', t, colors, { dim: true })
264
- const { sep = '#', ...style } = opts
265
- return (
266
- paint(identityColor(hueSource), projectText, colors, style) +
267
- dim(sep) +
268
- paint(identityColor(taskText), taskText, colors, style)
269
- )
238
+ return idParts(hueSource, projectText, taskText, colors, opts)
270
239
  }
271
240
 
272
241
  function paintId(
@@ -294,19 +263,27 @@ const STATUS_COL = 7 // "success" / "running" / "skipped" / "aborted" / "not run
294
263
  const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
295
264
  const BLOCKED_COL = 7 // `blocks`: how many tasks are downstream of the row's
296
265
  const USUAL_COL = 7 // "~18s" live, "+6.0s" / "−1m 05s" after
297
- const WAITED_COL = 7 // the time cell's width: "1.20s"
298
266
  const CPU_COL = 5 // "180%", "1200%"
299
267
  const MEM_COL = 6 // "412MB", "1.1GB", "1023MB"
300
268
  const OUT_COL = 7 // `outputs`: "12MB", "900B"
301
269
  const WORKER_COL = 12 // `local`, or a plugin's name for the machine, cut at 12
302
- /** The list header's colour: violet-400, the summary rule's first stop. */
270
+
271
+ // A row's padding, built once: `' '.repeat` per cell was ~7 ms of an
272
+ // 8,002-hit warm run's rows, the largest single cost of writing them.
273
+ const SPACES = Array.from({ length: 33 }, (_, n) => ' '.repeat(n))
274
+
275
+ /** `n` spaces (none below 1). */
276
+ function pad(n: number): string {
277
+ return n <= 0 ? '' : (SPACES[n] ?? ' '.repeat(n))
278
+ }
303
279
 
304
280
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
305
281
  function timeCell(ms: number | null, colors: ColorSupport): string {
306
282
  let raw = ms === null ? '' : formatElapsed(ms)
307
283
  // `1000.00s` is wider than the cell; minutes fit it up to `99m 59s`.
308
284
  if (ms !== null && raw.length > TIME_COL) raw = formatElapsed(ms, true)
309
- return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('', raw, colors, { dim: true })
285
+ // The time is the row's fact (value level); the cells around it are meta.
286
+ return pad(TIME_COL - raw.length) + paint('value', raw, colors)
310
287
  }
311
288
 
312
289
  /**
@@ -314,14 +291,9 @@ function timeCell(ms: number | null, colors: ColorSupport): string {
314
291
  * An empty color string renders the text dim (used for the `miss` cache
315
292
  * word); empty text is a blank cell.
316
293
  */
317
- function cell(text: string, color: string, width: number, colors: ColorSupport): string {
318
- const painted =
319
- text === ''
320
- ? ''
321
- : color === ''
322
- ? paint('', text, colors, { dim: true })
323
- : paint(color, text, colors)
324
- return painted + ' '.repeat(Math.max(0, width - text.length))
294
+ function cell(text: string, color: Style | '', width: number, colors: ColorSupport): string {
295
+ const painted = text === '' ? '' : paint(color === '' ? 'muted' : color, text, colors)
296
+ return painted + pad(width - text.length)
325
297
  }
326
298
 
327
299
  /** The cache-shape glyph (task-shape for non-success). */
@@ -343,34 +315,34 @@ function glyphShape(o: TaskOutcome): string {
343
315
  }
344
316
 
345
317
  /** Task-axis word + color (a cache hit is a success). */
346
- function statusOf(o: TaskOutcome): { word: string; color: string } {
318
+ function statusOf(o: TaskOutcome): { word: string; color: Style } {
347
319
  switch (o.status) {
348
320
  case 'failed':
349
- return { word: 'failed', color: ERROR }
321
+ return { word: 'failed', color: 'error' }
350
322
  case 'skipped':
351
- return { word: 'skipped', color: WARN }
323
+ return { word: 'skipped', color: 'warn' }
352
324
  default:
353
- return { word: 'success', color: SUCCESS }
325
+ return { word: 'success', color: 'ok' }
354
326
  }
355
327
  }
356
328
 
357
329
  /** Cache-axis word + color. Empty for states that never reached the cache. */
358
- function cacheOf(o: TaskOutcome): { word: string; color: string } {
330
+ function cacheOf(o: TaskOutcome): { word: string; color: Style } {
359
331
  switch (o.status) {
360
332
  case 'success':
361
333
  case 'failed':
362
334
  // A task no cache answered for never consulted one: not a miss.
363
- return { word: ranNoCache(o) ? 'no-cache' : 'miss', color: '' } // dim
335
+ return { word: ranNoCache(o) ? 'no-cache' : 'miss', color: 'muted' } // dim
364
336
  case 'cache-hit':
365
337
  return o.restored === false
366
- ? { word: 'fresh', color: SUCCESS }
367
- : { word: 'local', color: LOCAL }
338
+ ? { word: 'fresh', color: 'ok' }
339
+ : { word: 'local', color: 'local' }
368
340
  case 'cache-hit-remote':
369
341
  return o.restored === false
370
- ? { word: 'fresh', color: SUCCESS }
371
- : { word: 'remote', color: REMOTE }
342
+ ? { word: 'fresh', color: 'ok' }
343
+ : { word: 'remote', color: 'remote' }
372
344
  default:
373
- return { word: '', color: '' }
345
+ return { word: '', color: 'muted' }
374
346
  }
375
347
  }
376
348
 
@@ -380,7 +352,7 @@ function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
380
352
  }
381
353
 
382
354
  /**
383
- * One reported task row on the list grid: `<glyph> <time> <usual> <waited>
355
+ * One reported task row on the list grid: `<glyph> <time> <usual>
384
356
  * <status> <cache> <cpu> <mem> <blocks> <worker> <name>`. `glyph` is
385
357
  * pre-painted; status/cache are raw text + color, padded to their
386
358
  * columns; `paintedId` carries identity coloring. `ms = null` blanks the
@@ -390,9 +362,9 @@ export function formatTaskRow(
390
362
  glyph: string,
391
363
  ms: number | null,
392
364
  status: string,
393
- statusColor: string,
365
+ statusColor: Style | '',
394
366
  cache: string,
395
- cacheColor: string,
367
+ cacheColor: Style | '',
396
368
  paintedId: string,
397
369
  colors: ColorSupport = NO_COLOR,
398
370
  cells: RowCells = {},
@@ -401,31 +373,40 @@ export function formatTaskRow(
401
373
  // `aborting` runs one into the cache cell, which is blank beside it.
402
374
  const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
403
375
  const usual = cells.usual ?? { text: '', painted: '' }
404
- const us = `${' '.repeat(Math.max(0, USUAL_COL - usual.text.length))}${usual.painted}`
405
- const waited = cells.waitedMs === undefined ? '' : formatElapsed(cells.waitedMs)
406
- const cpu = cells.cpu === undefined ? '' : `${Math.round(cells.cpu * 100)}%`
407
- const mem = cells.memBytes === undefined ? '' : formatSize(cells.memBytes)
376
+ const us = `${pad(USUAL_COL - usual.text.length)}${usual.painted}`
377
+ const cpu =
378
+ cells.cpu === undefined ? '' : cells.cpu === null ? UNKNOWN : `${Math.round(cells.cpu * 100)}%`
379
+ const mem =
380
+ cells.memBytes === undefined
381
+ ? ''
382
+ : cells.memBytes === null
383
+ ? UNKNOWN
384
+ : formatSize(cells.memBytes)
408
385
  const out = cells.outputBytes === undefined ? '' : formatSize(cells.outputBytes)
409
386
  const blocks = cells.blocks ?? ''
410
387
  const worker = workerName(cells.worker ?? '')
411
388
  return (
412
- ` ${glyph} ${timeCell(ms, colors)} ${us} ${right(waited, WAITED_COL, colors)} ${st} ${ca} ` +
389
+ ` ${glyph} ${timeCell(ms, colors)} ${us} ${st} ${ca} ` +
413
390
  `${right(cpu, CPU_COL, colors)} ${right(mem, MEM_COL, colors)} ${right(out, OUT_COL, colors)} ` +
414
- `${' '.repeat(Math.max(0, BLOCKED_COL - blocks.length))}${paint(WARN, blocks, colors)} ` +
415
- `${worker === '' ? '' : ui.inline(ui.place(worker), colors)}${' '.repeat(WORKER_COL - worker.length)} ${paintedId}`
391
+ `${pad(BLOCKED_COL - blocks.length)}${paint(blocks === '0' ? 'muted' : 'value', blocks, colors)} ` +
392
+ `${placeCell(worker, colors)}${pad(WORKER_COL - worker.length)} ${paintedId}`
416
393
  )
417
394
  }
418
395
 
419
- /** A row's cells past time, status and cache; each blank when absent. */
396
+ /**
397
+ * A cell vx cannot know: a peak no look saw, a task history never timed.
398
+ * Blank is a cell that does not apply (a hit ran nothing).
399
+ */
400
+ const UNKNOWN = '\u2014'
401
+
402
+ /** A row's cells past time, status and cache; each blank when absent, `—` when null. */
420
403
  export interface RowCells {
421
404
  /** After the time, pre-painted: `~18s` live, `+6.0s` / `−1.4s` after. */
422
405
  usual?: { text: string; painted: string }
423
- /** How long the task waited ready for a slot. */
424
- waitedMs?: number
425
406
  /** Cores in use (live) or on average (after): 1.8 prints `180%`. */
426
- cpu?: number
407
+ cpu?: number | null
427
408
  /** Resident memory now (live) or at its peak (after). */
428
- memBytes?: number
409
+ memBytes?: number | null
429
410
  /** The bytes of the task's outputs: what its save stored, or its hit restored. */
430
411
  outputBytes?: number
431
412
  /** How many tasks are downstream. */
@@ -435,7 +416,7 @@ export interface RowCells {
435
416
  }
436
417
 
437
418
  function right(text: string, width: number, colors: ColorSupport): string {
438
- return ' '.repeat(Math.max(0, width - text.length)) + paint('', text, colors, { dim: true })
419
+ return pad(width - text.length) + paint('muted', text, colors)
439
420
  }
440
421
 
441
422
  /** `900B`, `412MB`, `1.1GB`: one width for a size, whatever it is. */
@@ -449,19 +430,24 @@ export function formatSize(bytes: number): string {
449
430
  return gb < 10 ? `${gb.toFixed(1)}GB` : `${Math.round(gb)}GB`
450
431
  }
451
432
 
433
+ // A run names a handful of workers over thousands of rows: each painted
434
+ // once (escaping and painting per row was ~12 ms of a 9,603-task warm run).
435
+ const placeCells = new WeakMap<ColorSupport, Map<string, string>>()
436
+
437
+ function placeCell(worker: string, colors: ColorSupport): string {
438
+ if (worker === '') return ''
439
+ let byName = placeCells.get(colors)
440
+ if (byName === undefined) placeCells.set(colors, (byName = new Map()))
441
+ let painted = byName.get(worker)
442
+ if (painted === undefined) byName.set(worker, (painted = ui.inline(ui.place(worker), colors)))
443
+ return painted
444
+ }
445
+
452
446
  /** A worker's name in its 12 columns: a longer one ends in `…`. */
453
447
  function workerName(name: string): string {
454
448
  return name.length > WORKER_COL ? `${name.slice(0, WORKER_COL - 1)}\u2026` : name
455
449
  }
456
450
 
457
- /**
458
- * A wait worth a cell: under 50 ms the task started at once, the
459
- * scheduler's own tick and a sibling's spawn, not a full pool.
460
- */
461
- export function waited(ms: number | undefined): boolean {
462
- return ms !== undefined && ms >= 50
463
- }
464
-
465
451
  /** A task's average cores over its run: CPU time over wall time. */
466
452
  function coresOf(o: TaskOutcome): number | undefined {
467
453
  return o.cpuMs === undefined || o.durationMs <= 0 ? undefined : o.cpuMs / o.durationMs
@@ -469,19 +455,27 @@ function coresOf(o: TaskOutcome): number | undefined {
469
455
 
470
456
  /**
471
457
  * The results list's `±usual` cell: how far an executed task ran from its
472
- * usual time, `+6.0s` red or `−1.4s` green, only past a fifth of the usual
473
- * either way; blank with no usual time to compare.
458
+ * usual time, `+6.0s` red or `−1.4s` green past a fifth of the usual either
459
+ * way, dim within it; `—` (null) with no history for the task, blank
460
+ * (undefined) with no history to ask. A blank within a fifth read as no
461
+ * usual at all (2026-10-29).
474
462
  */
475
- function usualDelta(
463
+ export function usualCell(
476
464
  ms: number,
477
- usualMs: number | undefined,
465
+ usualMs: number | null | undefined,
478
466
  colors: ColorSupport = NO_COLOR,
479
467
  ): { text: string; painted: string } {
480
- if (usualMs === undefined || usualMs <= 0 || Math.abs(ms - usualMs) <= usualMs * 0.2)
481
- return { text: '', painted: '' }
468
+ if (usualMs === undefined) return { text: '', painted: '' }
469
+ if (usualMs === null || usualMs <= 0) return unknownUsual(colors)
482
470
  const d = ms - usualMs
483
- const text = `${d > 0 ? '+' : '\u2212'}${formatElapsed(Math.abs(d), true)}`
484
- return { text, painted: paint(d > 0 ? ERROR : SUCCESS, text, colors) }
471
+ const text = `${d >= 0 ? '+' : '\u2212'}${formatElapsed(Math.abs(d), true)}`
472
+ if (Math.abs(d) <= usualMs * 0.2) return { text, painted: paint('muted', text, colors) }
473
+ return { text, painted: paint(d > 0 ? 'error' : 'ok', text, colors) }
474
+ }
475
+
476
+ /** The `usual` cell of a task history has never timed. */
477
+ export function unknownUsual(colors: ColorSupport = NO_COLOR): { text: string; painted: string } {
478
+ return { text: UNKNOWN, painted: paint('muted', UNKNOWN, colors) }
485
479
  }
486
480
 
487
481
  /**
@@ -503,10 +497,137 @@ export function formatRunningHeader(colors: ColorSupport = NO_COLOR): string {
503
497
 
504
498
  function header(usual: string, colors: ColorSupport): string {
505
499
  const head =
506
- ` ${'time'.padStart(TIME_COL)} ${usual.padStart(USUAL_COL)} ${'waited'.padStart(WAITED_COL)} ` +
500
+ ` ${'time'.padStart(TIME_COL)} ${usual.padStart(USUAL_COL)} ` +
507
501
  `${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${'cpu'.padStart(CPU_COL)} ` +
508
502
  `${'mem'.padStart(MEM_COL)} ${'outputs'.padStart(OUT_COL)} ${'blocks'.padStart(BLOCKED_COL)} ${'worker'.padEnd(WORKER_COL)} task`
509
- return paint('', head, colors, { dim: true, underline: true })
503
+ return paint('label', head, colors)
504
+ }
505
+
506
+ /**
507
+ * The task list on its grid, under its header: the run's results panel's
508
+ * body, and `vx last`'s, so a replay reads as the run did.
509
+ */
510
+ export function taskList(rows: readonly ((colors: ColorSupport) => string)[]): ui.Block {
511
+ return { lines: (c) => [formatListHeader(c), ...rows.map((row) => row(c))] }
512
+ }
513
+
514
+ /** What history keeps of a task's row: `vx last` draws it again from this. */
515
+ export interface RecordedRow {
516
+ project: string
517
+ task: string
518
+ status: string
519
+ durationMs: number
520
+ restored: boolean | null
521
+ cached: boolean | null
522
+ cpuMs: number | null
523
+ peakRssBytes: number | null
524
+ placement: string | null
525
+ outputBytes: number | null
526
+ blocks: number | null
527
+ }
528
+
529
+ /**
530
+ * A recorded task's row, as the run printed it; `note` (dim) after its id.
531
+ * `usualMs` is the task's usual time now, from history (`null`: never timed).
532
+ */
533
+ export function formatRecordedRow(
534
+ r: RecordedRow,
535
+ colors: ColorSupport = NO_COLOR,
536
+ note = '',
537
+ usualMs?: number | null,
538
+ ): string {
539
+ // The row functions read a node's names and whether it declared a cache.
540
+ const node = {
541
+ id: `${r.project}#${r.task}`,
542
+ projectName: r.project,
543
+ taskName: r.task,
544
+ config: r.cached === false ? {} : { cache: true },
545
+ } as unknown as TaskNode
546
+ const o = {
547
+ node,
548
+ status: r.status,
549
+ durationMs: r.durationMs,
550
+ ...(r.restored !== null ? { restored: r.restored } : {}),
551
+ ...(r.cpuMs !== null ? { cpuMs: r.cpuMs } : {}),
552
+ ...(r.peakRssBytes !== null ? { peakRssBytes: r.peakRssBytes } : {}),
553
+ ...(r.placement !== null ? { where: r.placement } : {}),
554
+ ...(r.outputBytes !== null ? { outputBytes: r.outputBytes } : {}),
555
+ } as TaskOutcome
556
+ const blocks = r.blocks ?? undefined
557
+ const row =
558
+ o.status === 'skipped'
559
+ ? formatTaskSkippedLine(node, colors, undefined, blocks)
560
+ : o.status === 'aborted'
561
+ ? formatTaskAbortedLine(node, o, colors, blocks)
562
+ : isCacheHit(o.status)
563
+ ? formatTaskHitLine(node, o, colors, false, blocks)
564
+ : formatTaskExecutedLine(node, o, colors, false, blocks, usualMs)
565
+ return note === '' ? row : `${row} ${paint('muted', note, colors)}`
566
+ }
567
+
568
+ /**
569
+ * A running task's usual duration, fixed while the elapsed time counts up
570
+ * toward it (2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
571
+ * column below 100 minutes.
572
+ */
573
+ export function formatExpected(ms: number): string {
574
+ // The tier by the rounded value: 999.6 ms printed `~1000ms` (item 1034's class).
575
+ if (Math.round(ms) < 1000) return `~${Math.round(ms)}ms`
576
+ if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
577
+ const s = Math.round(ms / 1000)
578
+ if (s < 60) return `~${s}s`
579
+ return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
580
+ }
581
+
582
+ /** A task `--dry` lists: what the run would do with it, on the running list's grid. */
583
+ export interface PlannedRow {
584
+ node: TaskNode
585
+ glyph: string
586
+ status: string
587
+ /** The glyph's and the status word's hue. */
588
+ tone: Style
589
+ cache: string
590
+ cacheTone: Style | ''
591
+ /** Its usual time, from history. */
592
+ usualMs?: number
593
+ /** What a remote hit would download. */
594
+ outputBytes?: number
595
+ /** How many tasks are downstream of it. */
596
+ blocks?: number
597
+ worker: string
598
+ /** Dim, after the id: its description, why it is affected. */
599
+ note: string
600
+ }
601
+
602
+ /** `--dry`'s list: the running list's header and grid, so a plan reads as the run would. */
603
+ export function planList(rows: readonly PlannedRow[]): ui.Block {
604
+ return {
605
+ lines: (c) => [
606
+ formatRunningHeader(c),
607
+ ...rows.map((r) => {
608
+ const usual = r.usualMs === undefined ? undefined : formatExpected(r.usualMs)
609
+ const row = formatTaskRow(
610
+ paint(r.tone, r.glyph, c),
611
+ null,
612
+ r.status,
613
+ r.tone,
614
+ r.cache,
615
+ r.cacheTone,
616
+ paintTaskId(r.node, c),
617
+ c,
618
+ {
619
+ ...(usual !== undefined
620
+ ? { usual: { text: usual, painted: paint('muted', usual, c) } }
621
+ : {}),
622
+ ...(r.outputBytes !== undefined ? { outputBytes: r.outputBytes } : {}),
623
+ ...(r.blocks !== undefined ? { blocks: String(r.blocks) } : {}),
624
+ worker: r.worker,
625
+ },
626
+ )
627
+ return r.note === '' ? row : `${row} ${paint('muted', r.note, c)}`
628
+ }),
629
+ ],
630
+ }
510
631
  }
511
632
 
512
633
  /** Compact one-liner for a cache hit with nothing to replay. */
@@ -532,8 +653,8 @@ export function formatTaskHashLine(
532
653
  o: TaskOutcome,
533
654
  colors: ColorSupport = NO_COLOR,
534
655
  ): string {
535
- const color = o.status === 'aborted' ? ABORTED : statusOf(o).color
536
- const key = o.hash === undefined ? '' : ` ${paint('', o.hash, colors, { dim: true })}`
656
+ const color = o.status === 'aborted' ? 'aborted' : statusOf(o).color
657
+ const key = o.hash === undefined ? '' : ` ${paint('muted', o.hash, colors)}`
537
658
  return `${paint(color, outcomeWord(o), colors)} ${paintTaskId(node, colors)}${key}`
538
659
  }
539
660
 
@@ -544,21 +665,32 @@ export function formatTaskExecutedLine(
544
665
  colors: ColorSupport = NO_COLOR,
545
666
  onPath = false,
546
667
  blocks?: number,
547
- usualMs?: number,
668
+ usualMs?: number | null,
548
669
  ): string {
549
670
  return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, {
550
671
  ...executedCells(o, blocks),
551
- usual: usualDelta(o.durationMs, usualMs, colors),
672
+ // A hit's time is a restore's, not the task's: nothing to compare.
673
+ ...(o.status === 'success' || o.status === 'failed'
674
+ ? { usual: usualCell(o.durationMs, usualMs, colors) }
675
+ : {}),
552
676
  })
553
677
  }
554
678
 
555
- /** An executed task's cells: where and how long it waited, its average cores and peak memory. */
679
+ /**
680
+ * An executed task's cells: where it ran, its average cores and peak
681
+ * memory; a task that ran with either unread shows `—`, a hit nothing.
682
+ */
556
683
  export function executedCells(o: TaskOutcome, blocks: number | undefined): RowCells {
557
684
  const cores = coresOf(o)
685
+ const ran = o.status === 'success' || o.status === 'failed'
558
686
  return {
559
687
  ...ranCells(o, blocks),
560
- ...(cores !== undefined ? { cpu: cores } : {}),
561
- ...(o.peakRssBytes !== undefined ? { memBytes: o.peakRssBytes } : {}),
688
+ ...(cores !== undefined ? { cpu: cores } : ran ? { cpu: null } : {}),
689
+ ...(o.peakRssBytes !== undefined
690
+ ? { memBytes: o.peakRssBytes }
691
+ : ran
692
+ ? { memBytes: null }
693
+ : {}),
562
694
  }
563
695
  }
564
696
 
@@ -568,7 +700,6 @@ function ranCells(o: TaskOutcome, blocks: number | undefined): RowCells {
568
700
  // A skip reached no machine.
569
701
  ...(o.status === 'skipped' ? {} : { worker: o.where ?? 'local' }),
570
702
  ...(blocks !== undefined ? { blocks: String(blocks) } : {}),
571
- ...(waited(o.queuedMs) ? { waitedMs: o.queuedMs! } : {}),
572
703
  ...(o.outputBytes !== undefined ? { outputBytes: o.outputBytes } : {}),
573
704
  }
574
705
  }
@@ -603,7 +734,8 @@ function formatOutcomeRow(
603
734
 
604
735
  /** `critical path`: the row's task held the run's end while others ran beside it. */
605
736
  export function pathNote(colors: ColorSupport = NO_COLOR): string {
606
- return ` ${paint(ERROR, 'critical path', colors)}`
737
+ // A fact worth knowing, not a failure: never red.
738
+ return ` ${paint('info', 'critical path', colors)}`
607
739
  }
608
740
 
609
741
  /** Where an executor ran the task, when not here, right after its id: `@small#2` (2026-10-21). */
@@ -616,7 +748,7 @@ function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
616
748
  * on the row, as a worker's name has no width to pad to (2026-10-21).
617
749
  */
618
750
  function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
619
- return `${paint('', ' \u00b7', colors, { dim: true })} ${ui.inline(ui.place(where), colors)}`
751
+ return `${paint('muted', ' \u00b7', colors)} ${ui.inline(ui.place(where), colors)}`
620
752
  }
621
753
 
622
754
  /**
@@ -634,7 +766,7 @@ export function flakyNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): stri
634
766
  }
635
767
  if ((o.attempts ?? 1) > 1) said.push(`${o.attempts} attempts`)
636
768
  const text = said.length === 0 ? 'flaky' : `flaky - ${said.join(' \u00b7 ')}`
637
- return ` ${paint('', text, colors, { dim: true })}`
769
+ return ` ${paint('muted', text, colors)}`
638
770
  }
639
771
 
640
772
  /**
@@ -649,13 +781,13 @@ export function formatTaskSkippedLine(
649
781
  blocks?: number,
650
782
  ): string {
651
783
  const why = skippedReason(skip)
652
- const suffix = why === '' ? '' : ` ${paint('', `• ${why}`, colors, { dim: true })}`
784
+ const suffix = why === '' ? '' : ` ${paint('muted', `• ${why}`, colors)}`
653
785
  return (
654
786
  formatTaskRow(
655
- paint(WARN, '\u2298', colors),
787
+ paint('warn', '\u2298', colors),
656
788
  null,
657
789
  'skipped',
658
- WARN,
790
+ 'warn',
659
791
  '',
660
792
  '',
661
793
  paintTaskId(node, colors),
@@ -680,7 +812,7 @@ export function formatTaskAbortedLine(
680
812
  const bl: RowCells = blocks === undefined ? {} : { blocks: String(blocks) }
681
813
  if (neverStarted(o)) {
682
814
  return formatTaskRow(
683
- paint('', '\u25cc', colors, { dim: true }),
815
+ paint('muted', '\u25cc', colors),
684
816
  null,
685
817
  'not run',
686
818
  '',
@@ -692,10 +824,10 @@ export function formatTaskAbortedLine(
692
824
  )
693
825
  }
694
826
  return formatTaskRow(
695
- paint(ABORTED, '\u2717', colors),
827
+ paint('aborted', '\u2717', colors),
696
828
  o.durationMs,
697
829
  'aborted',
698
- ABORTED,
830
+ 'aborted',
699
831
  '',
700
832
  '',
701
833
  id,
@@ -716,7 +848,7 @@ export function formatFrameOpen(
716
848
  colors: ColorSupport = NO_COLOR,
717
849
  forwardArgs: readonly string[] = [],
718
850
  ): string {
719
- const corner = (t: string) => paint('', t, colors, { dim: true })
851
+ const corner = (t: string) => paint('muted', t, colors)
720
852
  const cmd = shownCommand(node, forwardArgs)
721
853
  const mark = persistentMark(node, colors)
722
854
  return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
@@ -748,7 +880,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
748
880
  // glob matches and the user decides.
749
881
  const fix = outcome.sandboxFix
750
882
  const line = fix === undefined ? undefined : sandboxFixLine(fix)
751
- const dim = (text: string): string => paint('', text, colors, { dim: true })
883
+ const dim = (text: string): string => paint('muted', text, colors)
752
884
  const note =
753
885
  fix === undefined
754
886
  ? []
@@ -773,7 +905,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
773
905
  // vx's own notes (`vx: …`) explain a failure; they are not denials.
774
906
  const denials = body.filter((l) => !l.startsWith('vx: ')).length
775
907
  return [
776
- sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', ERROR, colors),
908
+ sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', 'error', colors),
777
909
  '',
778
910
  ...body,
779
911
  '',
@@ -786,7 +918,7 @@ export function formatFrameClose(
786
918
  outcome: TaskOutcome,
787
919
  colors: ColorSupport = NO_COLOR,
788
920
  ): string {
789
- const corner = (t: string) => paint('', t, colors, { dim: true })
921
+ const corner = (t: string) => paint('muted', t, colors)
790
922
  const mark = persistentMark(node, colors)
791
923
  // A persistent task's child keeps running past this "ready" outcome,
792
924
  // so the close reads `running` (accent), not `success` — the ▸ marks
@@ -810,11 +942,11 @@ function shownCommand(node: TaskNode, forwardArgs: readonly string[]): string {
810
942
  }
811
943
 
812
944
  function persistentMark(node: TaskNode, colors: ColorSupport): string {
813
- return node.config.exec?.persistent !== undefined ? `${paint(ACCENT, '▸', colors)} ` : ''
945
+ return node.config.exec?.persistent !== undefined ? `${paint('info', '▸', colors)} ` : ''
814
946
  }
815
947
 
816
948
  function runningFooter(o: TaskOutcome, colors: ColorSupport): string {
817
- return ` ${paint('', `(${formatElapsed(o.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
949
+ return ` ${paint('muted', `(${formatElapsed(o.durationMs)})`, colors)} ${paint('info', 'running', colors)}`
818
950
  }
819
951
 
820
952
  /**
@@ -841,8 +973,8 @@ export function formatPersistentTailBlock(
841
973
  const stderr = body.stderr ?? ''
842
974
  if (stdout.trim().length === 0 && stderr.trim().length === 0) return ''
843
975
  const lines: string[] = [formatFrameOpen(node, colors, forwardArgs)]
844
- pushStreamSection(lines, stdout, 'STDOUT (since ready)', SUCCESS, dropped.stdout ?? 0, colors)
845
- pushStreamSection(lines, stderr, 'STDERR (since ready)', ERROR, dropped.stderr ?? 0, colors)
976
+ pushStreamSection(lines, stdout, 'STDOUT (since ready)', 'ok', dropped.stdout ?? 0, colors)
977
+ pushStreamSection(lines, stderr, 'STDERR (since ready)', 'error', dropped.stderr ?? 0, colors)
846
978
  lines.push(formatFrameClose(node, outcome, colors))
847
979
  return lines.join('\n') + '\n'
848
980
  }
@@ -853,40 +985,16 @@ export function formatPersistentTailBlock(
853
985
  * logs read apart. `text` holds whole lines, its last newline cut off.
854
986
  */
855
987
  export function formatKeptLines(node: TaskNode, text: string, colors: ColorSupport): string {
856
- const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
988
+ const prefix = `${paintTaskId(node, colors)} ${paint('muted', '│', colors)} `
857
989
  return text
858
990
  .split('\n')
859
991
  .map((line) => {
860
992
  const shown = lastRedraw(line)
861
- return `${prefix}${shown}${leavesStyle(shown) ? RESET : ''}\n`
993
+ return `${prefix}${shown}${leavesStyle(shown) ? STYLE_END : ''}\n`
862
994
  })
863
995
  .join('')
864
996
  }
865
997
 
866
- // eslint-disable-next-line no-control-regex -- ESC is the point
867
- const LINE_RESET = /\r|\x1b\[[12]K|\x1b\[[01]?G/g
868
- // eslint-disable-next-line no-control-regex -- ESC is the point
869
- const TRAILING_RESETS = /(?:\r|\x1b\[[12]K|\x1b\[[01]?G)+$/
870
- // eslint-disable-next-line no-control-regex -- ESC is the point
871
- const SGR = /\x1b\[[0-9;]*m/g
872
-
873
- /**
874
- * What a line shows once its redraws have played: the text after its last
875
- * `\r`, line erase (`CSI 1K`/`2K`) or move to column 1 (`CSI G`), with the
876
- * colours set before it. Written after an id prefix, a dev server's redraw
877
- * (`\x1b[2K\x1b[1Gbuilt in 30ms`, a `10%\r100%` progress line) erased or
878
- * overwrote the prefix on a terminal. A reset that ends the line, as in
879
- * CRLF, redraws nothing.
880
- */
881
- function lastRedraw(line: string): string {
882
- if (!line.includes('\r') && !line.includes('\x1b')) return line
883
- const text = line.replace(TRAILING_RESETS, '')
884
- let cut = -1
885
- for (const m of text.matchAll(LINE_RESET)) cut = m.index + m[0].length
886
- if (cut === -1) return text
887
- return (text.slice(0, cut).match(SGR) ?? []).join('') + text.slice(cut)
888
- }
889
-
890
998
  /**
891
999
  * Between the requested task's frame and the summary footer, list the
892
1000
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -900,28 +1008,28 @@ export function formatPersistentList(
900
1008
  ): string[] {
901
1009
  return nodes.map(
902
1010
  (n) =>
903
- ` ${paint(ACCENT, '▸', colors)} ${paintTaskId(n, colors)} ${paint('', 'running', colors, { dim: true })}`,
1011
+ ` ${paint('info', '▸', colors)} ${paintTaskId(n, colors)} ${paint('muted', 'running', colors)}`,
904
1012
  )
905
1013
  }
906
1014
 
907
1015
  function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
908
1016
  const shortHash = o.hash ? o.hash.slice(0, 8) : ''
909
- const dim = (s: string) => paint('', s, colors, { dim: true })
1017
+ const dim = (s: string) => paint('muted', s, colors)
910
1018
  const word = outcomeLabel(o)
911
1019
  switch (o.status) {
912
1020
  case 'cache-hit':
913
- return `${paint(SUCCESS, word, colors)} ${dim(`• ${shortHash}`)}`
1021
+ return `${paint('ok', word, colors)} ${dim(`• ${shortHash}`)}`
914
1022
  case 'cache-hit-remote':
915
1023
  // A remote restore is the one hit painted accent: bytes came off the wire.
916
- return `${paint(o.restored === false ? SUCCESS : ACCENT, word, colors)} ${dim(`• ${shortHash}`)}`
1024
+ return `${paint(o.restored === false ? 'ok' : 'info', word, colors)} ${dim(`• ${shortHash}`)}`
917
1025
  case 'failed':
918
1026
  // The command lives in its own `├─ command` section; the header
919
1027
  // carries the outcome like every other status.
920
- return paint(ERROR, word, colors, { bold: true })
1028
+ return paint('error', word, colors, true)
921
1029
  case 'skipped':
922
- return paint(WARN, word, colors)
1030
+ return paint('warn', word, colors)
923
1031
  case 'aborted':
924
- return paint(ABORTED, word, colors)
1032
+ return paint('aborted', word, colors)
925
1033
  default:
926
1034
  return dim(word)
927
1035
  }
@@ -935,7 +1043,7 @@ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
935
1043
  // saved" on the strength of it. The stored time lives on
936
1044
  // `TaskOutcome.storedDurationMs`.) Status differs by outcome — see
937
1045
  // formatStatusTag.
938
- const dur = paint('', `(${formatElapsed(o.durationMs)})`, colors, { dim: true })
1046
+ const dur = paint('muted', `(${formatElapsed(o.durationMs)})`, colors)
939
1047
  const tag = formatStatusTag(o, colors)
940
1048
  return ` ${dur} ${tag}${flakyNote(o, colors)}`
941
1049
  }
@@ -944,13 +1052,13 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
944
1052
  const word = outcomeLabel(o)
945
1053
  switch (o.status) {
946
1054
  case 'failed':
947
- return paint(ERROR, word, colors, { bold: true })
1055
+ return paint('error', word, colors, true)
948
1056
  case 'skipped':
949
- return paint(WARN, word, colors)
1057
+ return paint('warn', word, colors)
950
1058
  case 'aborted':
951
- return paint(ABORTED, word, colors)
1059
+ return paint('aborted', word, colors)
952
1060
  default:
953
- return paint('', word, colors, { dim: true })
1061
+ return paint('muted', word, colors)
954
1062
  }
955
1063
  }
956
1064