@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,506 @@
1
+ // vx's output components. A verb, the run and every plugin compose these;
2
+ // nothing else draws (tests/ui-only-output.unsafe.test.ts). Each owns its
3
+ // colour, indent and spacing, decided at print time for the stream it lands
4
+ // on, so every surface shares one look. Design: docs/design/ui-2026-11.md.
5
+
6
+ import {
7
+ barWidth,
8
+ identityHue,
9
+ INDENT,
10
+ LABEL_MIN,
11
+ paint,
12
+ paintHex,
13
+ RESET,
14
+ ruleWidth,
15
+ SYMBOL,
16
+ type ColorSupport,
17
+ type Style,
18
+ type Tone,
19
+ } from './theme.js'
20
+
21
+ /** A piece of text in a style. */
22
+ interface Token {
23
+ readonly style: Style | 'id'
24
+ readonly text: string
25
+ readonly bold?: boolean
26
+ }
27
+
28
+ /** Styled text inside a component: a plain string, a token, or a run of them. */
29
+ export type Inline = string | Token | readonly Inline[]
30
+
31
+ /** A laid-out piece of output; `render` and `print` space blocks apart. */
32
+ export interface Block {
33
+ readonly lines: (colors: ColorSupport) => string[]
34
+ }
35
+
36
+ /** A `project` or `project#task` id, each half in its name's hue, the `#` dim. */
37
+ export const id = (text: string): Inline => ({ style: 'id', text })
38
+ /** A path, glob or file: bold. */
39
+ export const path = (text: string): Inline => ({ style: 'strong', text })
40
+ /** A command to type: bold. */
41
+ export const cmd = (text: string): Inline => ({ style: 'strong', text })
42
+ /** What to read first in a line: bold. */
43
+ export const strong = (text: string): Inline => ({ style: 'strong', text })
44
+ /** A secondary fact. */
45
+ export const dim = (text: string): Inline => ({ style: 'muted', text })
46
+ /** Where a task runs (`small#1`): its name's hue in every row and block; `local` plain. */
47
+ export const place = (text: string): Inline => (text === 'local' ? dim(text) : id(text))
48
+ /** Text in a tone's colour, bold if asked. */
49
+ export const tone = (t: Style, text: string, bold = false): Inline =>
50
+ bold ? { style: t, text, bold } : { style: t, text }
51
+
52
+ // oxlint-disable-next-line no-control-regex
53
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
54
+
55
+ /**
56
+ * `s` with every control byte but a tab and a line break as `\xNN`: a
57
+ * component quotes paths and names, anyone's bytes, and one holding
58
+ * `ESC ]0;` retitled the terminal. The colours are the components' own.
59
+ */
60
+ const plainText = (s: string): string =>
61
+ s.replace(CONTROL, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`)
62
+
63
+ /** An id's two halves, each in its name's hue, the `#` dim. */
64
+ function paintId(text: string, c: ColorSupport, bold: boolean): string {
65
+ const opts = bold ? { bold: true } : {}
66
+ const hash = text.lastIndexOf('#')
67
+ if (hash < 0) return paintHex(identityHue(text), text, c, opts)
68
+ const task = text.slice(hash + 1)
69
+ const project = text.slice(0, hash)
70
+ return (
71
+ paintHex(identityHue(project), project, c, opts) +
72
+ paintHex('', '#', c, { dim: true }) +
73
+ paintHex(identityHue(task), task, c, opts)
74
+ )
75
+ }
76
+
77
+ /**
78
+ * `project#task` halves, each in its name's identity hue (never a status
79
+ * colour), the separator dim. `hueSource` is the FULL project name, so a
80
+ * cut display string keeps its hue.
81
+ */
82
+ export function idParts(
83
+ hueSource: string,
84
+ projectText: string,
85
+ taskText: string,
86
+ c: ColorSupport,
87
+ opts: { bold?: boolean; sep?: '#' | ':' } = {},
88
+ ): string {
89
+ const style = opts.bold === true ? { bold: true } : {}
90
+ return (
91
+ paintHex(identityHue(hueSource), projectText, c, style) +
92
+ paintHex('', opts.sep ?? '#', c, { dim: true }) +
93
+ paintHex(identityHue(taskText), taskText, c, style)
94
+ )
95
+ }
96
+
97
+ /** One inline as text, coloured for `c`. A bare string is printed as given. */
98
+ export function inline(x: Inline, c: ColorSupport): string {
99
+ if (typeof x === 'string') return plainText(x)
100
+ if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
101
+ const t = x as Token
102
+ const text = plainText(t.text)
103
+ return t.style === 'id' ? paintId(text, c, t.bold === true) : paint(t.style, text, c, t.bold)
104
+ }
105
+
106
+ /** The visible width of an inline: its text, never its colour. */
107
+ export function width(x: Inline): number {
108
+ if (typeof x === 'string') return Bun.stringWidth(x)
109
+ if (Array.isArray(x)) return (x as readonly Inline[]).reduce<number>((n, p) => n + width(p), 0)
110
+ return Bun.stringWidth((x as Token).text)
111
+ }
112
+
113
+ const block = (lines: (c: ColorSupport) => string[]): Block => ({ lines })
114
+ const lines = (x: Inline, c: ColorSupport): string[] => inline(x, c).split('\n')
115
+
116
+ /** Plain words at the value level, each `command` in them bold. */
117
+ function prose(text: string): Inline {
118
+ return text
119
+ .split(/(`[^`\n]+`)/)
120
+ .map((p, i) => (i % 2 === 1 ? cmd(p) : p === '' ? p : { style: 'value' as const, text: p }))
121
+ }
122
+
123
+ /**
124
+ * A panel's top off colour: `─ Title count ──── note ─`, flush with the
125
+ * bars. The same characters a pipe, a test and `grep` read.
126
+ */
127
+ function ruleLine(
128
+ title: string,
129
+ count: Inline | undefined,
130
+ c: ColorSupport,
131
+ note?: Inline,
132
+ ): string {
133
+ const plain = { enabled: false }
134
+ const head = `─ ${title}${count === undefined ? '' : ` ${inline(count, plain)}`} `
135
+ if (note === undefined)
136
+ return `${head}${'─'.repeat(Math.max(8, ruleWidth(c) - Bun.stringWidth(head)))}`
137
+ const tail = ` ${inline(note, plain)} ─`
138
+ return `${head}${'─'.repeat(Math.max(3, ruleWidth(c) - Bun.stringWidth(head) - Bun.stringWidth(tail)))}${tail}`
139
+ }
140
+
141
+ // ── Panels ──────────────────────────────────────────────────────────
142
+ // vx's screens look like a TUI and are not one: each part is a box drawn
143
+ // line by line into the scrollback, so a pipe, a CI log and a terminal all
144
+ // read it top to bottom. The box is paint: off colour a panel is its rule
145
+ // and its lines, the same bytes a grep or a test reads.
146
+
147
+ // oxlint-disable-next-line no-control-regex -- an escape is copied whole, never cut
148
+ const ESCAPE = /^(?:\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))/
149
+
150
+ /**
151
+ * `line` in rows of `w` visible columns, each padded to `w`. Nothing is
152
+ * cut: a long line wraps at a space where it has one, a continuation sits
153
+ * under the line's value column (past its first two-space gap: a field's
154
+ * value, a table's last cell) so the label column stays clear, and a colour
155
+ * open at a break carries on in the next row.
156
+ */
157
+ export function wrap(line: string, w: number): string[] {
158
+ const shown = Bun.stringWidth(line)
159
+ if (shown <= w) return [line + ' '.repeat(w - shown)]
160
+ // Each visible character with the escapes before it and the style open at it.
161
+ const cells: { pre: string; ch: string; cw: number; style: string }[] = []
162
+ let pre = ''
163
+ let style = ''
164
+ for (let i = 0; i < line.length;) {
165
+ if (line.charCodeAt(i) === 0x1b) {
166
+ const m = ESCAPE.exec(line.slice(i, i + 256))
167
+ if (m !== null) {
168
+ pre += m[0]
169
+ if (m[0].endsWith('m')) style = m[0] === RESET ? '' : style + m[0]
170
+ i += m[0].length
171
+ continue
172
+ }
173
+ }
174
+ const ch = String.fromCodePoint(line.codePointAt(i)!)
175
+ cells.push({ pre, ch, cw: Bun.stringWidth(ch), style })
176
+ pre = ''
177
+ i += ch.length
178
+ }
179
+ const plain = cells.map((c) => c.ch).join('')
180
+ const gap = /^ *\S.*? {2,}(?=\S)/.exec(plain)?.[0]
181
+ const lead = /^ */.exec(plain)![0].length
182
+ const hang = Math.min(gap === undefined ? lead : Bun.stringWidth(gap), Math.floor(w / 2))
183
+ const rows: string[] = []
184
+ let at = 0
185
+ let indent = 0
186
+ while (at < cells.length) {
187
+ // The widest run from `at` that fits, ended at a space when there is one.
188
+ let used = indent
189
+ let end = at
190
+ let space = -1
191
+ while (end < cells.length && used + cells[end]!.cw <= w) {
192
+ if (cells[end]!.ch === ' ' && end > at) space = end
193
+ used += cells[end]!.cw
194
+ end++
195
+ }
196
+ if (end < cells.length && space !== -1 && cells[end]!.ch !== ' ') end = space
197
+ if (end === at) end = at + 1
198
+ let row = indent === 0 ? '' : ' '.repeat(indent)
199
+ if (at > 0) row += cells[at]!.style
200
+ let width = indent
201
+ for (let k = at; k < end; k++) {
202
+ row += cells[k]!.pre + cells[k]!.ch
203
+ width += cells[k]!.cw
204
+ }
205
+ const open = end < cells.length ? cells[end]!.style : ''
206
+ if (end >= cells.length) row += pre
207
+ rows.push(`${row}${open === '' ? '' : RESET}${' '.repeat(Math.max(0, w - width))}`)
208
+ at = end
209
+ while (at < cells.length && cells[at]!.ch === ' ') at++
210
+ indent = hang
211
+ }
212
+ return rows
213
+ }
214
+
215
+ const border = (s: string, c: ColorSupport): string => paint('border', s, c)
216
+
217
+ /**
218
+ * A panel's top: the rule `─ Title count ──── note ─`, its title bold in
219
+ * the brand hue, the rule a quiet grey. A panel has no box: its lines sit
220
+ * under the rule as they are, so a terminal, a CI log and a pipe read the
221
+ * same layout (off colour, the same characters unpainted).
222
+ */
223
+ export function panelTop(title: string, c: ColorSupport, count?: Inline, note?: Inline): string {
224
+ if (!c.enabled) return ruleLine(title, count, c, note)
225
+ const plain = { enabled: false }
226
+ const head = `─ ${title}${count === undefined ? '' : ` ${inline(count, plain)}`} `
227
+ const tail = note === undefined ? '' : ` ${inline(note, plain)} ─`
228
+ const dashes = Math.max(
229
+ note === undefined ? 8 : 3,
230
+ ruleWidth(c) - Bun.stringWidth(head) - Bun.stringWidth(tail),
231
+ )
232
+ return (
233
+ border('─ ', c) +
234
+ paint('heading', title, c) +
235
+ (count === undefined ? '' : ` ${inline(typeof count === 'string' ? dim(count) : count, c)}`) +
236
+ ' ' +
237
+ border('─'.repeat(dashes), c) +
238
+ (note === undefined
239
+ ? ''
240
+ : ` ${inline(typeof note === 'string' ? dim(note) : note, c)} ${border('─', c)}`)
241
+ )
242
+ }
243
+
244
+ /**
245
+ * A titled part of a screen: `Tasks`, `Warnings`, `Running`, `Summary`,
246
+ * a verb's report. A panel of its blocks, each a blank line apart.
247
+ */
248
+ export function section(title: string, ...body: Block[]): Block
249
+ export function section(title: string, count: Inline, ...body: Block[]): Block
250
+ export function section(title: string, ...rest: (Block | Inline)[]): Block {
251
+ const counted = rest.length > 0 && !isBlock(rest[0])
252
+ const count = counted ? (rest[0] as Inline) : undefined
253
+ const body = (counted ? rest.slice(1) : rest) as Block[]
254
+ return block((c) => {
255
+ const inner = body.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
256
+ return [panelTop(title, c, count), ...inner]
257
+ })
258
+ }
259
+
260
+ const isBlock = (x: unknown): x is Block =>
261
+ typeof x === 'object' && x !== null && 'lines' in x && typeof x.lines === 'function'
262
+
263
+ /** `✗ vx lock lock not written`: the tone's symbol, the bold verb, the headline. */
264
+ export function status(t: Tone, verb: string, headline: Inline): Block {
265
+ return block((c) => [
266
+ `${paint(t, SYMBOL[t], c)} ${paint('strong', verb, c)} ${inline(headline, c)}`,
267
+ ])
268
+ }
269
+
270
+ /**
271
+ * A line vx says mid-run (`[vx] cache save failed: …`, `vx watch: …`), its
272
+ * words as they always read and its tag in the tone's hue; a line with no
273
+ * tag is painted whole. One string, no newline: the run's own streams write it.
274
+ */
275
+ export function tagged(t: Tone, line: string, c: ColorSupport): string {
276
+ // A warning carries a remote's or a plugin's words, anyone's bytes.
277
+ const body = plainText(line.replace(/\n+$/, ''))
278
+ const tag = /^(?:\[vx\]|vx(?: [a-z][\w-]*){0,2}:|[\w@/.-]+: )/.exec(body)?.[0].trimEnd()
279
+ return tag === undefined
280
+ ? paint(t, body, c)
281
+ : `${paint(t, tag, c, true)}${paint('muted', body.slice(tag.length), c)}`
282
+ }
283
+
284
+ /**
285
+ * A file or excerpt shown whole (`vx init --dry`, `vx docs`): its title rule
286
+ * bold, its body behind a dim gutter. Uncoloured it is the bare text, so a
287
+ * pipe gets the bytes it would copy.
288
+ */
289
+ export function excerpt(title: string, body: string): Block {
290
+ return block((c) => {
291
+ if (!c.enabled) return [`── ${title} ──`, ...body.split('\n')]
292
+ const gutter = paint('info', '│', c)
293
+ return [
294
+ paint('info', `── ${title} ──`, c, true),
295
+ ...body
296
+ .split('\n')
297
+ .map((l, i, all) => (i === all.length - 1 && l === '' ? l : `${gutter} ${l}`)),
298
+ ]
299
+ })
300
+ }
301
+
302
+ /** Indented short lines: what is wrong, or what happened. */
303
+ export function text(...items: Inline[]): Block {
304
+ return block((c) =>
305
+ items.flatMap((i) =>
306
+ lines(typeof i === 'string' ? prose(i) : i, c).map((l) => `${INDENT}${l}`),
307
+ ),
308
+ )
309
+ }
310
+
311
+ /** One line at the margin: a verb's answer (`vx 0.0.650`), plain strings dim. */
312
+ export function line(...items: Inline[]): Block {
313
+ return block((c) => [items.map((i) => inline(typeof i === 'string' ? prose(i) : i, c)).join('')])
314
+ }
315
+
316
+ /** A blank line: what parts a run's cycles or a reply from what came before. */
317
+ export function gap(): Block {
318
+ return block(() => [''])
319
+ }
320
+
321
+ /** An indented dim line: why it matters. */
322
+ export function why(line: Inline): Block {
323
+ return block((c) => lines(line, c).map((l) => `${INDENT}${paint('muted', l, c)}`))
324
+ }
325
+
326
+ /** A label and its values; values after the first sit under it. `null` is a blank line. */
327
+ export type FieldRow = readonly [label: string, ...values: Inline[]] | null
328
+
329
+ /** A dim label column with the values aligned beside it; `next` and `fix` are bold. */
330
+ export function fields(rows: readonly FieldRow[]): Block {
331
+ return block((c) => {
332
+ const w = Math.max(LABEL_MIN, ...rows.map((r) => (r === null ? 0 : r[0].length)))
333
+ const out: string[] = []
334
+ for (const row of rows) {
335
+ if (row === null) {
336
+ out.push('')
337
+ continue
338
+ }
339
+ const [label, ...values] = row
340
+ const strongRow = label === 'next' || label === 'fix'
341
+ values
342
+ // A command is bold whole, never dim prose under the bold.
343
+ .flatMap((v) => lines(typeof v === 'string' && !strongRow ? prose(v) : v, c))
344
+ .forEach((v, i) => {
345
+ const head = i === 0 ? paint('muted', label.padEnd(w), c) : ' '.repeat(w)
346
+ const value = strongRow ? paint('strong', v, c) : v
347
+ out.push(`${INDENT}${head} ${value}`.trimEnd())
348
+ })
349
+ }
350
+ return out
351
+ })
352
+ }
353
+
354
+ /** `fix <action>`: what to do about it. */
355
+ export function fix(action: Inline): Block {
356
+ return labelled('fix', action)
357
+ }
358
+
359
+ /** `next <command>`: the command to type next. */
360
+ export function next(command: Inline): Block {
361
+ return labelled('next', command)
362
+ }
363
+
364
+ function labelled(label: string, value: Inline): Block {
365
+ return block((c) => [
366
+ `${INDENT}${paint('muted', label, c)} ${paint('strong', inline(value, c), c)}`,
367
+ ])
368
+ }
369
+
370
+ /** A report: a blank line, the section rule naming `title`, then its fields. */
371
+ export function report(title: string, rows: readonly FieldRow[]): Block {
372
+ return block((c) => ['', ...section(title, fields(rows)).lines(c)])
373
+ }
374
+
375
+ /** A table column: its header, and `right` for numbers. */
376
+ export type Column = string | { readonly title: string; readonly align: 'right' }
377
+
378
+ /**
379
+ * Aligned columns under a dim header (an empty header line is left out).
380
+ * A plain string cell is dim; the last column is never padded.
381
+ */
382
+ export function table(columns: readonly Column[], rows: readonly (readonly Inline[])[]): Block {
383
+ return block((c) => {
384
+ const titles = columns.map((col) => (typeof col === 'string' ? col : col.title))
385
+ const right = columns.map((col) => typeof col !== 'string' && col.align === 'right')
386
+ const cells = rows.map((r) => r.map((x) => inline(typeof x === 'string' ? prose(x) : x, c)))
387
+ const widths = titles.map((h, i) =>
388
+ Math.max(h.length, ...rows.map((r) => (r[i] === undefined ? 0 : width(r[i]!)))),
389
+ )
390
+ const pad = (s: string, i: number, w: number): string => {
391
+ const gap = ' '.repeat(Math.max(0, widths[i]! - w))
392
+ return right[i] ? gap + s : i === titles.length - 1 ? s : s + gap
393
+ }
394
+ const head = titles.every((t) => t === '')
395
+ ? []
396
+ : [
397
+ `${INDENT}${paint('muted', titles.map((h, i) => pad(h, i, h.length)).join(' '), c)}`.trimEnd(),
398
+ ]
399
+ return [
400
+ ...head,
401
+ ...cells.map((r, ri) =>
402
+ `${INDENT}${r.map((s, i) => pad(s, i, width(rows[ri]![i] ?? ''))).join(' ')}`.trimEnd(),
403
+ ),
404
+ ]
405
+ })
406
+ }
407
+
408
+ /**
409
+ * A debug table vx prints on request (`VX_DEBUG_TIMING=1`): its `[tag]`
410
+ * lines' tag bold in the info hue, every other character dim.
411
+ */
412
+ export function trace(lines: readonly string[]): Block {
413
+ return block((c) =>
414
+ lines.map((l) => {
415
+ const tag = /^\[[^\]]+\]/.exec(l)?.[0]
416
+ return tag === undefined
417
+ ? paint('muted', l, c)
418
+ : `${paint('info', tag, c, true)}${paint('muted', l.slice(tag.length), c)}`
419
+ }),
420
+ )
421
+ }
422
+
423
+ /** A source frame (`> 1 | …` and its caret), indented. */
424
+ export function code(frame: string): Block {
425
+ return block(() => frame.split('\n').map((l) => (l === '' ? '' : `${INDENT}${l}`)))
426
+ }
427
+
428
+ export interface NoticeParts {
429
+ tone: Tone
430
+ verb: string
431
+ headline: Inline
432
+ /** What is wrong, one fact a line. */
433
+ what?: readonly Inline[]
434
+ why?: Inline
435
+ /** A source frame, below the facts. */
436
+ code?: string
437
+ fix?: Inline
438
+ }
439
+
440
+ /** A refusal or a warning: status, then what and why, the frame, and the fix, a blank line apart. */
441
+ export function notice(p: NoticeParts): Block {
442
+ return block((c) => {
443
+ const parts: Block[] = [status(p.tone, p.verb, p.headline)]
444
+ const body = [
445
+ ...(p.what !== undefined ? [text(...p.what)] : []),
446
+ ...(p.why !== undefined ? [why(p.why)] : []),
447
+ ]
448
+ if (body.length > 0) parts.push(block((cc) => body.flatMap((b) => b.lines(cc))))
449
+ if (p.code !== undefined) parts.push(code(p.code))
450
+ if (p.fix !== undefined) parts.push(fix(p.fix))
451
+ return parts.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
452
+ })
453
+ }
454
+
455
+ /** One part of a meter: how many, in which style, drawn with which cell. */
456
+ export interface Segment {
457
+ readonly n: number
458
+ readonly style: Style
459
+ /** `▰` by default; `▱` for what is not done yet. */
460
+ readonly cell?: string
461
+ }
462
+
463
+ /**
464
+ * A stacked meter: one run of cells per segment, proportional by largest
465
+ * remainder, every non-empty segment at least one cell. Style carries the
466
+ * state; the numbers ride beside it in a legend.
467
+ */
468
+ export function bar(segments: readonly Segment[], c: ColorSupport): string {
469
+ const total = segments.reduce((sum, seg) => sum + seg.n, 0)
470
+ if (total === 0) return ''
471
+ const w = barWidth(c)
472
+ const exact = segments.map((seg) => (seg.n / total) * w)
473
+ const cells = exact.map(Math.floor)
474
+ let leftover = w - cells.reduce((sum, n) => sum + n, 0)
475
+ const order = exact
476
+ .map((e, i) => ({ i, frac: e - Math.floor(e) }))
477
+ .sort((x, y) => y.frac - x.frac)
478
+ for (const { i } of order) {
479
+ if (leftover === 0) break
480
+ cells[i]!++
481
+ leftover--
482
+ }
483
+ for (let i = 0; i < segments.length; i++) {
484
+ if (segments[i]!.n > 0 && cells[i] === 0) {
485
+ const biggest = cells.indexOf(Math.max(...cells))
486
+ cells[biggest]!--
487
+ cells[i] = 1
488
+ }
489
+ }
490
+ return segments.map((seg, i) => paint(seg.style, (seg.cell ?? '▰').repeat(cells[i]!), c)).join('')
491
+ }
492
+
493
+ /** A meter of `filled` of `width` cells in `style`, the rest dim. */
494
+ export function gauge(filled: number, style: Style, c: ColorSupport): string {
495
+ const w = barWidth(c)
496
+ const n = Math.max(0, Math.min(w, filled))
497
+ return paint(style, '▰'.repeat(n), c) + paint('muted', '▱'.repeat(w - n), c)
498
+ }
499
+
500
+ /** A meter's width, for a caller that scales a count to it. */
501
+ export { barWidth }
502
+
503
+ /** The blocks as text, a blank line apart, ending in a newline. */
504
+ export function render(blocks: readonly Block[], colors: ColorSupport): string {
505
+ return `${blocks.map((b) => b.lines(colors).join('\n')).join('\n\n')}\n`
506
+ }
@@ -0,0 +1,79 @@
1
+ // Module contract: vx's UI kit. The only module that draws: its tokens
2
+ // (theme), its components, the live region and the streams. Every other
3
+ // module and every plugin composes these (tests/ui-only-output.unsafe.test.ts).
4
+ // Design: docs/design/ui-2026-11.md.
5
+
6
+ // The façade's `ui`: the kit a plugin may draw with.
7
+ import * as ui from './kit.js'
8
+ export { ui }
9
+
10
+ export {
11
+ bar,
12
+ barWidth,
13
+ cmd,
14
+ code,
15
+ dim,
16
+ excerpt,
17
+ fields,
18
+ fix,
19
+ gauge,
20
+ id,
21
+ idParts,
22
+ inline,
23
+ next,
24
+ notice,
25
+ panelTop,
26
+ path,
27
+ place,
28
+ render,
29
+ report,
30
+ section,
31
+ status,
32
+ strong,
33
+ table,
34
+ tagged,
35
+ text,
36
+ line,
37
+ gap,
38
+ tone,
39
+ trace,
40
+ why,
41
+ width,
42
+ wrap,
43
+ type Block,
44
+ type Column,
45
+ type FieldRow,
46
+ type Inline,
47
+ type NoticeParts,
48
+ type Segment,
49
+ } from './components.js'
50
+ export {
51
+ detectColors,
52
+ dumbTerminal,
53
+ INDENT,
54
+ paint,
55
+ ruleWidth,
56
+ SYMBOL,
57
+ type ColorSupport,
58
+ type Style,
59
+ type Tone,
60
+ } from './theme.js'
61
+ export { colorsOf, data, print, say, transient, type StreamName } from './terminal.js'
62
+ export { endStyle, hasEscape, lastRedraw, leavesStyle, STYLE_END } from './ansi.js'
63
+ export {
64
+ createOutputWriter,
65
+ type OutputWriter,
66
+ type OutputWriterOptions,
67
+ type Region,
68
+ type StatusStream,
69
+ } from './live-region.js'
70
+ export {
71
+ notifyEscape,
72
+ PROGRESS_CLEAR,
73
+ progressEscape,
74
+ terminalLinks,
75
+ terminalSignals,
76
+ type TerminalSignals,
77
+ } from './terminal-signals.js'
78
+ export { fileLocations, fileMemo, linkPaths, type OutputLocation } from './links.js'
79
+ export { lineNotice, messageParts, refusalNotice, sayLine, sayNotice } from './refusal.js'
package/src/ui/kit.ts ADDED
@@ -0,0 +1,37 @@
1
+ // The kit a plugin draws with, exported by the façade as `ui`: the
2
+ // components and the streams, never the live region or the escapes that
3
+ // drive it (those are core's run screen). Same parts core's verbs use.
4
+
5
+ export {
6
+ cmd,
7
+ code,
8
+ dim,
9
+ excerpt,
10
+ fields,
11
+ fix,
12
+ gap,
13
+ id,
14
+ inline,
15
+ line,
16
+ next,
17
+ notice,
18
+ path,
19
+ place,
20
+ render,
21
+ report,
22
+ section,
23
+ status,
24
+ strong,
25
+ table,
26
+ tagged,
27
+ text,
28
+ tone,
29
+ why,
30
+ type Block,
31
+ type Column,
32
+ type FieldRow,
33
+ type Inline,
34
+ type NoticeParts,
35
+ } from './components.js'
36
+ export { type ColorSupport, type Style, type Tone } from './theme.js'
37
+ export { colorsOf, data, print, say, transient, type StreamName } from './terminal.js'