@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
@@ -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(
@@ -292,16 +261,20 @@ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolea
292
261
  export const TIME_COL = 7 // "0ms" … "999.99s", then "16m 40s" … "99m 59s"
293
262
  const STATUS_COL = 7 // "success" / "running" / "skipped" / "aborted" / "not run"
294
263
  const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
295
- const BLOCKED_COL = 7 // the live list's count of tasks a running one holds back
296
- /** The list header's colour: violet-400, the summary rule's first stop. */
297
- const HEADER = '#a78bfa'
264
+ const BLOCKED_COL = 7 // `blocks`: how many tasks are downstream of the row's
265
+ const USUAL_COL = 7 // "~18s" live, "+6.0s" / "−1m 05s" after
266
+ const CPU_COL = 5 // "180%", "1200%"
267
+ const MEM_COL = 6 // "412MB", "1.1GB", "1023MB"
268
+ const OUT_COL = 7 // `outputs`: "12MB", "900B"
269
+ const WORKER_COL = 12 // `local`, or a plugin's name for the machine, cut at 12
298
270
 
299
271
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
300
272
  function timeCell(ms: number | null, colors: ColorSupport): string {
301
273
  let raw = ms === null ? '' : formatElapsed(ms)
302
274
  // `1000.00s` is wider than the cell; minutes fit it up to `99m 59s`.
303
275
  if (ms !== null && raw.length > TIME_COL) raw = formatElapsed(ms, true)
304
- return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('', raw, colors, { dim: true })
276
+ // The time is the row's fact (value level); the cells around it are meta.
277
+ return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('value', raw, colors)
305
278
  }
306
279
 
307
280
  /**
@@ -309,13 +282,8 @@ function timeCell(ms: number | null, colors: ColorSupport): string {
309
282
  * An empty color string renders the text dim (used for the `miss` cache
310
283
  * word); empty text is a blank cell.
311
284
  */
312
- function cell(text: string, color: string, width: number, colors: ColorSupport): string {
313
- const painted =
314
- text === ''
315
- ? ''
316
- : color === ''
317
- ? paint('', text, colors, { dim: true })
318
- : paint(color, text, colors)
285
+ function cell(text: string, color: Style | '', width: number, colors: ColorSupport): string {
286
+ const painted = text === '' ? '' : paint(color === '' ? 'muted' : color, text, colors)
319
287
  return painted + ' '.repeat(Math.max(0, width - text.length))
320
288
  }
321
289
 
@@ -338,34 +306,34 @@ function glyphShape(o: TaskOutcome): string {
338
306
  }
339
307
 
340
308
  /** Task-axis word + color (a cache hit is a success). */
341
- function statusOf(o: TaskOutcome): { word: string; color: string } {
309
+ function statusOf(o: TaskOutcome): { word: string; color: Style } {
342
310
  switch (o.status) {
343
311
  case 'failed':
344
- return { word: 'failed', color: ERROR }
312
+ return { word: 'failed', color: 'error' }
345
313
  case 'skipped':
346
- return { word: 'skipped', color: WARN }
314
+ return { word: 'skipped', color: 'warn' }
347
315
  default:
348
- return { word: 'success', color: SUCCESS }
316
+ return { word: 'success', color: 'ok' }
349
317
  }
350
318
  }
351
319
 
352
320
  /** Cache-axis word + color. Empty for states that never reached the cache. */
353
- function cacheOf(o: TaskOutcome): { word: string; color: string } {
321
+ function cacheOf(o: TaskOutcome): { word: string; color: Style } {
354
322
  switch (o.status) {
355
323
  case 'success':
356
324
  case 'failed':
357
325
  // A task no cache answered for never consulted one: not a miss.
358
- return { word: ranNoCache(o) ? 'no-cache' : 'miss', color: '' } // dim
326
+ return { word: ranNoCache(o) ? 'no-cache' : 'miss', color: 'muted' } // dim
359
327
  case 'cache-hit':
360
328
  return o.restored === false
361
- ? { word: 'fresh', color: SUCCESS }
362
- : { word: 'local', color: LOCAL }
329
+ ? { word: 'fresh', color: 'ok' }
330
+ : { word: 'local', color: 'local' }
363
331
  case 'cache-hit-remote':
364
332
  return o.restored === false
365
- ? { word: 'fresh', color: SUCCESS }
366
- : { word: 'remote', color: REMOTE }
333
+ ? { word: 'fresh', color: 'ok' }
334
+ : { word: 'remote', color: 'remote' }
367
335
  default:
368
- return { word: '', color: '' }
336
+ return { word: '', color: 'muted' }
369
337
  }
370
338
  }
371
339
 
@@ -375,46 +343,282 @@ function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
375
343
  }
376
344
 
377
345
  /**
378
- * One reported task row: `<glyph> <time> <status> <cache> <name>`.
379
- * `glyph` is pre-painted; status/cache are raw text + color, padded to
380
- * their columns; `paintedId` carries identity coloring. `ms = null`
381
- * blanks the time (e.g. a skip never ran); `cache = ''` blanks it.
346
+ * One reported task row on the list grid: `<glyph> <time> <usual>
347
+ * <status> <cache> <cpu> <mem> <blocks> <worker> <name>`. `glyph` is
348
+ * pre-painted; status/cache are raw text + color, padded to their
349
+ * columns; `paintedId` carries identity coloring. `ms = null` blanks the
350
+ * time (a skip never ran); `cache = ''` and an absent cell blank theirs.
382
351
  */
383
352
  export function formatTaskRow(
384
353
  glyph: string,
385
354
  ms: number | null,
386
355
  status: string,
387
- statusColor: string,
356
+ statusColor: Style | '',
388
357
  cache: string,
389
- cacheColor: string,
358
+ cacheColor: Style | '',
390
359
  paintedId: string,
391
360
  colors: ColorSupport = NO_COLOR,
392
- /** The live list's `blocked` cell, right-aligned before the id; absent off that list. */
393
- blocked?: string,
361
+ cells: RowCells = {},
394
362
  ): string {
395
363
  const st = cell(status, statusColor, STATUS_COL, colors)
396
364
  // `aborting` runs one into the cache cell, which is blank beside it.
397
365
  const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
398
- const bl =
399
- blocked === undefined
366
+ const usual = cells.usual ?? { text: '', painted: '' }
367
+ const us = `${' '.repeat(Math.max(0, USUAL_COL - usual.text.length))}${usual.painted}`
368
+ const cpu =
369
+ cells.cpu === undefined ? '' : cells.cpu === null ? UNKNOWN : `${Math.round(cells.cpu * 100)}%`
370
+ const mem =
371
+ cells.memBytes === undefined
400
372
  ? ''
401
- : `${' '.repeat(Math.max(0, BLOCKED_COL - blocked.length))}${paint(WARN, blocked, colors)} `
402
- return ` ${glyph} ${timeCell(ms, colors)} ${st} ${ca} ${bl}${paintedId}`
373
+ : cells.memBytes === null
374
+ ? UNKNOWN
375
+ : formatSize(cells.memBytes)
376
+ const out = cells.outputBytes === undefined ? '' : formatSize(cells.outputBytes)
377
+ const blocks = cells.blocks ?? ''
378
+ const worker = workerName(cells.worker ?? '')
379
+ return (
380
+ ` ${glyph} ${timeCell(ms, colors)} ${us} ${st} ${ca} ` +
381
+ `${right(cpu, CPU_COL, colors)} ${right(mem, MEM_COL, colors)} ${right(out, OUT_COL, colors)} ` +
382
+ `${' '.repeat(Math.max(0, BLOCKED_COL - blocks.length))}${paint(blocks === '0' ? 'muted' : 'value', blocks, colors)} ` +
383
+ `${placeCell(worker, colors)}${' '.repeat(WORKER_COL - worker.length)} ${paintedId}`
384
+ )
385
+ }
386
+
387
+ /**
388
+ * A cell vx cannot know: a peak no look saw, a task history never timed.
389
+ * Blank is a cell that does not apply (a hit ran nothing).
390
+ */
391
+ const UNKNOWN = '\u2014'
392
+
393
+ /** A row's cells past time, status and cache; each blank when absent, `—` when null. */
394
+ export interface RowCells {
395
+ /** After the time, pre-painted: `~18s` live, `+6.0s` / `−1.4s` after. */
396
+ usual?: { text: string; painted: string }
397
+ /** Cores in use (live) or on average (after): 1.8 prints `180%`. */
398
+ cpu?: number | null
399
+ /** Resident memory now (live) or at its peak (after). */
400
+ memBytes?: number | null
401
+ /** The bytes of the task's outputs: what its save stored, or its hit restored. */
402
+ outputBytes?: number
403
+ /** How many tasks are downstream. */
404
+ blocks?: string
405
+ /** The machine it runs or ran on: `local`, or the executor's name for it. */
406
+ worker?: string
407
+ }
408
+
409
+ function right(text: string, width: number, colors: ColorSupport): string {
410
+ return ' '.repeat(Math.max(0, width - text.length)) + paint('muted', text, colors)
411
+ }
412
+
413
+ /** `900B`, `412MB`, `1.1GB`: one width for a size, whatever it is. */
414
+ export function formatSize(bytes: number): string {
415
+ if (bytes < 1024) return `${bytes}B`
416
+ const mb = bytes / 1024 / 1024
417
+ if (mb < 1) return `${Math.round(bytes / 1024)}KB`
418
+ if (mb < 9.95) return `${mb.toFixed(1)}MB`
419
+ if (mb < 1000) return `${Math.round(mb)}MB`
420
+ const gb = mb / 1024
421
+ return gb < 10 ? `${gb.toFixed(1)}GB` : `${Math.round(gb)}GB`
422
+ }
423
+
424
+ // A run names a handful of workers over thousands of rows: each painted
425
+ // once (escaping and painting per row was ~12 ms of a 9,603-task warm run).
426
+ const placeCells = new WeakMap<ColorSupport, Map<string, string>>()
427
+
428
+ function placeCell(worker: string, colors: ColorSupport): string {
429
+ if (worker === '') return ''
430
+ let byName = placeCells.get(colors)
431
+ if (byName === undefined) placeCells.set(colors, (byName = new Map()))
432
+ let painted = byName.get(worker)
433
+ if (painted === undefined) byName.set(worker, (painted = ui.inline(ui.place(worker), colors)))
434
+ return painted
435
+ }
436
+
437
+ /** A worker's name in its 12 columns: a longer one ends in `…`. */
438
+ function workerName(name: string): string {
439
+ return name.length > WORKER_COL ? `${name.slice(0, WORKER_COL - 1)}\u2026` : name
440
+ }
441
+
442
+ /** A task's average cores over its run: CPU time over wall time. */
443
+ function coresOf(o: TaskOutcome): number | undefined {
444
+ return o.cpuMs === undefined || o.durationMs <= 0 ? undefined : o.cpuMs / o.durationMs
403
445
  }
404
446
 
405
447
  /**
406
- * The header over a task list, on the row grid: bold and coloured, so it
407
- * reads apart from the rows (dim, it was lost among them). `worker` when an
408
- * executor places tasks: the name after the id's dot. `blocked` on the live
409
- * list, whose rows count the tasks each running one holds back.
448
+ * The results list's `±usual` cell: how far an executed task ran from its
449
+ * usual time, `+6.0s` red or `−1.4s` green past a fifth of the usual either
450
+ * way, dim within it; `—` (null) with no history for the task, blank
451
+ * (undefined) with no history to ask. A blank within a fifth read as no
452
+ * usual at all (2026-10-29).
410
453
  */
411
- export function formatListHeader(
412
- worker: boolean,
454
+ export function usualCell(
455
+ ms: number,
456
+ usualMs: number | null | undefined,
413
457
  colors: ColorSupport = NO_COLOR,
414
- blocked = false,
458
+ ): { text: string; painted: string } {
459
+ if (usualMs === undefined) return { text: '', painted: '' }
460
+ if (usualMs === null || usualMs <= 0) return unknownUsual(colors)
461
+ const d = ms - usualMs
462
+ const text = `${d >= 0 ? '+' : '\u2212'}${formatElapsed(Math.abs(d), true)}`
463
+ if (Math.abs(d) <= usualMs * 0.2) return { text, painted: paint('muted', text, colors) }
464
+ return { text, painted: paint(d > 0 ? 'error' : 'ok', text, colors) }
465
+ }
466
+
467
+ /** The `usual` cell of a task history has never timed. */
468
+ export function unknownUsual(colors: ColorSupport = NO_COLOR): { text: string; painted: string } {
469
+ return { text: UNKNOWN, painted: paint('muted', UNKNOWN, colors) }
470
+ }
471
+
472
+ /**
473
+ * The header over a task list, on the row grid: dim and underlined across
474
+ * the whole line, gaps included, straight over its rows (bold in a hue, it
475
+ * blended into rows of the same hues).
476
+ */
477
+ export function formatListHeader(colors: ColorSupport = NO_COLOR): string {
478
+ return header('\u00b1usual', colors)
479
+ }
480
+
481
+ /**
482
+ * The live list's header: the usual duration where the results list has
483
+ * how far a task ran from it; the other columns are the results'.
484
+ */
485
+ export function formatRunningHeader(colors: ColorSupport = NO_COLOR): string {
486
+ return header('usual', colors)
487
+ }
488
+
489
+ function header(usual: string, colors: ColorSupport): string {
490
+ const head =
491
+ ` ${'time'.padStart(TIME_COL)} ${usual.padStart(USUAL_COL)} ` +
492
+ `${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${'cpu'.padStart(CPU_COL)} ` +
493
+ `${'mem'.padStart(MEM_COL)} ${'outputs'.padStart(OUT_COL)} ${'blocks'.padStart(BLOCKED_COL)} ${'worker'.padEnd(WORKER_COL)} task`
494
+ return paint('label', head, colors)
495
+ }
496
+
497
+ /**
498
+ * The task list on its grid, under its header: the run's results panel's
499
+ * body, and `vx last`'s, so a replay reads as the run did.
500
+ */
501
+ export function taskList(rows: readonly ((colors: ColorSupport) => string)[]): ui.Block {
502
+ return { lines: (c) => [formatListHeader(c), ...rows.map((row) => row(c))] }
503
+ }
504
+
505
+ /** What history keeps of a task's row: `vx last` draws it again from this. */
506
+ export interface RecordedRow {
507
+ project: string
508
+ task: string
509
+ status: string
510
+ durationMs: number
511
+ restored: boolean | null
512
+ cached: boolean | null
513
+ cpuMs: number | null
514
+ peakRssBytes: number | null
515
+ placement: string | null
516
+ outputBytes: number | null
517
+ blocks: number | null
518
+ }
519
+
520
+ /**
521
+ * A recorded task's row, as the run printed it; `note` (dim) after its id.
522
+ * `usualMs` is the task's usual time now, from history (`null`: never timed).
523
+ */
524
+ export function formatRecordedRow(
525
+ r: RecordedRow,
526
+ colors: ColorSupport = NO_COLOR,
527
+ note = '',
528
+ usualMs?: number | null,
415
529
  ): string {
416
- const head = ` ${'time'.padStart(TIME_COL)} ${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${blocked ? `${'blocked'.padStart(BLOCKED_COL)} ` : ''}task${worker ? ' \u00b7 worker' : ''}`
417
- return paint(HEADER, head, colors, { bold: true })
530
+ // The row functions read a node's names and whether it declared a cache.
531
+ const node = {
532
+ id: `${r.project}#${r.task}`,
533
+ projectName: r.project,
534
+ taskName: r.task,
535
+ config: r.cached === false ? {} : { cache: {} },
536
+ } as unknown as TaskNode
537
+ const o = {
538
+ node,
539
+ status: r.status,
540
+ durationMs: r.durationMs,
541
+ ...(r.restored !== null ? { restored: r.restored } : {}),
542
+ ...(r.cpuMs !== null ? { cpuMs: r.cpuMs } : {}),
543
+ ...(r.peakRssBytes !== null ? { peakRssBytes: r.peakRssBytes } : {}),
544
+ ...(r.placement !== null ? { where: r.placement } : {}),
545
+ ...(r.outputBytes !== null ? { outputBytes: r.outputBytes } : {}),
546
+ } as TaskOutcome
547
+ const blocks = r.blocks ?? undefined
548
+ const row =
549
+ o.status === 'skipped'
550
+ ? formatTaskSkippedLine(node, colors, undefined, blocks)
551
+ : o.status === 'aborted'
552
+ ? formatTaskAbortedLine(node, o, colors, blocks)
553
+ : isCacheHit(o.status)
554
+ ? formatTaskHitLine(node, o, colors, false, blocks)
555
+ : formatTaskExecutedLine(node, o, colors, false, blocks, usualMs)
556
+ return note === '' ? row : `${row} ${paint('muted', note, colors)}`
557
+ }
558
+
559
+ /**
560
+ * A running task's usual duration, fixed while the elapsed time counts up
561
+ * toward it (2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
562
+ * column below 100 minutes.
563
+ */
564
+ export function formatExpected(ms: number): string {
565
+ // The tier by the rounded value: 999.6 ms printed `~1000ms` (item 1034's class).
566
+ if (Math.round(ms) < 1000) return `~${Math.round(ms)}ms`
567
+ if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
568
+ const s = Math.round(ms / 1000)
569
+ if (s < 60) return `~${s}s`
570
+ return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
571
+ }
572
+
573
+ /** A task `--dry` lists: what the run would do with it, on the running list's grid. */
574
+ export interface PlannedRow {
575
+ node: TaskNode
576
+ glyph: string
577
+ status: string
578
+ /** The glyph's and the status word's hue. */
579
+ tone: Style
580
+ cache: string
581
+ cacheTone: Style | ''
582
+ /** Its usual time, from history. */
583
+ usualMs?: number
584
+ /** What a remote hit would download. */
585
+ outputBytes?: number
586
+ /** How many tasks are downstream of it. */
587
+ blocks?: number
588
+ worker: string
589
+ /** Dim, after the id: its description, why it is affected. */
590
+ note: string
591
+ }
592
+
593
+ /** `--dry`'s list: the running list's header and grid, so a plan reads as the run would. */
594
+ export function planList(rows: readonly PlannedRow[]): ui.Block {
595
+ return {
596
+ lines: (c) => [
597
+ formatRunningHeader(c),
598
+ ...rows.map((r) => {
599
+ const usual = r.usualMs === undefined ? undefined : formatExpected(r.usualMs)
600
+ const row = formatTaskRow(
601
+ paint(r.tone, r.glyph, c),
602
+ null,
603
+ r.status,
604
+ r.tone,
605
+ r.cache,
606
+ r.cacheTone,
607
+ paintTaskId(r.node, c),
608
+ c,
609
+ {
610
+ ...(usual !== undefined
611
+ ? { usual: { text: usual, painted: paint('muted', usual, c) } }
612
+ : {}),
613
+ ...(r.outputBytes !== undefined ? { outputBytes: r.outputBytes } : {}),
614
+ ...(r.blocks !== undefined ? { blocks: String(r.blocks) } : {}),
615
+ worker: r.worker,
616
+ },
617
+ )
618
+ return r.note === '' ? row : `${row} ${paint('muted', r.note, c)}`
619
+ }),
620
+ ],
621
+ }
418
622
  }
419
623
 
420
624
  /** Compact one-liner for a cache hit with nothing to replay. */
@@ -423,8 +627,11 @@ export function formatTaskHitLine(
423
627
  o: TaskOutcome,
424
628
  colors: ColorSupport = NO_COLOR,
425
629
  onPath = false,
630
+ blocks?: number,
426
631
  ): string {
427
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath)
632
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, {
633
+ ...ranCells(o, blocks),
634
+ })
428
635
  }
429
636
 
430
637
  /**
@@ -437,8 +644,8 @@ export function formatTaskHashLine(
437
644
  o: TaskOutcome,
438
645
  colors: ColorSupport = NO_COLOR,
439
646
  ): string {
440
- const color = o.status === 'aborted' ? ABORTED : statusOf(o).color
441
- const key = o.hash === undefined ? '' : ` ${paint('', o.hash, colors, { dim: true })}`
647
+ const color = o.status === 'aborted' ? 'aborted' : statusOf(o).color
648
+ const key = o.hash === undefined ? '' : ` ${paint('muted', o.hash, colors)}`
442
649
  return `${paint(color, outcomeWord(o), colors)} ${paintTaskId(node, colors)}${key}`
443
650
  }
444
651
 
@@ -448,8 +655,44 @@ export function formatTaskExecutedLine(
448
655
  o: TaskOutcome,
449
656
  colors: ColorSupport = NO_COLOR,
450
657
  onPath = false,
658
+ blocks?: number,
659
+ usualMs?: number | null,
451
660
  ): string {
452
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath)
661
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, {
662
+ ...executedCells(o, blocks),
663
+ // A hit's time is a restore's, not the task's: nothing to compare.
664
+ ...(o.status === 'success' || o.status === 'failed'
665
+ ? { usual: usualCell(o.durationMs, usualMs, colors) }
666
+ : {}),
667
+ })
668
+ }
669
+
670
+ /**
671
+ * An executed task's cells: where it ran, its average cores and peak
672
+ * memory; a task that ran with either unread shows `—`, a hit nothing.
673
+ */
674
+ export function executedCells(o: TaskOutcome, blocks: number | undefined): RowCells {
675
+ const cores = coresOf(o)
676
+ const ran = o.status === 'success' || o.status === 'failed'
677
+ return {
678
+ ...ranCells(o, blocks),
679
+ ...(cores !== undefined ? { cpu: cores } : ran ? { cpu: null } : {}),
680
+ ...(o.peakRssBytes !== undefined
681
+ ? { memBytes: o.peakRssBytes }
682
+ : ran
683
+ ? { memBytes: null }
684
+ : {}),
685
+ }
686
+ }
687
+
688
+ /** The cells every row of a task that reached a machine carries. */
689
+ function ranCells(o: TaskOutcome, blocks: number | undefined): RowCells {
690
+ return {
691
+ // A skip reached no machine.
692
+ ...(o.status === 'skipped' ? {} : { worker: o.where ?? 'local' }),
693
+ ...(blocks !== undefined ? { blocks: String(blocks) } : {}),
694
+ ...(o.outputBytes !== undefined ? { outputBytes: o.outputBytes } : {}),
695
+ }
453
696
  }
454
697
 
455
698
  /** Shared: render any outcome on the grid. */
@@ -459,6 +702,7 @@ function formatOutcomeRow(
459
702
  ms: number | null,
460
703
  colors: ColorSupport,
461
704
  onPath: boolean,
705
+ cells: RowCells,
462
706
  ): string {
463
707
  const st = statusOf(o)
464
708
  const ca = cacheOf(o)
@@ -472,20 +716,21 @@ function formatOutcomeRow(
472
716
  ca.color,
473
717
  paintedId,
474
718
  colors,
719
+ cells,
475
720
  ) +
476
721
  (onPath ? pathNote(colors) : '') +
477
- whereNote(o, colors) +
478
722
  flakyNote(o, colors)
479
723
  )
480
724
  }
481
725
 
482
726
  /** `critical path`: the row's task held the run's end while others ran beside it. */
483
727
  export function pathNote(colors: ColorSupport = NO_COLOR): string {
484
- return ` ${paint(ERROR, 'critical path', colors)}`
728
+ // A fact worth knowing, not a failure: never red.
729
+ return ` ${paint('info', 'critical path', colors)}`
485
730
  }
486
731
 
487
732
  /** Where an executor ran the task, when not here, right after its id: `@small#2` (2026-10-21). */
488
- export function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
733
+ function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
489
734
  return o.where === undefined ? '' : placeNote(o.where, colors)
490
735
  }
