@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,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 '
|
|
6
|
-
import type
|
|
7
|
-
import {
|
|
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
|
-
|
|
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: '
|
|
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
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -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
|
+
}
|