@vzn/vx 0.0.650 → 0.0.652
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /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
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -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'
|