491
736
 
@@ -493,8 +738,8 @@ export function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): stri
493
738
  * ` · <where>` after the id: the dot dim, the place in its own hue. Last
494
739
  * on the row, as a worker's name has no width to pad to (2026-10-21).
495
740
  */
496
- export function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
497
- return `${paint('', ' \u00b7', colors, { dim: true })} ${ui.inline(ui.place(where), colors)}`
741
+ function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
742
+ return `${paint('muted', ' \u00b7', colors)} ${ui.inline(ui.place(where), colors)}`
498
743
  }
499
744
 
500
745
  /**
@@ -512,7 +757,7 @@ export function flakyNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): stri
512
757
  }
513
758
  if ((o.attempts ?? 1) > 1) said.push(`${o.attempts} attempts`)
514
759
  const text = said.length === 0 ? 'flaky' : `flaky - ${said.join(' \u00b7 ')}`
515
- return ` ${paint('', text, colors, { dim: true })}`
760
+ return ` ${paint('muted', text, colors)}`
516
761
  }
517
762
 
518
763
  /**
@@ -524,19 +769,21 @@ export function formatTaskSkippedLine(
524
769
  node: TaskNode,
525
770
  colors: ColorSupport = NO_COLOR,
526
771
  skip?: Parameters<typeof skippedReason>[0],
772
+ blocks?: number,
527
773
  ): string {
528
774
  const why = skippedReason(skip)
529
- const suffix = why === '' ? '' : ` ${paint('', `• ${why}`, colors, { dim: true })}`
775
+ const suffix = why === '' ? '' : ` ${paint('muted', `• ${why}`, colors)}`
530
776
  return (
531
777
  formatTaskRow(
532
- paint(WARN, '\u2298', colors),
778
+ paint('warn', '\u2298', colors),
533
779
  null,
534
780
  'skipped',
535
- WARN,
781
+ 'warn',
536
782
  '',
537
783
  '',
538
784
  paintTaskId(node, colors),
539
785
  colors,
786
+ blocks === undefined ? {} : { blocks: String(blocks) },
540
787
  ) + suffix
541
788
  )
542
789
  }
@@ -550,11 +797,13 @@ export function formatTaskAbortedLine(
550
797
  node: TaskNode,
551
798
  o: TaskOutcome,
552
799
  colors: ColorSupport = NO_COLOR,
800
+ blocks?: number,
553
801
  ): string {
554
802
  const id = paintTaskId(node, colors)
803
+ const bl: RowCells = blocks === undefined ? {} : { blocks: String(blocks) }
555
804
  if (neverStarted(o)) {
556
805
  return formatTaskRow(
557
- paint('', '\u25cc', colors, { dim: true }),
806
+ paint('muted', '\u25cc', colors),
558
807
  null,
559
808
  'not run',
560
809
  '',
@@ -562,17 +811,19 @@ export function formatTaskAbortedLine(
562
811
  '',
563
812
  id,
564
813
  colors,
814
+ bl,
565
815
  )
566
816
  }
567
817
  return formatTaskRow(
568
- paint(ABORTED, '\u2717', colors),
818
+ paint('aborted', '\u2717', colors),
569
819
  o.durationMs,
570
820
  'aborted',
571
- ABORTED,
821
+ 'aborted',
572
822
  '',
573
823
  '',
574
824
  id,
575
825
  colors,
826
+ ranCells(o, blocks),
576
827
  )
577
828
  }
578
829
 
@@ -588,7 +839,7 @@ export function formatFrameOpen(
588
839
  colors: ColorSupport = NO_COLOR,
589
840
  forwardArgs: readonly string[] = [],
590
841
  ): string {
591
- const corner = (t: string) => paint('', t, colors, { dim: true })
842
+ const corner = (t: string) => paint('muted', t, colors)
592
843
  const cmd = shownCommand(node, forwardArgs)
593
844
  const mark = persistentMark(node, colors)
594
845
  return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
@@ -620,7 +871,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
620
871
  // glob matches and the user decides.
621
872
  const fix = outcome.sandboxFix
622
873
  const line = fix === undefined ? undefined : sandboxFixLine(fix)
623
- const dim = (text: string): string => paint('', text, colors, { dim: true })
874
+ const dim = (text: string): string => paint('muted', text, colors)
624
875
  const note =
625
876
  fix === undefined
626
877
  ? []
@@ -645,7 +896,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
645
896
  // vx's own notes (`vx: …`) explain a failure; they are not denials.
646
897
  const denials = body.filter((l) => !l.startsWith('vx: ')).length
647
898
  return [
648
- sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', ERROR, colors),
899
+ sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', 'error', colors),
649
900
  '',
650
901
  ...body,
651
902
  '',
@@ -658,7 +909,7 @@ export function formatFrameClose(
658
909
  outcome: TaskOutcome,
659
910
  colors: ColorSupport = NO_COLOR,
660
911
  ): string {
661
- const corner = (t: string) => paint('', t, colors, { dim: true })
912
+ const corner = (t: string) => paint('muted', t, colors)
662
913
  const mark = persistentMark(node, colors)
663
914
  // A persistent task's child keeps running past this "ready" outcome,
664
915
  // so the close reads `running` (accent), not `success` — the ▸ marks
@@ -682,11 +933,11 @@ function shownCommand(node: TaskNode, forwardArgs: readonly string[]): string {
682
933
  }
683
934
 
684
935
  function persistentMark(node: TaskNode, colors: ColorSupport): string {
685
- return node.config.exec?.persistent !== undefined ? `${paint(ACCENT, '▸', colors)} ` : ''
936
+ return node.config.exec?.persistent !== undefined ? `${paint('info', '▸', colors)} ` : ''
686
937
  }
687
938
 
688
939
  function runningFooter(o: TaskOutcome, colors: ColorSupport): string {
689
- return ` ${paint('', `(${formatElapsed(o.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
940
+ return ` ${paint('muted', `(${formatElapsed(o.durationMs)})`, colors)} ${paint('info', 'running', colors)}`
690
941
  }
691
942
 
692
943
  /**
@@ -713,8 +964,8 @@ export function formatPersistentTailBlock(
713
964
  const stderr = body.stderr ?? ''
714
965
  if (stdout.trim().length === 0 && stderr.trim().length === 0) return ''
715
966
  const lines: string[] = [formatFrameOpen(node, colors, forwardArgs)]
716
- pushStreamSection(lines, stdout, 'STDOUT (since ready)', SUCCESS, dropped.stdout ?? 0, colors)
717
- pushStreamSection(lines, stderr, 'STDERR (since ready)', ERROR, dropped.stderr ?? 0, colors)
967
+ pushStreamSection(lines, stdout, 'STDOUT (since ready)', 'ok', dropped.stdout ?? 0, colors)
968
+ pushStreamSection(lines, stderr, 'STDERR (since ready)', 'error', dropped.stderr ?? 0, colors)
718
969
  lines.push(formatFrameClose(node, outcome, colors))
719
970
  return lines.join('\n') + '\n'
720
971
  }
@@ -725,40 +976,16 @@ export function formatPersistentTailBlock(
725
976
  * logs read apart. `text` holds whole lines, its last newline cut off.
726
977
  */
