@lewishowles/cli-style 0.10.0 → 0.12.0
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/CHANGELOG.md +13 -0
- package/README.md +25 -0
- package/package.json +4 -4
- package/src/catalogue/renderer-catalogue.js +9 -2
- package/src/cli/render-command.js +6 -0
- package/src/create-cli-style.js +14 -0
- package/src/formatters/wrap.js +220 -0
- package/src/gallery/render-gallery.js +87 -5
- package/src/index.js +2 -0
- package/src/interactive/spinner.js +335 -0
- package/src/primitives/feedback.js +6 -9
- package/src/primitives/labelled-line.js +134 -0
- package/src/primitives/row.js +26 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.12.0 - 2026-08-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `labelledLine(label, message, options)`, a general icon/label/message renderer with a configurable tone. `hint()` now delegates to it instead of duplicating its own rendering, with unchanged output for existing callers.
|
|
10
|
+
- `row()` now wraps long values within the available row width (64 columns by default), instead of overflowing the line. Override with `wrapWidth`, or disable with `wrap: false`.
|
|
11
|
+
|
|
12
|
+
## 0.11.0 - 2026-07-24
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added a spinner/loading indicator for long-running JavaScript tasks: `ui.spinner.run(text, fn)` wraps a single async call, and `ui.spinner(text)` returns a handle (`update`, `succeed`, `fail`, `stop`) for cases that don't fit one call. Output always goes to stderr, animates only on an interactive terminal, and falls back to a static start/result line under CI, plain, JSON, or agent profiles. JavaScript-only; not available through the Bash, Python, or Swift adapters.
|
|
17
|
+
|
|
5
18
|
## 0.10.0 - 2026-07-20
|
|
6
19
|
|
|
7
20
|
### Added
|
package/README.md
CHANGED
|
@@ -582,6 +582,31 @@ const reporter = createReporter({
|
|
|
582
582
|
});
|
|
583
583
|
```
|
|
584
584
|
|
|
585
|
+
## Spinner
|
|
586
|
+
|
|
587
|
+
Use a spinner when a JavaScript script runs a long task and needs progress feedback. Spinner output always goes to stderr, so piped or JSON stdout stays clean. On a non-interactive target (CI, plain, JSON, agent profiles, or non-TTY stdout) it prints a static start line and result line instead of animating.
|
|
588
|
+
|
|
589
|
+
```js
|
|
590
|
+
import { createCliStyle } from "@lewishowles/cli-style";
|
|
591
|
+
|
|
592
|
+
const ui = createCliStyle();
|
|
593
|
+
|
|
594
|
+
await ui.spinner.run("Building", async () => {
|
|
595
|
+
await runBuild();
|
|
596
|
+
});
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
For cases that don't fit one awaited call, use the manual handle:
|
|
600
|
+
|
|
601
|
+
```js
|
|
602
|
+
const spinner = ui.spinner("Building");
|
|
603
|
+
|
|
604
|
+
spinner.update("Building (2/3)");
|
|
605
|
+
spinner.succeed("Build complete");
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Only one spinner can be active at a time; starting a second while one is running throws.
|
|
609
|
+
|
|
585
610
|
## Options and flags
|
|
586
611
|
|
|
587
612
|
### JavaScript options
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lewishowles/cli-style",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Shared terminal output styles for Lewis Howles tools, diagnostics, and package CLIs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"check": "vp check --fix",
|
|
33
33
|
"cli-style:gallery": "bun ./bin/cli-style.js gallery",
|
|
34
34
|
"package:binary": "scripts/package-binary.sh",
|
|
35
|
-
"prepare": "vp config",
|
|
35
|
+
"prepare": "vp config --no-agent",
|
|
36
36
|
"smoke:binary": "./dist/bin/cli-style render status --plain < tests/fixtures/render/status-success.json",
|
|
37
37
|
"test:unit": "bun test"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
41
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.
|
|
42
|
-
"vite-plus": "0.2.
|
|
41
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.8",
|
|
42
|
+
"vite-plus": "0.2.8"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=20"
|
|
@@ -25,11 +25,18 @@ export const rendererCatalogue = [
|
|
|
25
25
|
["empty-state", "primitive", "emptyState", ["detail", "title"], undefined],
|
|
26
26
|
["error-block", "primitive", "errorBlock", ["lines", "title"], undefined],
|
|
27
27
|
["hint", "primitive", "hint", ["message"], undefined],
|
|
28
|
+
[
|
|
29
|
+
"labelled-line",
|
|
30
|
+
"primitive",
|
|
31
|
+
"labelledLine",
|
|
32
|
+
["icon", "label", "message", "wrap", "wrapWidth"],
|
|
33
|
+
undefined,
|
|
34
|
+
],
|
|
28
35
|
["next-step-block", "pattern", "nextStepBlock", ["next", "reason"], "next-step-block"],
|
|
29
36
|
["panel", "primitive", "panel", ["lines", "title", "tone"], undefined],
|
|
30
37
|
["progress-bar", "primitive", "progressBar", ["max", "value"], undefined],
|
|
31
|
-
["row", "primitive", "row", ["label", "value"], undefined],
|
|
32
|
-
["row-group", "primitive", "rowGroup", ["rows"], undefined],
|
|
38
|
+
["row", "primitive", "row", ["label", "value", "wrap", "wrapWidth"], undefined],
|
|
39
|
+
["row-group", "primitive", "rowGroup", ["rows", "wrap", "wrapWidth"], undefined],
|
|
33
40
|
["sparkline", "primitive", "sparkline", ["values"], "sparkline"],
|
|
34
41
|
["span", "primitive", "span", ["tone", "value"], undefined],
|
|
35
42
|
["status", "primitive", "status", ["detail", "type"], undefined],
|
|
@@ -11,6 +11,7 @@ import { barChart } from "../primitives/bar-chart.js";
|
|
|
11
11
|
import { chip } from "../primitives/chip.js";
|
|
12
12
|
import { divider } from "../primitives/divider.js";
|
|
13
13
|
import { emptyState, errorBlock, hint } from "../primitives/feedback.js";
|
|
14
|
+
import { labelledLine } from "../primitives/labelled-line.js";
|
|
14
15
|
import { panel } from "../primitives/panel.js";
|
|
15
16
|
import { progressBar } from "../primitives/progress-bar.js";
|
|
16
17
|
import { row } from "../primitives/row.js";
|
|
@@ -62,6 +63,11 @@ const renderers = {
|
|
|
62
63
|
...input,
|
|
63
64
|
...options,
|
|
64
65
|
}),
|
|
66
|
+
"labelled-line": (input, options) =>
|
|
67
|
+
labelledLine(input.label, input.message, {
|
|
68
|
+
...input,
|
|
69
|
+
...options,
|
|
70
|
+
}),
|
|
65
71
|
"next-step-block": (input, options) => nextStepBlock(input, options),
|
|
66
72
|
panel: (input, options) =>
|
|
67
73
|
panel({
|
package/src/create-cli-style.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveProfile } from "./capability/resolve-profile.js";
|
|
2
2
|
import { resolveTerminalCapabilities } from "./capability/terminal.js";
|
|
3
|
+
import { runSpinner, spinner as startSpinner } from "./interactive/spinner.js";
|
|
3
4
|
import { profiles } from "./profiles/profiles.js";
|
|
4
5
|
import { createReporter } from "./reporters/create-reporter.js";
|
|
5
6
|
|
|
@@ -23,6 +24,18 @@ export function createCliStyle(options = {}) {
|
|
|
23
24
|
applyExplicitOption(resolvedOptions, options, "unicode");
|
|
24
25
|
applyExplicitOption(resolvedOptions, options, "width");
|
|
25
26
|
|
|
27
|
+
const spinner = (text, spinnerOptions = {}) =>
|
|
28
|
+
startSpinner(text, {
|
|
29
|
+
...resolvedOptions,
|
|
30
|
+
...spinnerOptions,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
spinner.run = (text, fn, spinnerOptions = {}) =>
|
|
34
|
+
runSpinner(text, fn, {
|
|
35
|
+
...resolvedOptions,
|
|
36
|
+
...spinnerOptions,
|
|
37
|
+
});
|
|
38
|
+
|
|
26
39
|
return {
|
|
27
40
|
options: resolvedOptions,
|
|
28
41
|
print: (value) => {
|
|
@@ -33,6 +46,7 @@ export function createCliStyle(options = {}) {
|
|
|
33
46
|
...resolvedOptions,
|
|
34
47
|
...reporterOptions,
|
|
35
48
|
}),
|
|
49
|
+
spinner,
|
|
36
50
|
write: (value) => {
|
|
37
51
|
process.stdout.write(value);
|
|
38
52
|
},
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Width used when a caller doesn't provide one.
|
|
2
|
+
const defaultWrapWidth = 64;
|
|
3
|
+
const escapeCharacter = String.fromCharCode(27);
|
|
4
|
+
const bellCharacter = String.fromCharCode(7);
|
|
5
|
+
|
|
6
|
+
// Matches ANSI CSI and OSC escape sequences so they can be measured as zero-width.
|
|
7
|
+
const ansiEscapePattern = new RegExp(
|
|
8
|
+
`${escapeCharacter}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${bellCharacter}]*(?:${bellCharacter}|${escapeCharacter}\\\\))`,
|
|
9
|
+
"gu",
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wrap text to a maximum visible width, treating ANSI escape sequences as zero-width.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* Text to wrap.
|
|
17
|
+
* @param {number} width
|
|
18
|
+
* Maximum visible width per line.
|
|
19
|
+
* @returns {string[]}
|
|
20
|
+
* Wrapped lines.
|
|
21
|
+
*/
|
|
22
|
+
export function wrapText(text, width = defaultWrapWidth) {
|
|
23
|
+
const wrapWidth = resolveWrapWidth(width);
|
|
24
|
+
const tokens = tokenise(String(text));
|
|
25
|
+
const lines = [];
|
|
26
|
+
|
|
27
|
+
let line = [];
|
|
28
|
+
|
|
29
|
+
for (const token of tokens) {
|
|
30
|
+
if (token.value === "\n") {
|
|
31
|
+
lines.push(renderTokens(line));
|
|
32
|
+
line = [];
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
line.push(token);
|
|
37
|
+
|
|
38
|
+
while (visibleWidth(line) > wrapWidth) {
|
|
39
|
+
const whitespaceIndex = findLastWhitespace(line);
|
|
40
|
+
|
|
41
|
+
if (whitespaceIndex > 0) {
|
|
42
|
+
const left = line.slice(0, whitespaceIndex);
|
|
43
|
+
|
|
44
|
+
lines.push(renderTokens(left));
|
|
45
|
+
line = trimLeadingWhitespace(line.slice(whitespaceIndex + 1));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const splitIndex = findHardSplitIndex(line, wrapWidth);
|
|
50
|
+
|
|
51
|
+
lines.push(renderTokens(line.slice(0, splitIndex)));
|
|
52
|
+
line = line.slice(splitIndex);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(renderTokens(line));
|
|
57
|
+
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a usable wrap width, falling back to the default for invalid input.
|
|
63
|
+
*
|
|
64
|
+
* @param {number} width
|
|
65
|
+
* Requested wrap width.
|
|
66
|
+
* @returns {number}
|
|
67
|
+
* Whole-number wrap width of at least 1.
|
|
68
|
+
*/
|
|
69
|
+
function resolveWrapWidth(width) {
|
|
70
|
+
const numericWidth = Number(width);
|
|
71
|
+
|
|
72
|
+
if (!Number.isFinite(numericWidth) || numericWidth < 1) {
|
|
73
|
+
return defaultWrapWidth;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Math.floor(numericWidth);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Split text into visible characters and ANSI escape sequences.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* Text to tokenise.
|
|
84
|
+
* @returns {object[]}
|
|
85
|
+
* Tokens, each with a `value` and its `visibleWidth`.
|
|
86
|
+
*/
|
|
87
|
+
function tokenise(text) {
|
|
88
|
+
const tokens = [];
|
|
89
|
+
|
|
90
|
+
let textStart = 0;
|
|
91
|
+
|
|
92
|
+
for (const match of text.matchAll(ansiEscapePattern)) {
|
|
93
|
+
appendTextTokens(tokens, text.slice(textStart, match.index));
|
|
94
|
+
tokens.push({
|
|
95
|
+
value: match[0],
|
|
96
|
+
visibleWidth: 0,
|
|
97
|
+
});
|
|
98
|
+
textStart = match.index + match[0].length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
appendTextTokens(tokens, text.slice(textStart));
|
|
102
|
+
|
|
103
|
+
return tokens;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Append one token per character of plain text to a token list.
|
|
108
|
+
*
|
|
109
|
+
* @param {object[]} tokens
|
|
110
|
+
* Token list to append to.
|
|
111
|
+
* @param {string} text
|
|
112
|
+
* Plain text, with no ANSI escape sequences.
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
function appendTextTokens(tokens, text) {
|
|
116
|
+
for (const value of text) {
|
|
117
|
+
tokens.push({
|
|
118
|
+
value,
|
|
119
|
+
visibleWidth: value === "\n" ? 0 : 1,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sum the visible width of a list of tokens.
|
|
126
|
+
*
|
|
127
|
+
* @param {object[]} tokens
|
|
128
|
+
* Tokens to measure.
|
|
129
|
+
* @returns {number}
|
|
130
|
+
* Total visible width.
|
|
131
|
+
*/
|
|
132
|
+
function visibleWidth(tokens) {
|
|
133
|
+
return tokens.reduce((width, token) => width + token.visibleWidth, 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Find the index of the last whitespace token, to break a line at a word boundary.
|
|
138
|
+
*
|
|
139
|
+
* @param {object[]} tokens
|
|
140
|
+
* Tokens to search.
|
|
141
|
+
* @returns {number}
|
|
142
|
+
* Index of the last whitespace token, or -1 when none is found.
|
|
143
|
+
*/
|
|
144
|
+
function findLastWhitespace(tokens) {
|
|
145
|
+
for (let index = tokens.length - 1; index >= 0; index -= 1) {
|
|
146
|
+
const token = tokens[index];
|
|
147
|
+
|
|
148
|
+
if (token.visibleWidth === 1 && /\s/u.test(token.value)) {
|
|
149
|
+
return index;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return -1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Find the index to split at when a line has no whitespace to break on.
|
|
158
|
+
*
|
|
159
|
+
* @param {object[]} tokens
|
|
160
|
+
* Tokens to search.
|
|
161
|
+
* @param {number} width
|
|
162
|
+
* Maximum visible width for the first segment.
|
|
163
|
+
* @returns {number}
|
|
164
|
+
* Index at which to split the tokens.
|
|
165
|
+
*/
|
|
166
|
+
function findHardSplitIndex(tokens, width) {
|
|
167
|
+
let currentWidth = 0;
|
|
168
|
+
|
|
169
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
170
|
+
currentWidth += tokens[index].visibleWidth;
|
|
171
|
+
|
|
172
|
+
if (currentWidth >= width) {
|
|
173
|
+
return index + 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return tokens.length;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remove leading whitespace tokens from a wrapped continuation line.
|
|
182
|
+
*
|
|
183
|
+
* @param {object[]} tokens
|
|
184
|
+
* Tokens to trim.
|
|
185
|
+
* @returns {object[]}
|
|
186
|
+
* Tokens with leading whitespace removed.
|
|
187
|
+
*/
|
|
188
|
+
function trimLeadingWhitespace(tokens) {
|
|
189
|
+
const trimmedTokens = [];
|
|
190
|
+
|
|
191
|
+
let hasVisibleText = false;
|
|
192
|
+
|
|
193
|
+
for (const token of tokens) {
|
|
194
|
+
if (token.visibleWidth === 0) {
|
|
195
|
+
trimmedTokens.push(token);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!hasVisibleText && /\s/u.test(token.value)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
hasVisibleText = true;
|
|
204
|
+
trimmedTokens.push(token);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return trimmedTokens;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Join a list of tokens back into a string.
|
|
212
|
+
*
|
|
213
|
+
* @param {object[]} tokens
|
|
214
|
+
* Tokens to render.
|
|
215
|
+
* @returns {string}
|
|
216
|
+
* Rendered text.
|
|
217
|
+
*/
|
|
218
|
+
function renderTokens(tokens) {
|
|
219
|
+
return tokens.map((token) => token.value).join("");
|
|
220
|
+
}
|
|
@@ -8,10 +8,12 @@ import { diffBlock } from "../patterns/diff-block.js";
|
|
|
8
8
|
import { nextStepBlock } from "../patterns/next-step-block.js";
|
|
9
9
|
import { taskSummary } from "../patterns/task-summary.js";
|
|
10
10
|
import { stripAnsi } from "../formatters/ansi.js";
|
|
11
|
+
import { renderSpinnerFrame } from "../interactive/spinner.js";
|
|
11
12
|
import { barChart } from "../primitives/bar-chart.js";
|
|
12
13
|
import { chip } from "../primitives/chip.js";
|
|
13
14
|
import { divider } from "../primitives/divider.js";
|
|
14
15
|
import { emptyState, errorBlock, hint } from "../primitives/feedback.js";
|
|
16
|
+
import { labelledLine } from "../primitives/labelled-line.js";
|
|
15
17
|
import { panel } from "../primitives/panel.js";
|
|
16
18
|
import { progressBar } from "../primitives/progress-bar.js";
|
|
17
19
|
import { row } from "../primitives/row.js";
|
|
@@ -131,6 +133,7 @@ export const galleryFixtures = [
|
|
|
131
133
|
"next-step-block",
|
|
132
134
|
"diff-block",
|
|
133
135
|
"sparkline",
|
|
136
|
+
"spinner",
|
|
134
137
|
];
|
|
135
138
|
|
|
136
139
|
/**
|
|
@@ -183,11 +186,7 @@ function renderVariant(variant, options, request) {
|
|
|
183
186
|
const sections = [];
|
|
184
187
|
|
|
185
188
|
if (request.fixture !== undefined) {
|
|
186
|
-
sections.push(
|
|
187
|
-
request.fixture === "sparkline"
|
|
188
|
-
? renderSparklineFixture(resolvedVariant.options)
|
|
189
|
-
: renderPatterns(resolvedVariant.options, request.fixture),
|
|
190
|
-
);
|
|
189
|
+
sections.push(renderFocusedFixture(request.fixture, resolvedVariant.options));
|
|
191
190
|
} else {
|
|
192
191
|
if (request.section === undefined || request.section === "primitives") {
|
|
193
192
|
sections.push(renderPrimitives(resolvedVariant.options));
|
|
@@ -676,6 +675,40 @@ function renderSparklineFixture(options) {
|
|
|
676
675
|
return ["Sparkline", frameExample(renderSparklineExample(options), options)].join("\n");
|
|
677
676
|
}
|
|
678
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Render the focused spinner fixture.
|
|
680
|
+
*
|
|
681
|
+
* @param {object} options
|
|
682
|
+
* Rendering options.
|
|
683
|
+
* @returns {string}
|
|
684
|
+
* Framed spinner fixture output.
|
|
685
|
+
*/
|
|
686
|
+
function renderSpinnerFixture(options) {
|
|
687
|
+
return ["Spinner", frameExample(renderSpinnerExample(options), options)].join("\n");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Render one fixture selected through `--fixture`.
|
|
692
|
+
*
|
|
693
|
+
* @param {string} fixture
|
|
694
|
+
* Requested fixture name.
|
|
695
|
+
* @param {object} options
|
|
696
|
+
* Rendering options.
|
|
697
|
+
* @returns {string}
|
|
698
|
+
* Framed fixture output.
|
|
699
|
+
*/
|
|
700
|
+
function renderFocusedFixture(fixture, options) {
|
|
701
|
+
if (fixture === "sparkline") {
|
|
702
|
+
return renderSparklineFixture(options);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (fixture === "spinner") {
|
|
706
|
+
return renderSpinnerFixture(options);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return renderPatterns(options, fixture);
|
|
710
|
+
}
|
|
711
|
+
|
|
679
712
|
/**
|
|
680
713
|
* Add gallery-only framing around one rendered example.
|
|
681
714
|
*
|
|
@@ -719,6 +752,9 @@ function renderPrimitives(options) {
|
|
|
719
752
|
"Progress bars",
|
|
720
753
|
...renderProgressExamples(options),
|
|
721
754
|
"",
|
|
755
|
+
"Spinner",
|
|
756
|
+
renderSpinnerExample(options),
|
|
757
|
+
"",
|
|
722
758
|
"Bar charts",
|
|
723
759
|
renderBarChartExample(options),
|
|
724
760
|
"",
|
|
@@ -736,6 +772,11 @@ function renderPrimitives(options) {
|
|
|
736
772
|
"",
|
|
737
773
|
"Feedback",
|
|
738
774
|
hint(`Run ${span("npm run docs:readme", "info", options)}`, options),
|
|
775
|
+
labelledLine("Notice", "Reusable labelled output", {
|
|
776
|
+
...options,
|
|
777
|
+
icon: "!",
|
|
778
|
+
tone: "warning",
|
|
779
|
+
}),
|
|
739
780
|
emptyState("No results", "tone: muted", options),
|
|
740
781
|
errorBlock("Failed", ["tone: danger"], {
|
|
741
782
|
...options,
|
|
@@ -773,6 +814,23 @@ function renderPrimitives(options) {
|
|
|
773
814
|
labelWidth: 8,
|
|
774
815
|
result: resultTypes.FAILED,
|
|
775
816
|
}),
|
|
817
|
+
row(
|
|
818
|
+
"Description",
|
|
819
|
+
"This value wraps at the default width so long diagnostic details remain readable in a terminal.",
|
|
820
|
+
{
|
|
821
|
+
...options,
|
|
822
|
+
labelWidth: 11,
|
|
823
|
+
},
|
|
824
|
+
),
|
|
825
|
+
row(
|
|
826
|
+
"Raw output",
|
|
827
|
+
"This long value keeps its original line because wrapping is disabled for raw output.",
|
|
828
|
+
{
|
|
829
|
+
...options,
|
|
830
|
+
labelWidth: 11,
|
|
831
|
+
wrap: false,
|
|
832
|
+
},
|
|
833
|
+
),
|
|
776
834
|
"",
|
|
777
835
|
"Row groups",
|
|
778
836
|
rowGroup({
|
|
@@ -795,6 +853,10 @@ function renderPrimitives(options) {
|
|
|
795
853
|
result: resultTypes.FAILED,
|
|
796
854
|
value: "is 22.3 KB, above the 12.0 KB budget",
|
|
797
855
|
},
|
|
856
|
+
{
|
|
857
|
+
label: "Description",
|
|
858
|
+
value: "This group value inherits the default wrapping used by each row in the group.",
|
|
859
|
+
},
|
|
798
860
|
],
|
|
799
861
|
}),
|
|
800
862
|
].join("\n");
|
|
@@ -912,6 +974,26 @@ function renderSparklineExample(options) {
|
|
|
912
974
|
.join("\n");
|
|
913
975
|
}
|
|
914
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Render static loading, success, and fail frames for the spinner primitive.
|
|
979
|
+
*
|
|
980
|
+
* The gallery shows fixed snapshots rather than a live animation, so no timer is started.
|
|
981
|
+
*
|
|
982
|
+
* @param {object} options
|
|
983
|
+
* Rendering options.
|
|
984
|
+
* @returns {string}
|
|
985
|
+
* Spinner state examples.
|
|
986
|
+
*/
|
|
987
|
+
function renderSpinnerExample(options) {
|
|
988
|
+
const label = "Running task";
|
|
989
|
+
|
|
990
|
+
return [
|
|
991
|
+
renderSpinnerFrame(label, 0, options),
|
|
992
|
+
status(resultTypes.SUCCESS, "", { ...options, label }),
|
|
993
|
+
status(resultTypes.FAILED, "", { ...options, label }),
|
|
994
|
+
].join("\n");
|
|
995
|
+
}
|
|
996
|
+
|
|
915
997
|
/**
|
|
916
998
|
* Render common progress values and tones.
|
|
917
999
|
*
|
package/src/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export {
|
|
|
29
29
|
|
|
30
30
|
export { background, foreground, stripAnsi, style } from "./formatters/ansi.js";
|
|
31
31
|
export { defaultWidth, minimumWidth, normaliseWidth } from "./formatters/width.js";
|
|
32
|
+
export { renderSpinnerFrame, runSpinner, spinner } from "./interactive/spinner.js";
|
|
32
33
|
export { agentTranscript } from "./patterns/agent-transcript.js";
|
|
33
34
|
export { auditFinding } from "./patterns/audit-finding.js";
|
|
34
35
|
export { commandResult } from "./patterns/command-result.js";
|
|
@@ -42,6 +43,7 @@ export { barChart } from "./primitives/bar-chart.js";
|
|
|
42
43
|
export { chip } from "./primitives/chip.js";
|
|
43
44
|
export { divider } from "./primitives/divider.js";
|
|
44
45
|
export { emptyState, errorBlock, hint } from "./primitives/feedback.js";
|
|
46
|
+
export { labelledLine } from "./primitives/labelled-line.js";
|
|
45
47
|
export { panel } from "./primitives/panel.js";
|
|
46
48
|
export { progressBar } from "./primitives/progress-bar.js";
|
|
47
49
|
export { row } from "./primitives/row.js";
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { foreground } from "../formatters/ansi.js";
|
|
2
|
+
import { profiles } from "../profiles/profiles.js";
|
|
3
|
+
import { status } from "../primitives/status.js";
|
|
4
|
+
import { resultTypes } from "../theme/results.js";
|
|
5
|
+
|
|
6
|
+
// Frame glyphs cycled while a spinner is animating.
|
|
7
|
+
const unicodeFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
8
|
+
const asciiFrames = ["-", "\\", "|", "/"];
|
|
9
|
+
|
|
10
|
+
// Time between animated frame redraws.
|
|
11
|
+
const frameIntervalMs = 80;
|
|
12
|
+
|
|
13
|
+
// Built from a character code, rather than a string escape, so the control byte stays visible in diffs.
|
|
14
|
+
const escapeCharacter = String.fromCharCode(27);
|
|
15
|
+
|
|
16
|
+
// ANSI cursor and line-clear sequences used only on an interactive TTY.
|
|
17
|
+
const hideCursor = `${escapeCharacter}[?25l`;
|
|
18
|
+
const showCursor = `${escapeCharacter}[?25h`;
|
|
19
|
+
const clearLine = `\r${escapeCharacter}[2K`;
|
|
20
|
+
|
|
21
|
+
// Profiles that always use the static, non-animated spinner output.
|
|
22
|
+
const nonInteractiveProfiles = new Set([
|
|
23
|
+
profiles.AGENT,
|
|
24
|
+
profiles.CI,
|
|
25
|
+
profiles.JSON,
|
|
26
|
+
profiles.PLAIN,
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Spinner currently writing output; only one may be active at a time.
|
|
30
|
+
let runningSpinner = null;
|
|
31
|
+
|
|
32
|
+
// Whether the process-exit cleanup listener has been registered.
|
|
33
|
+
let cleanupRegistered = false;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start a spinner and return a manual handle for advanced lifecycle control.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} text
|
|
39
|
+
* Initial spinner text.
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* Rendering and capability options.
|
|
42
|
+
* @param {boolean} options.colour
|
|
43
|
+
* Whether ANSI colour should be applied.
|
|
44
|
+
* @param {boolean} options.isTty
|
|
45
|
+
* Whether stdout is an interactive terminal.
|
|
46
|
+
* @param {string} options.profile
|
|
47
|
+
* Resolved output profile.
|
|
48
|
+
* @param {object} options.stream
|
|
49
|
+
* Writable stream the spinner writes to. Defaults to `process.stderr`.
|
|
50
|
+
* @param {boolean} options.unicode
|
|
51
|
+
* Whether Unicode symbols should be used.
|
|
52
|
+
* @returns {object}
|
|
53
|
+
* Handle with `update`, `succeed`, `fail`, and `stop` methods.
|
|
54
|
+
*/
|
|
55
|
+
export function spinner(text, options = {}) {
|
|
56
|
+
if (runningSpinner !== null) {
|
|
57
|
+
throw new Error("A spinner is already active; stop it before starting another.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const state = {
|
|
61
|
+
active: true,
|
|
62
|
+
frameIndex: 0,
|
|
63
|
+
interactive: isInteractive(options),
|
|
64
|
+
options,
|
|
65
|
+
stream: options.stream ?? process.stderr,
|
|
66
|
+
text,
|
|
67
|
+
timer: null,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
runningSpinner = state;
|
|
71
|
+
registerCleanup();
|
|
72
|
+
startSpinnerState(state);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
fail: (finalText) => finishSpinnerState(state, resultTypes.FAILED, finalText),
|
|
76
|
+
stop: () => stopSpinnerState(state),
|
|
77
|
+
succeed: (finalText) => finishSpinnerState(state, resultTypes.SUCCESS, finalText),
|
|
78
|
+
update: (nextText) => updateSpinnerState(state, nextText),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run an async task behind a spinner, resolving or rejecting it unchanged.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} text
|
|
86
|
+
* Spinner text shown while the task runs.
|
|
87
|
+
* @param {Function} fn
|
|
88
|
+
* Async task to run.
|
|
89
|
+
* @param {object} options
|
|
90
|
+
* Rendering and capability options, forwarded to `spinner()`.
|
|
91
|
+
* @returns {Promise<*>}
|
|
92
|
+
* Resolved value of `fn`, or its rejection rethrown after cleanup.
|
|
93
|
+
*/
|
|
94
|
+
export async function runSpinner(text, fn, options = {}) {
|
|
95
|
+
const handle = spinner(text, options);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = await fn();
|
|
99
|
+
|
|
100
|
+
handle.succeed();
|
|
101
|
+
|
|
102
|
+
return result;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
handle.fail();
|
|
105
|
+
|
|
106
|
+
throw error;
|
|
107
|
+
} finally {
|
|
108
|
+
handle.stop();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Render one animated spinner frame without starting a timer.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} text
|
|
116
|
+
* Spinner text to show alongside the frame glyph.
|
|
117
|
+
* @param {number} frameIndex
|
|
118
|
+
* Frame position to render.
|
|
119
|
+
* @param {object} options
|
|
120
|
+
* Rendering options.
|
|
121
|
+
* @param {boolean} options.colour
|
|
122
|
+
* Whether ANSI colour should be applied.
|
|
123
|
+
* @param {boolean} options.unicode
|
|
124
|
+
* Whether Unicode symbols should be used.
|
|
125
|
+
* @returns {string}
|
|
126
|
+
* Rendered frame line.
|
|
127
|
+
*/
|
|
128
|
+
export function renderSpinnerFrame(text, frameIndex, options = {}) {
|
|
129
|
+
const frames = options.unicode === false ? asciiFrames : unicodeFrames;
|
|
130
|
+
const glyph = frames[frameIndex % frames.length];
|
|
131
|
+
const colouredGlyph = foreground(glyph, "info", options);
|
|
132
|
+
|
|
133
|
+
return `${colouredGlyph} ${text}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Determine whether a spinner should animate on the current output target.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} options
|
|
140
|
+
* Rendering and capability options.
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
* Whether the spinner can animate.
|
|
143
|
+
*/
|
|
144
|
+
function isInteractive(options) {
|
|
145
|
+
return options.isTty === true && !nonInteractiveProfiles.has(options.profile);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Write the initial spinner output for a state, starting the frame timer when interactive.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} state
|
|
152
|
+
* Spinner state to start.
|
|
153
|
+
* @returns {void}
|
|
154
|
+
*/
|
|
155
|
+
function startSpinnerState(state) {
|
|
156
|
+
if (!state.interactive) {
|
|
157
|
+
state.stream.write(
|
|
158
|
+
`${status(resultTypes.INFO, "", { ...state.options, label: state.text })}\n`,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
state.stream.write(hideCursor);
|
|
165
|
+
renderFrame(state);
|
|
166
|
+
|
|
167
|
+
state.timer = setInterval(() => tickSpinnerState(state), frameIntervalMs);
|
|
168
|
+
state.timer.unref?.();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Advance and redraw the animated frame for a state.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} state
|
|
175
|
+
* Spinner state to advance.
|
|
176
|
+
* @returns {void}
|
|
177
|
+
*/
|
|
178
|
+
function tickSpinnerState(state) {
|
|
179
|
+
state.frameIndex += 1;
|
|
180
|
+
|
|
181
|
+
renderFrame(state);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Redraw the current animated frame in place.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} state
|
|
188
|
+
* Spinner state to render.
|
|
189
|
+
* @returns {void}
|
|
190
|
+
*/
|
|
191
|
+
function renderFrame(state) {
|
|
192
|
+
state.stream.write(
|
|
193
|
+
`${clearLine}${renderSpinnerFrame(state.text, state.frameIndex, state.options)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Update the text shown by an active spinner.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} state
|
|
201
|
+
* Spinner state to update.
|
|
202
|
+
* @param {string} nextText
|
|
203
|
+
* Replacement spinner text.
|
|
204
|
+
* @returns {void}
|
|
205
|
+
*/
|
|
206
|
+
function updateSpinnerState(state, nextText) {
|
|
207
|
+
if (!state.active) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
state.text = nextText;
|
|
212
|
+
|
|
213
|
+
if (state.interactive) {
|
|
214
|
+
renderFrame(state);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Stop a spinner without printing a result line.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} state
|
|
222
|
+
* Spinner state to stop.
|
|
223
|
+
* @returns {void}
|
|
224
|
+
*/
|
|
225
|
+
function stopSpinnerState(state) {
|
|
226
|
+
if (!state.active) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
state.active = false;
|
|
231
|
+
clearSpinnerTimer(state);
|
|
232
|
+
|
|
233
|
+
if (state.interactive) {
|
|
234
|
+
state.stream.write(`${clearLine}${showCursor}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (runningSpinner === state) {
|
|
238
|
+
runningSpinner = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Stop a spinner and print a success or fail result line.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} state
|
|
246
|
+
* Spinner state to finish.
|
|
247
|
+
* @param {string} resultType
|
|
248
|
+
* Result type to display, from `resultTypes`.
|
|
249
|
+
* @param {string} finalText
|
|
250
|
+
* Optional replacement text for the result line.
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
function finishSpinnerState(state, resultType, finalText) {
|
|
254
|
+
if (!state.active) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
state.active = false;
|
|
259
|
+
clearSpinnerTimer(state);
|
|
260
|
+
|
|
261
|
+
const label = finalText ?? state.text;
|
|
262
|
+
const line = status(resultType, "", { ...state.options, label });
|
|
263
|
+
|
|
264
|
+
if (state.interactive) {
|
|
265
|
+
state.stream.write(`${clearLine}${showCursor}${line}\n`);
|
|
266
|
+
} else {
|
|
267
|
+
state.stream.write(`${line}\n`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (runningSpinner === state) {
|
|
271
|
+
runningSpinner = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Clear an active state's frame timer, when one exists.
|
|
277
|
+
*
|
|
278
|
+
* @param {object} state
|
|
279
|
+
* Spinner state to clear.
|
|
280
|
+
* @returns {void}
|
|
281
|
+
*/
|
|
282
|
+
function clearSpinnerTimer(state) {
|
|
283
|
+
if (state.timer !== null) {
|
|
284
|
+
clearInterval(state.timer);
|
|
285
|
+
state.timer = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Restore the cursor for an active, interactive spinner, stop its frame timer, and clear the
|
|
291
|
+
* running reference.
|
|
292
|
+
*
|
|
293
|
+
* @returns {void}
|
|
294
|
+
*/
|
|
295
|
+
function restoreRunningSpinner() {
|
|
296
|
+
if (runningSpinner !== null) {
|
|
297
|
+
clearSpinnerTimer(runningSpinner);
|
|
298
|
+
|
|
299
|
+
if (runningSpinner.interactive) {
|
|
300
|
+
runningSpinner.stream.write(`${clearLine}${showCursor}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
runningSpinner = null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Restore the cursor on SIGINT, then re-raise the signal so the process still terminates with
|
|
309
|
+
* Node's default SIGINT exit code. A listener that did not re-raise would silently replace that
|
|
310
|
+
* default termination, leaving the process running whenever other work still holds the event loop
|
|
311
|
+
* open.
|
|
312
|
+
*
|
|
313
|
+
* @returns {void}
|
|
314
|
+
*/
|
|
315
|
+
function handleSpinnerSigint() {
|
|
316
|
+
restoreRunningSpinner();
|
|
317
|
+
process.removeListener("SIGINT", handleSpinnerSigint);
|
|
318
|
+
process.kill(process.pid, "SIGINT");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Register process-exit and SIGINT listeners, once, that restore the cursor for an interrupted spinner.
|
|
323
|
+
*
|
|
324
|
+
* @returns {void}
|
|
325
|
+
*/
|
|
326
|
+
function registerCleanup() {
|
|
327
|
+
if (cleanupRegistered) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
cleanupRegistered = true;
|
|
332
|
+
|
|
333
|
+
process.on("exit", restoreRunningSpinner);
|
|
334
|
+
process.on("SIGINT", handleSpinnerSigint);
|
|
335
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { foreground, style } from "../formatters/ansi.js";
|
|
2
2
|
import { getResultSymbol, resultTypes } from "../theme/results.js";
|
|
3
|
+
import { labelledLine } from "./labelled-line.js";
|
|
3
4
|
import { panel } from "./panel.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -15,15 +16,11 @@ import { panel } from "./panel.js";
|
|
|
15
16
|
* Rendered hint.
|
|
16
17
|
*/
|
|
17
18
|
export function hint(message = "", options = {}) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const renderedPrefix = style(foreground(prefix, "info", options), "bold", options);
|
|
25
|
-
|
|
26
|
-
return `${renderedPrefix} ${message}`;
|
|
19
|
+
return labelledLine("Hint", message, {
|
|
20
|
+
icon: "i",
|
|
21
|
+
tone: "info",
|
|
22
|
+
...options,
|
|
23
|
+
});
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
/**
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { foreground, style } from "../formatters/ansi.js";
|
|
2
|
+
import { wrapText } from "../formatters/wrap.js";
|
|
3
|
+
|
|
4
|
+
// Falls back to the informational tone when no colour token is given.
|
|
5
|
+
const defaultColour = "info";
|
|
6
|
+
|
|
7
|
+
// Sits between the colon-terminated prefix and the message.
|
|
8
|
+
const messageSeparator = " ";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render an icon, label, and message as one wrapped line.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} label
|
|
14
|
+
* Label shown before the message; omit for an unlabelled line.
|
|
15
|
+
* @param {string} message
|
|
16
|
+
* Message text.
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* Rendering options.
|
|
19
|
+
* @param {boolean} options.colour
|
|
20
|
+
* Whether ANSI colour should be applied.
|
|
21
|
+
* @param {string} options.icon
|
|
22
|
+
* Optional icon shown before the label.
|
|
23
|
+
* @param {string} options.tone
|
|
24
|
+
* Colour token for the icon and label when colour is enabled; defaults to "info".
|
|
25
|
+
* @param {number} options.width
|
|
26
|
+
* Total line width; the message width is this minus the label and separator.
|
|
27
|
+
* @param {boolean} options.wrap
|
|
28
|
+
* Set to false to keep the message on one line instead of wrapping it.
|
|
29
|
+
* @param {number} options.wrapWidth
|
|
30
|
+
* Column width at which the message wraps onto a new line.
|
|
31
|
+
* @returns {string}
|
|
32
|
+
* Rendered labelled line.
|
|
33
|
+
*/
|
|
34
|
+
export function labelledLine(label = "", message = "", options = {}) {
|
|
35
|
+
const prefixText = createPrefix(label, options);
|
|
36
|
+
const prefix = renderPrefix(prefixText, options);
|
|
37
|
+
const messageText = String(message);
|
|
38
|
+
|
|
39
|
+
if (prefix === "") {
|
|
40
|
+
return messageText;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (options.wrap === false) {
|
|
44
|
+
return `${prefix}${messageSeparator}${messageText}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const wrapWidth = resolveWrapWidth(prefixText, options);
|
|
48
|
+
const messageLines = wrapText(messageText, wrapWidth);
|
|
49
|
+
const continuationIndent = " ".repeat(prefixText.length + messageSeparator.length);
|
|
50
|
+
const wrappedMessage = messageLines.join(`\n${continuationIndent}`);
|
|
51
|
+
|
|
52
|
+
return `${prefix}${messageSeparator}${wrappedMessage}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Colour the icon/label prefix, sized from its plain-text length so the
|
|
57
|
+
* continuation indent lines up whether or not colour is applied.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} prefix
|
|
60
|
+
* Plain-text icon and label prefix.
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* Rendering options.
|
|
63
|
+
* @returns {string}
|
|
64
|
+
* Prefix, coloured when requested.
|
|
65
|
+
*/
|
|
66
|
+
function renderPrefix(prefix, options) {
|
|
67
|
+
if (prefix === "") {
|
|
68
|
+
return prefix;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.colour !== true) {
|
|
72
|
+
return prefix;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const colouredPrefix = foreground(prefix, resolveColour(options), options);
|
|
76
|
+
|
|
77
|
+
return style(colouredPrefix, "bold", options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the plain-text icon and label prefix.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} label
|
|
84
|
+
* Label text.
|
|
85
|
+
* @param {object} options
|
|
86
|
+
* Rendering options.
|
|
87
|
+
* @returns {string}
|
|
88
|
+
* Icon and label joined with a colon and space, or an empty string when both are empty.
|
|
89
|
+
*/
|
|
90
|
+
function createPrefix(label, options) {
|
|
91
|
+
const labelText = String(label);
|
|
92
|
+
const icon = typeof options.icon === "string" ? options.icon : "";
|
|
93
|
+
const labelPart = labelText === "" ? "" : `${labelText}:`;
|
|
94
|
+
|
|
95
|
+
return [icon, labelPart].filter((part) => part !== "").join(" ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pick the prefix's colour token.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} options
|
|
102
|
+
* Rendering options.
|
|
103
|
+
* @returns {string}
|
|
104
|
+
* Colour token, defaulting to "info" when no tone is given.
|
|
105
|
+
*/
|
|
106
|
+
function resolveColour(options) {
|
|
107
|
+
if (typeof options.tone === "string" && options.tone !== "") {
|
|
108
|
+
return options.tone;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return defaultColour;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Work out how wide the message can be before it wraps.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} prefix
|
|
118
|
+
* Plain-text icon and label prefix, used to size the continuation indent.
|
|
119
|
+
* @param {object} options
|
|
120
|
+
* Rendering options.
|
|
121
|
+
* @returns {number|undefined}
|
|
122
|
+
* Explicit or derived wrap width, or undefined to use wrapText's own default.
|
|
123
|
+
*/
|
|
124
|
+
function resolveWrapWidth(prefix, options) {
|
|
125
|
+
if (options.wrapWidth !== undefined) {
|
|
126
|
+
return options.wrapWidth;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (Number.isFinite(options.width)) {
|
|
130
|
+
return Math.max(1, options.width - prefix.length - messageSeparator.length);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
package/src/primitives/row.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { foreground } from "../formatters/ansi.js";
|
|
2
|
+
import { wrapText } from "../formatters/wrap.js";
|
|
2
3
|
import { getResultSymbol, getResultToken } from "../theme/results.js";
|
|
3
4
|
|
|
4
5
|
// Row labels use the same subdued tone as table headers by default.
|
|
@@ -7,6 +8,9 @@ const defaultLabelColour = "muted";
|
|
|
7
8
|
// Rows use a compact gap that still reads cleanly in plain terminals.
|
|
8
9
|
const defaultSeparator = " ";
|
|
9
10
|
|
|
11
|
+
// Keeps a wrapped value within a typical narrow terminal column.
|
|
12
|
+
const defaultWrapWidth = 64;
|
|
13
|
+
|
|
10
14
|
/**
|
|
11
15
|
* Render a labelled value row.
|
|
12
16
|
*
|
|
@@ -28,6 +32,12 @@ const defaultSeparator = " ";
|
|
|
28
32
|
* Text between label and value.
|
|
29
33
|
* @param {string} options.valueColour
|
|
30
34
|
* Optional colour token for the value.
|
|
35
|
+
* @param {number} options.width
|
|
36
|
+
* Total row width used to calculate the available value width.
|
|
37
|
+
* @param {boolean} options.wrap
|
|
38
|
+
* Set to false to keep the value on one line instead of wrapping it.
|
|
39
|
+
* @param {number} options.wrapWidth
|
|
40
|
+
* Column width at which a value wraps onto a new line.
|
|
31
41
|
* @returns {string}
|
|
32
42
|
* Rendered row.
|
|
33
43
|
*/
|
|
@@ -45,7 +55,22 @@ export function row(label, value = "", options = {}) {
|
|
|
45
55
|
const renderedValue = renderValue(value, options);
|
|
46
56
|
const separator = options.separator ?? defaultSeparator;
|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
if (options.wrap === false) {
|
|
59
|
+
return `${renderedLabel}${separator}${renderedValue}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const wrapWidth =
|
|
63
|
+
options.wrapWidth ??
|
|
64
|
+
(Number.isFinite(options.width)
|
|
65
|
+
? Math.max(1, options.width - labelWidth - separator.length)
|
|
66
|
+
: defaultWrapWidth);
|
|
67
|
+
|
|
68
|
+
const valueLines = wrapText(value, wrapWidth);
|
|
69
|
+
const continuationIndent = " ".repeat(labelWidth + separator.length);
|
|
70
|
+
const renderedValueLines = valueLines.map((line) => renderValue(line, options));
|
|
71
|
+
const wrappedValue = renderedValueLines.join(`\n${continuationIndent}`);
|
|
72
|
+
|
|
73
|
+
return `${renderedLabel}${separator}${wrappedValue}`;
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
/**
|