@vzn/vx 0.0.650 → 0.0.651

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -0,0 +1,363 @@
1
+ // The live region: a block of lines redrawn in place under the log, with
2
+ // clear-line / cursor-up and no alternate screen (NOT a TUI). The writer is
3
+ // the serialization point for ALL of a screen's output: every ordinary write
4
+ // erases the region first, writes its content, then redraws, so the region
5
+ // never interleaves with what scrolls above it.
6
+
7
+ import { dumbTerminal } from './theme.js'
8
+
9
+ /** Erase the current line and return the cursor to column 0. */
10
+ const CLEAR = '\x1b[2K\r'
11
+
12
+ export interface StatusStream {
13
+ write(chunk: string): unknown
14
+ isTTY?: boolean
15
+ /**
16
+ * Terminal width in columns. Read fresh on every draw — `process.stdout`
17
+ * updates it on SIGWINCH, so a mid-run resize is picked up. Absent (not a
18
+ * TTY) means the region is never drawn anyway.
19
+ */
20
+ columns?: number
21
+ /** Terminal height in rows, read per draw as `columns` is. */
22
+ rows?: number
23
+ }
24
+
25
+ /** A region's lines, or what builds them when it is drawn. */
26
+ export type Region = readonly string[] | (() => readonly string[])
27
+
28
+ export interface OutputWriter {
29
+ /** True only when the stream is a TTY and the run isn't CI. */
30
+ readonly enabled: boolean
31
+ /** Serialized content write: erase status → content → redraw. */
32
+ write(chunk: string): void
33
+ /**
34
+ * The same, to another stream on the same terminal (stderr): the region
35
+ * on this one is erased first and redrawn after.
36
+ */
37
+ writeTo(target: StatusStream, chunk: string): void
38
+ /**
39
+ * Replace the status line's content. Redraws are throttled to
40
+ * `minRedrawMs` unless `force` (task start/finish) is set.
41
+ */
42
+ setStatus(line: string, opts?: { force?: boolean }): void
43
+ /**
44
+ * Replace the multi-line status region. Same throttle contract as
45
+ * `setStatus`; the two share one display slot. A function is called
46
+ * when the region is next drawn, once: a set the throttle coalesces
47
+ * builds nothing.
48
+ */
49
+ setRegion(lines: Region, opts?: { force?: boolean }): void
50
+ /** Hand over what `coalesce` holds and write straight through from now on. */
51
+ settle(): void
52
+ /** Permanently remove the status display; later set* is a no-op. */
53
+ clearStatus(): void
54
+ /**
55
+ * Erase the status display and draw none until the returned release:
56
+ * a prompt on the terminal (a secret's) waits for its answer there.
57
+ */
58
+ hold(): () => void
59
+ }
60
+
61
+ export interface OutputWriterOptions {
62
+ /** Master switch on top of the TTY check (false in CI). */
63
+ enabled?: boolean
64
+ /** Minimum interval between unforced redraws. */
65
+ minRedrawMs?: number
66
+ /**
67
+ * Floor between FORCED redraws. Task events force a redraw each; a
68
+ * 3,270-task warm run produced 6,540 forced redraws ≈ 6.7 MB of
69
+ * ANSI to the terminal. A forced set inside the floor marks the
70
+ * content dirty and ONE trailing draw lands the final state when
71
+ * the floor expires. 0 disables (tests asserting synchronously).
72
+ */
73
+ forceFloorMs?: number
74
+ /** Injectable clock for tests. */
75
+ now?: () => number
76
+ /**
77
+ * Off a TTY, hold writes and hand them to the stream once per turn of
78
+ * the event loop until `settle()`. A warm 476-package run wrote each of
79
+ * its 952 task lines as its own syscall (item 753). Off by default: a
80
+ * caller reading its stream right after a write sees it.
81
+ */
82
+ coalesce?: boolean
83
+ }
84
+
85
+ export function createOutputWriter(
86
+ stream: StatusStream,
87
+ opts: OutputWriterOptions = {},
88
+ ): OutputWriter {
89
+ const enabled = (opts.enabled ?? true) && stream.isTTY === true && !dumbTerminal()
90
+ const minRedrawMs = opts.minRedrawMs ?? 100
91
+ const forceFloorMs = opts.forceFloorMs ?? 30
92
+ const now = opts.now ?? Date.now
93
+ // The coalesced writes not yet handed over; null once settled or when
94
+ // not coalescing.
95
+ let held: string[] | null = !enabled && opts.coalesce === true ? [] : null
96
+ let flushQueued = false
97
+ const flushHeld = (): void => {
98
+ flushQueued = false
99
+ if (held === null || held.length === 0) return
100
+ const text = held.join('')
101
+ held.length = 0
102
+ stream.write(text)
103
+ }
104
+
105
+ let current: Region | null = null
106
+ let shown = false
107
+ // PHYSICAL rows the shown region occupies, not logical lines — see
108
+ // `regionRows`.
109
+ let shownRows = 0
110
+ let dead = false
111
+ let holds = 0
112
+ let lastDraw = -Infinity
113
+ // Trailing draw scheduled when a forced set lands inside the floor.
114
+ let trailing: ReturnType<typeof setTimeout> | null = null
115
+ // Streamed task output (focused mode) can end mid-line; redrawing
116
+ // the status display would wipe the partial line, so we hold off
117
+ // until a write restores column 0.
118
+ let atLineStart = true
119
+
120
+ const cancelTrailing = (): void => {
121
+ if (trailing !== null) {
122
+ clearTimeout(trailing)
123
+ trailing = null
124
+ }
125
+ }
126
+
127
+ /**
128
+ * How many PHYSICAL terminal rows `lines` occupies. The terminal wraps
129
+ * any line wider than the viewport, so a region of N logical lines can
130
+ * cover more than N rows — and erasing by the logical count moves the
131
+ * cursor up too few rows, leaving the top of the region on screen. It
132
+ * accumulates, because every redraw is short by the same amount.
133
+ *
134
+ * Both triggers are ordinary: the summary section is a fixed 62 visible
135
+ * columns (narrower terminal ⇒ every bar row wraps), and a task id is
136
+ * deliberately never truncated (long id ⇒ the worker row wraps at any
137
+ * width). Width is read per draw so a mid-run resize is handled. Width
138
+ * must be VISIBLE width — these lines carry ANSI, which occupies no
139
+ * column.
140
+ *
141
+ * Unknown width falls back to the logical count, which is a KNOWN
142
+ * residual rather than a guarantee: a wrapped line then under-erases
143
+ * exactly as it did before this function existed. It is deliberate,
144
+ * because the alternatives are worse — guessing a width over-erases on a
145
+ * wider terminal, and `ESC[J` clears to end of SCREEN, so over-erasing
146
+ * destroys output above the region instead of merely leaving junk below
147
+ * it. Reachable only on a TTY whose winsize ioctl fails, since a non-TTY
148
+ * disables the region outright.
149
+ */
150
+ // A line's rows, memoized per width: most of a frame's lines are the
151
+ // last frame's, and measuring each one again was the most of a redraw's
152
+ // CPU (a 2,000-task run's profile).
153
+ let rowsWidth = -1
154
+ const rowsOf = new Map<string, number>()
155
+ const lineRows = (line: string): number => {
156
+ const cols = stream.columns
157
+ if (cols === undefined || cols <= 0) return 1
158
+ if (cols !== rowsWidth || rowsOf.size > 4096) {
159
+ rowsWidth = cols
160
+ rowsOf.clear()
161
+ }
162
+ let rows = rowsOf.get(line)
163
+ if (rows === undefined) {
164
+ rows = Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
165
+ rowsOf.set(line, rows)
166
+ }
167
+ return rows
168
+ }
169
+ const regionRows = (lines: readonly string[]): number => {
170
+ let rows = 0
171
+ for (const line of lines) rows += lineRows(line)
172
+ return rows
173
+ }
174
+
175
+ /**
176
+ * The leading lines that fit the terminal's height, one row spared.
177
+ * Cursor-up stops at the top of the screen, and a row that scrolled into
178
+ * the scrollback is out of the erase's reach: a region taller than the
179
+ * terminal left its top behind on every redraw, one stacked block per tick
180
+ * (VS Code's short panel, 2026-10-14). The first line always shows.
181
+ */
182
+ const fitted = (lines: readonly string[]): readonly string[] => {
183
+ const height = stream.rows
184
+ if (height === undefined || height <= 0) return lines
185
+ let rows = 0
186
+ let n = 0
187
+ for (const line of lines) {
188
+ rows += lineRows(line)
189
+ if (n > 0 && rows > height - 1) break
190
+ n++
191
+ }
192
+ return n === lines.length ? lines : lines.slice(0, n)
193
+ }
194
+
195
+ // Erase sequence for whatever is currently shown. A single row keeps
196
+ // the exact legacy bytes (ESC[2K\r); anything taller moves to its
197
+ // top row and clears to end of screen.
198
+ const eraseSeq = (): string => (shownRows > 1 ? `\r\x1b[${shownRows - 1}A\x1b[J` : CLEAR)
199
+
200
+ // Content written since the last frame, drawn with it: one write per
201
+ // frame, the region erased and redrawn once around all of it. Each row
202
+ // used to be three writes (erase, row, region) and a whole region
203
+ // redraw: 2,000 rows wrote 8.5 MB.
204
+ const pending: string[] = []
205
+ // The region's text as last written: a frame that would write the same
206
+ // bytes writes nothing.
207
+ let shownText = ''
208
+
209
+ /** Write what is pending and, where it may show, the region: one write. */
210
+ const draw = (): void => {
211
+ cancelTrailing()
212
+ let text = ''
213
+ const content = pending.length > 0
214
+ if (content) {
215
+ text = (shown ? eraseSeq() : '') + pending.join('')
216
+ pending.length = 0
217
+ shown = false
218
+ shownRows = 0
219
+ }
220
+ if (!dead && current !== null && atLineStart && holds === 0) {
221
+ // Built here, not at the set: a warm run's task events set the
222
+ // region hundreds of times inside one floor, and each build was a
223
+ // forecast pass and a summary render no frame showed (200 hits on a
224
+ // terminal: run graph 62 → 9 ms). Kept built: a write redraws it.
225
+ if (typeof current === 'function') current = current()
226
+ const lines = fitted(current!)
227
+ const region = lines.join('\n')
228
+ if (shown && region === shownText) {
229
+ if (text !== '') stream.write(text)
230
+ lastDraw = now()
231
+ return
232
+ }
233
+ text += (shown ? eraseSeq() : CLEAR) + region
234
+ shown = true
235
+ shownRows = regionRows(lines)
236
+ shownText = region
237
+ }
238
+ if (text !== '') stream.write(text)
239
+ lastDraw = now()
240
+ }
241
+ /**
242
+ * The next frame: now if the floor has passed since the last one, else
243
+ * once it does. A timer holding content keeps the process open until it
244
+ * lands; one that only redraws the region never does.
245
+ */
246
+ const frame = (): void => {
247
+ const since = now() - lastDraw
248
+ if (since >= forceFloorMs) {
249
+ draw()
250
+ return
251
+ }
252
+ if (trailing === null) {
253
+ trailing = setTimeout(() => {
254
+ trailing = null
255
+ draw()
256
+ }, forceFloorMs - since)
257
+ if (pending.length === 0) trailing.unref?.()
258
+ } else if (pending.length > 0) trailing.ref?.()
259
+ }
260
+ const set = (lines: Region, o: { force?: boolean }): void => {
261
+ if (!enabled || dead) return
262
+ current = lines
263
+ if (!atLineStart || holds > 0) return
264
+ if (o.force) {
265
+ // A burst coalesces: `current` is already the latest state, so one
266
+ // frame at the floor's expiry lands it.
267
+ frame()
268
+ return
269
+ }
270
+ const since = now() - lastDraw
271
+ if (since >= minRedrawMs) draw()
272
+ }
273
+
274
+ return {
275
+ enabled,
276
+ write(chunk) {
277
+ if (!enabled) {
278
+ if (held === null) {
279
+ stream.write(chunk)
280
+ return
281
+ }
282
+ held.push(chunk)
283
+ if (!flushQueued) {
284
+ flushQueued = true
285
+ setImmediate(flushHeld)
286
+ }
287
+ return
288
+ }
289
+ if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
290
+ pending.push(chunk)
291
+ // A held terminal (a prompt waits on it) or a dead region takes the
292
+ // write now: nothing will draw a frame for it.
293
+ if (holds > 0 || dead) draw()
294
+ else frame()
295
+ },
296
+ writeTo(target, chunk) {
297
+ if (target === stream) return this.write(chunk)
298
+ // Held stdout is older than this chunk.
299
+ flushHeld()
300
+ if (!enabled) {
301
+ target.write(chunk)
302
+ return
303
+ }
304
+ // What this stream holds is older than the chunk: it lands first,
305
+ // with the region erased, and the region returns after the chunk.
306
+ let text = pending.join('')
307
+ pending.length = 0
308
+ if (shown) {
309
+ text = eraseSeq() + text
310
+ shown = false
311
+ shownRows = 0
312
+ }
313
+ if (text !== '') stream.write(text)
314
+ target.write(chunk)
315
+ if (!dead && current !== null && atLineStart && holds === 0) draw()
316
+ },
317
+ setStatus(line, o = {}) {
318
+ set([line], o)
319
+ },
320
+ setRegion(lines, o = {}) {
321
+ set(lines, o)
322
+ },
323
+ settle() {
324
+ flushHeld()
325
+ held = null
326
+ },
327
+ clearStatus() {
328
+ if (!enabled || dead) return
329
+ dead = true
330
+ current = null
331
+ cancelTrailing()
332
+ // Content still waiting for a frame lands now, over the erased region.
333
+ let text = pending.join('')
334
+ pending.length = 0
335
+ if (shown) {
336
+ text = eraseSeq() + text
337
+ shown = false
338
+ shownRows = 0
339
+ }
340
+ if (text !== '') stream.write(text)
341
+ },
342
+ hold() {
343
+ if (!enabled || dead) return () => undefined
344
+ holds++
345
+ cancelTrailing()
346
+ let text = pending.join('')
347
+ pending.length = 0
348
+ if (shown) {
349
+ text = eraseSeq() + text
350
+ shown = false
351
+ shownRows = 0
352
+ }
353
+ if (text !== '') stream.write(text)
354
+ let released = false
355
+ return () => {
356
+ if (released) return
357
+ released = true
358
+ holds--
359
+ if (holds === 0 && !dead && current !== null && atLineStart) draw()
360
+ }
361
+ },
362
+ }
363
+ }
@@ -2,9 +2,10 @@
2
2
  // carries, a config refusal's frame, or a plain message laid out a fact a