727
978
  export function formatKeptLines(node: TaskNode, text: string, colors: ColorSupport): string {
728
- const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
979
+ const prefix = `${paintTaskId(node, colors)} ${paint('muted', '│', colors)} `
729
980
  return text
730
981
  .split('\n')
731
982
  .map((line) => {
732
983
  const shown = lastRedraw(line)
733
- return `${prefix}${shown}${leavesStyle(shown) ? RESET : ''}\n`
984
+ return `${prefix}${shown}${leavesStyle(shown) ? STYLE_END : ''}\n`
734
985
  })
735
986
  .join('')
736
987
  }
737
988
 
738
- // eslint-disable-next-line no-control-regex -- ESC is the point
739
- const LINE_RESET = /\r|\x1b\[[12]K|\x1b\[[01]?G/g
740
- // eslint-disable-next-line no-control-regex -- ESC is the point
741
- const TRAILING_RESETS = /(?:\r|\x1b\[[12]K|\x1b\[[01]?G)+$/
742
- // eslint-disable-next-line no-control-regex -- ESC is the point
743
- const SGR = /\x1b\[[0-9;]*m/g
744
-
745
- /**
746
- * What a line shows once its redraws have played: the text after its last
747
- * `\r`, line erase (`CSI 1K`/`2K`) or move to column 1 (`CSI G`), with the
748
- * colours set before it. Written after an id prefix, a dev server's redraw
749
- * (`\x1b[2K\x1b[1Gbuilt in 30ms`, a `10%\r100%` progress line) erased or
750
- * overwrote the prefix on a terminal. A reset that ends the line, as in
751
- * CRLF, redraws nothing.
752
- */
753
- function lastRedraw(line: string): string {
754
- if (!line.includes('\r') && !line.includes('\x1b')) return line
755
- const text = line.replace(TRAILING_RESETS, '')
756
- let cut = -1
757
- for (const m of text.matchAll(LINE_RESET)) cut = m.index + m[0].length
758
- if (cut === -1) return text
759
- return (text.slice(0, cut).match(SGR) ?? []).join('') + text.slice(cut)
760
- }
761
-
762
989
  /**
763
990
  * Between the requested task's frame and the summary footer, list the
764
991
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -772,28 +999,28 @@ export function formatPersistentList(
772
999
  ): string[] {
773
1000
  return nodes.map(
774
1001
  (n) =>
775
- ` ${paint(ACCENT, '▸', colors)} ${paintTaskId(n, colors)} ${paint('', 'running', colors, { dim: true })}`,
1002
+ ` ${paint('info', '▸', colors)} ${paintTaskId(n, colors)} ${paint('muted', 'running', colors)}`,
776
1003
  )
777
1004
  }
778
1005
 
779
1006
  function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
780
1007
  const shortHash = o.hash ? o.hash.slice(0, 8) : ''
781
- const dim = (s: string) => paint('', s, colors, { dim: true })
1008
+ const dim = (s: string) => paint('muted', s, colors)
782
1009
  const word = outcomeLabel(o)
783
1010
  switch (o.status) {
784
1011
  case 'cache-hit':
785
- return `${paint(SUCCESS, word, colors)} ${dim(`• ${shortHash}`)}`
1012
+ return `${paint('ok', word, colors)} ${dim(`• ${shortHash}`)}`
786
1013
  case 'cache-hit-remote':
787
1014
  // A remote restore is the one hit painted accent: bytes came off the wire.
788
- return `${paint(o.restored === false ? SUCCESS : ACCENT, word, colors)} ${dim(`• ${shortHash}`)}`
1015
+ return `${paint(o.restored === false ? 'ok' : 'info', word, colors)} ${dim(`• ${shortHash}`)}`
789
1016
  case 'failed':
790
1017
  // The command lives in its own `├─ command` section; the header
791
1018
  // carries the outcome like every other status.
792
- return paint(ERROR, word, colors, { bold: true })
1019
+ return paint('error', word, colors, true)
793
1020
  case 'skipped':
794
- return paint(WARN, word, colors)
1021
+ return paint('warn', word, colors)
795
1022
  case 'aborted':
796
- return paint(ABORTED, word, colors)
1023
+ return paint('aborted', word, colors)
797
1024
  default:
798
1025
  return dim(word)
799
1026
  }
@@ -807,7 +1034,7 @@ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
807
1034
  // saved" on the strength of it. The stored time lives on
808
1035
  // `TaskOutcome.storedDurationMs`.) Status differs by outcome — see
809
1036
  // formatStatusTag.
810
- const dur = paint('', `(${formatElapsed(o.durationMs)})`, colors, { dim: true })
1037
+ const dur = paint('muted', `(${formatElapsed(o.durationMs)})`, colors)
811
1038
  const tag = formatStatusTag(o, colors)
812
1039
  return ` ${dur} ${tag}${flakyNote(o, colors)}`
813
1040
  }
@@ -816,13 +1043,13 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
816
1043
  const word = outcomeLabel(o)
817
1044
  switch (o.status) {
818
1045
  case 'failed':
819
- return paint(ERROR, word, colors, { bold: true })
1046
+ return paint('error', word, colors, true)
820
1047
  case 'skipped':
821
- return paint(WARN, word, colors)
1048
+ return paint('warn', word, colors)
822
1049
  case 'aborted':
823
- return paint(ABORTED, word, colors)
1050
+ return paint('aborted', word, colors)
824
1051
  default:
825
- return paint('', word, colors, { dim: true })
1052
+ return paint('muted', word, colors)
826
1053
  }
827
1054
  }
828
1055