3
3
  // line. The flat message stays what `--format json` and logs carry.
4
4
 
5
- import { configRefusal } from './config-frame.js'
6
- import type { RefusalParts } from './errors.js'
7
- import { notice, print, type Block, type Inline, type Tone } from './ui.js'
5
+ import { configRefusal, type RefusalParts } from '../util/index.js'
6
+ import { notice, type Block, type Inline } from './components.js'
7
+ import { print } from './terminal.js'
8
+ import type { Tone } from './theme.js'
8
9
 
9
10
  // A sentence break before a capital, a backtick or a paren: vx joins
10
11
  // its facts with these.
@@ -14,7 +15,8 @@ const SEE = /\s*\(see `([^`]+)`\)/
14
15
  // `(did you mean --concurrency?)`, `. Did you mean build?`: the fix.
15
16
  const MEANT = /\s*(?:\(did you mean ([^?]+)\?\)|(?:^|[.;—]\s*)[Dd]id you mean ([^?]+)\?)/
16
17
 
17
- const bare = (s: string): string => s.trim().replace(/[.:]$/, '')
18
+ // A trailing `...` is the text's own (a `--filter a...`), not a full stop.
19
+ const bare = (s: string): string => s.trim().replace(/(?<!\.)[.:]$/, '')
18
20
 
19
21
  /** A plain message in parts: its first sentence heads, each later one is a line. */
20
22
  export function messageParts(message: string): RefusalParts {
@@ -54,7 +56,7 @@ export function refusalNotice(
54
56
  ): Block {
55
57
  const c = parts === undefined ? configRefusal(message, opts.cwd ?? process.cwd()) : undefined
56
58
  if (c !== undefined) {
57
- const what: Inline[] = [{ style: 'bold', text: c.where }, c.problem]
59
+ const what: Inline[] = [{ style: 'strong', text: c.where }, c.problem]
58
60
  return notice({
59
61
  tone: 'error',
60
62
  verb,
@@ -0,0 +1,51 @@
1
+ // Where output leaves vx: the only file outside the live region that writes
2
+ // to a stream. A screen is blocks (`print`), a line said mid-run (`say`), or
3
+ // data a program reads (`data`: JSON, a completion script, a file's bytes),
4
+ // which no component may restyle.
5
+
6
+ import { inline, render, tagged, wrap, type Block, type Inline } from './components.js'
7
+ import { detectColors, INDENT, type Tone } from './theme.js'
8
+
9
+ export type StreamName = 'out' | 'err'
10
+
11
+ const streamOf = (s: StreamName): NodeJS.WriteStream =>
12
+ s === 'out' ? process.stdout : process.stderr
13
+
14
+ /** Write the blocks to stdout (`out`) or stderr (`err`), coloured for that stream. */
15
+ export function print(stream: StreamName, ...blocks: Block[]): void {
16
+ const s = streamOf(stream)
17
+ s.write(render(blocks, detectColors(s)))
18
+ }
19
+
20
+ /** `tagged` on a stream (stderr unless told), coloured for it. */
21
+ export function say(t: Tone, line: string, stream: StreamName = 'err'): void {
22
+ const s = streamOf(stream)
23
+ s.write(`${tagged(t, line, detectColors(s))}\n`)
24
+ }
25
+
26
+ /**
27
+ * Bytes a program reads, written as they are: `--format json`, a
28
+ * completion script, a file `vx show` prints. Never a sentence for a person.
29
+ */
30
+ export function data(stream: StreamName, bytes: string): void {
31
+ streamOf(stream).write(bytes)
32
+ }
33
+
34
+ /** Whether a stream paints. */
35
+ export function colorsOf(stream: StreamName): ReturnType<typeof detectColors> {
36
+ return detectColors(streamOf(stream))
37
+ }
38
+
39
+ /**
40
+ * A line that stands while something runs and then goes (`vx init`'s
41
+ * package install), on stdout's terminal only: piped, it is not written.
42
+ * Wrapped, never cut. Returns the call that takes it back.
43
+ */
44
+ export function transient(line: Inline): () => void {
45
+ const s = process.stdout
46
+ if (s.isTTY !== true) return () => {}
47
+ const c = detectColors(s)
48
+ const rows = wrap(`${INDENT}${inline(line, c)} …`, s.columns ?? 80)
49
+ s.write(`\n${rows.join('\n')}`)
50
+ return () => void s.write(`\r\x1b[2K${'\x1b[1A\x1b[2K'.repeat(rows.length - 1)}\x1b[1A`)
51
+ }
@@ -0,0 +1,224 @@
1
+ // The design tokens: every colour, glyph and spacing vx prints. Nothing
2
+ // outside src/ui names a hue or writes an escape byte
3
+ // (tests/ui-only-output.unsafe.test.ts); a component asks for a style.
4
+
5
+ /** Whether a stream paints, and how wide it is. */
6
+ export interface ColorSupport {
7
+ enabled: boolean
8
+ /** The terminal's width, read live; none off a terminal. Bars and rules fit it. */
9
+ readonly columns?: number | undefined
10
+ }
11
+
12
+ /**
13
+ * A style every component and inline token draws with, by the level of
14
+ * what it marks. Five levels, each one look, brightest first:
15
+ *
16
+ * 1 outcome how a thing went: a tone (`ok` `warn` `error` `info`,
17
+ * `aborted`, `notRun`), its glyph and status word bold
18
+ * 2 name what it is about: a title (`heading`), an id's hues, a
19
+ * command or path to type (`strong`, `term`), where a
20
+ * cache answer came from (`local`, `remote`)
21
+ * 3 value the fact itself: a field's value, a table cell, a
22
+ * sentence (`value`, a soft light grey: no line of vx
23
+ * reads plain white)
24
+ * 4 meta what frames a fact: a label, a unit, a hint, a count
25
+ * (`muted`, `label`)
26
+ * 5 chrome rules and separators (`border`)
27
+ *
28
+ * A component picks the level; a plain string takes the level of where it
29
+ * sits (a value in a field, meta in a title's count).
30
+ */
31
+ export type Style =
32
+ | 'ok'
33
+ | 'warn'
34
+ | 'error'
35
+ | 'info'
36
+ | 'aborted'
37
+ | 'notRun'
38
+ | 'local'
39
+ | 'remote'
40
+ | 'heading'
41
+ | 'term'
42
+ | 'value'
43
+ | 'muted'
44
+ | 'strong'
45
+ | 'label'
46
+ | 'border'
47
+ | 'plain'
48
+
49
+ /** How a status went: the four a notice or a status line can be. */
50
+ export type Tone = 'ok' | 'warn' | 'error' | 'info'
51
+
52
+ const HUE: Record<Style, string> = {
53
+ ok: '#22c55e', // green-500
54
+ warn: '#eab308', // yellow-500
55
+ error: '#ef4444', // red-500
56
+ info: '#06b6d4', // cyan-500
57
+ aborted: '#f97316', // orange-500: red's neighbour, not a failure
58
+ notRun: '#a855f7', // purple-500: stopped before it began
59
+ local: '#38bdf8', // sky-400
60
+ remote: '#2563eb', // blue-600
61
+ heading: '#a78bfa', // violet-400: the brand's first stop
62
+ term: '#22d3ee', // cyan-400: a flag, a selector, an example to type
63
+ value: '#cbd5e1', // slate-300: the fact, one step under a name
64
+ muted: '',
65
+ strong: '',
66
+ label: '',
67
+ border: '#6e7681', // a panel's rule: quiet, never a tone
68
+ plain: '',
69
+ }
70
+
71
+ /** A tone's mark: the one glyph a status, a notice or a card opens with. */
72
+ export const SYMBOL: Record<Tone, string> = { ok: '✓', warn: '!', error: '✗', info: '›' }
73
+
74
+ /** Content sits this far in under its section or card head. */
75
+ export const INDENT = ' '
76
+ /** A field label's column: 8 wide, wider only if a label is. */
77
+ export const LABEL_MIN = 8
78
+ /** A meter's widest, and the label column beside it (2 indent + 8 label + 2 gap). */
79
+ const BAR_MAX = 50
80
+ const BAR_LABEL = 12
81
+
82
+ /**
83
+ * Identity hues for the two halves of a `project#task` id, never a status
84
+ * colour: one name, one hue, in every run, list and row (2026-10-15).
85
+ */
86
+ const IDENTITY = [
87
+ '#a78bfa', // violet-400
88
+ '#60a5fa', // blue-400
89
+ '#e879f9', // fuchsia-400
90
+ '#818cf8', // indigo-400
91
+ '#c084fc', // purple-400
92
+ '#93c5fd', // blue-300
93
+ '#f472b6', // pink-400
94
+ ] as const
95
+
96
+ /** A name's identity hue: a fold of the name into the palette. */
97
+ export function identityHue(name: string): string {
98
+ let h = 0
99
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
100
+ return IDENTITY[h % IDENTITY.length]!
101
+ }
102
+
103
+ /**
104
+ * Standard env precedence:
105
+ * NO_COLOR non-empty → off (overrides FORCE_COLOR)
106
+ * FORCE_COLOR=0 or =false → off, even on a TTY
107
+ * FORCE_COLOR any other value → on (the empty string too)
108
+ * any CI (`onCi`) → on: a CI log looks like the terminal
109
+ * else: on iff the stream is a TTY that is not `TERM=dumb`.
110
+ * `0` and `false` are the convention's "off" (supports-color, chalk,
111
+ * Node); reading every non-empty value as on painted a piped CI log that
112
+ * set `FORCE_COLOR=0` (nx#35292's bug).
113
+ */
114
+ export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
115
+ const force = process.env['FORCE_COLOR']
116
+ const enabled = process.env['NO_COLOR']
117
+ ? false
118
+ : force !== undefined
119
+ ? force !== '0' && force !== 'false'
120
+ : onCi() || (stream.isTTY === true && !dumbTerminal())
121
+ return {
122
+ enabled,
123
+ get columns() {
124
+ return stream.isTTY === true ? stream.columns : undefined
125
+ },
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The variables a CI sets, each on when non-empty and not `0`/`false`:
131
+ * the generic `CI` most set, and the ones of CIs that set no `CI` (Azure
132
+ * Pipelines' `TF_BUILD`, Jenkins' `JENKINS_URL`, TeamCity's
133
+ * `TEAMCITY_VERSION`). A CI log gets the same colours as a terminal; one
134
+ * whose viewer shows raw escapes is turned off with `NO_COLOR`.
135
+ */
136
+ const CI_VARS = [
137
+ 'CI',
138
+ 'GITHUB_ACTIONS',
139
+ 'GITLAB_CI',
140
+ 'BUILDKITE',
141
+ 'CIRCLECI',
142
+ 'TF_BUILD',
143
+ 'JENKINS_URL',
144
+ 'TEAMCITY_VERSION',
145
+ ] as const
146
+
147
+ function onCi(): boolean {
148
+ return CI_VARS.some((name) => {
149
+ const v = process.env[name]
150
+ return v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
151
+ })
152
+ }
153
+
154
+ /** `TERM=dumb` can neither paint nor move the cursor: vx prints to it as off a terminal. */
155
+ export function dumbTerminal(): boolean {
156
+ return process.env['TERM'] === 'dumb'
157
+ }
158
+
159
+ export const RESET = '\x1b[0m'
160
+ const BOLD = '\x1b[1m'
161
+ const DIM = '\x1b[2m'
162
+ const UNDERLINE = '\x1b[4m'
163
+
164
+ // Bun.color('red', 'ansi') emits the colour number as a raw byte, so vx
165
+ // paints 24-bit. Bun.color is FFI work per call; a run paints the same
166
+ // handful of hues thousands of times, so each is converted once.
167
+ const ansiCache = new Map<string, string>()
168
+ function ansiFor(hex: string): string {
169
+ let v = ansiCache.get(hex)
170
+ if (v === undefined) ansiCache.set(hex, (v = Bun.color(hex, 'ansi-16m') ?? ''))
171
+ return v
172
+ }
173
+
174
+ export interface PaintOptions {
175
+ bold?: boolean
176
+ dim?: boolean
177
+ underline?: boolean
178
+ }
179
+
180
+ /** `text` in a raw hue (an identity, a gradient stop) and modifiers. Inside src/ui only. */
181
+ export function paintHex(
182
+ hex: string,
183
+ text: string,
184
+ c: ColorSupport,
185
+ opts: PaintOptions = {},
186
+ ): string {
187
+ // Empty text paints nothing: an escape pair around no text is bytes and no mark.
188
+ if (!c.enabled || text === '') return text
189
+ let prefix = ''
190
+ if (opts.bold) prefix += BOLD
191
+ if (opts.dim) prefix += DIM
192
+ if (opts.underline) prefix += UNDERLINE
193
+ if (hex) prefix += ansiFor(hex)
194
+ return prefix === '' ? text : `${prefix}${text}${RESET}`
195
+ }
196
+
197
+ /** `text` in a style; `bold` adds weight to a hue. */
198
+ export function paint(style: Style, text: string, c: ColorSupport, bold = false): string {
199
+ switch (style) {
200
+ case 'plain':
201
+ return text
202
+ case 'muted':
203
+ return paintHex('', text, c, { dim: true })
204
+ case 'strong':
205
+ return paintHex('', text, c, { bold: true })
206
+ case 'heading':
207
+ return paintHex(HUE.heading, text, c, { bold: true })
208
+ case 'label':
209
+ return paintHex('', text, c, { dim: true, underline: true })
210
+ default:
211
+ return paintHex(HUE[style], text, c, bold ? { bold: true } : {})
212
+ }
213
+ }
214
+
215
+ /** A meter's cells: 50, or fewer so the bar fits a narrower terminal. */
216
+ export function barWidth(c: ColorSupport): number {
217
+ const columns = c.columns
218
+ return columns === undefined ? BAR_MAX : Math.max(10, Math.min(BAR_MAX, columns - BAR_LABEL))
219
+ }
220
+
221
+ /** A rule's width: flush with the bars beside their labels. */
222
+ export function ruleWidth(c: ColorSupport): number {
223
+ return BAR_LABEL + barWidth(c)
224
+ }