@lewishowles/cli-style 0.10.0 → 0.11.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 +6 -0
- package/README.md +25 -0
- package/package.json +1 -1
- package/src/create-cli-style.js +14 -0
- package/src/gallery/render-gallery.js +60 -5
- package/src/index.js +1 -0
- package/src/interactive/spinner.js +335 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.11.0 - 2026-07-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
5
11
|
## 0.10.0 - 2026-07-20
|
|
6
12
|
|
|
7
13
|
### 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
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
|
},
|
|
@@ -8,6 +8,7 @@ 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";
|
|
@@ -131,6 +132,7 @@ export const galleryFixtures = [
|
|
|
131
132
|
"next-step-block",
|
|
132
133
|
"diff-block",
|
|
133
134
|
"sparkline",
|
|
135
|
+
"spinner",
|
|
134
136
|
];
|
|
135
137
|
|
|
136
138
|
/**
|
|
@@ -183,11 +185,7 @@ function renderVariant(variant, options, request) {
|
|
|
183
185
|
const sections = [];
|
|
184
186
|
|
|
185
187
|
if (request.fixture !== undefined) {
|
|
186
|
-
sections.push(
|
|
187
|
-
request.fixture === "sparkline"
|
|
188
|
-
? renderSparklineFixture(resolvedVariant.options)
|
|
189
|
-
: renderPatterns(resolvedVariant.options, request.fixture),
|
|
190
|
-
);
|
|
188
|
+
sections.push(renderFocusedFixture(request.fixture, resolvedVariant.options));
|
|
191
189
|
} else {
|
|
192
190
|
if (request.section === undefined || request.section === "primitives") {
|
|
193
191
|
sections.push(renderPrimitives(resolvedVariant.options));
|
|
@@ -676,6 +674,40 @@ function renderSparklineFixture(options) {
|
|
|
676
674
|
return ["Sparkline", frameExample(renderSparklineExample(options), options)].join("\n");
|
|
677
675
|
}
|
|
678
676
|
|
|
677
|
+
/**
|
|
678
|
+
* Render the focused spinner fixture.
|
|
679
|
+
*
|
|
680
|
+
* @param {object} options
|
|
681
|
+
* Rendering options.
|
|
682
|
+
* @returns {string}
|
|
683
|
+
* Framed spinner fixture output.
|
|
684
|
+
*/
|
|
685
|
+
function renderSpinnerFixture(options) {
|
|
686
|
+
return ["Spinner", frameExample(renderSpinnerExample(options), options)].join("\n");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Render one fixture selected through `--fixture`.
|
|
691
|
+
*
|
|
692
|
+
* @param {string} fixture
|
|
693
|
+
* Requested fixture name.
|
|
694
|
+
* @param {object} options
|
|
695
|
+
* Rendering options.
|
|
696
|
+
* @returns {string}
|
|
697
|
+
* Framed fixture output.
|
|
698
|
+
*/
|
|
699
|
+
function renderFocusedFixture(fixture, options) {
|
|
700
|
+
if (fixture === "sparkline") {
|
|
701
|
+
return renderSparklineFixture(options);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (fixture === "spinner") {
|
|
705
|
+
return renderSpinnerFixture(options);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return renderPatterns(options, fixture);
|
|
709
|
+
}
|
|
710
|
+
|
|
679
711
|
/**
|
|
680
712
|
* Add gallery-only framing around one rendered example.
|
|
681
713
|
*
|
|
@@ -719,6 +751,9 @@ function renderPrimitives(options) {
|
|
|
719
751
|
"Progress bars",
|
|
720
752
|
...renderProgressExamples(options),
|
|
721
753
|
"",
|
|
754
|
+
"Spinner",
|
|
755
|
+
renderSpinnerExample(options),
|
|
756
|
+
"",
|
|
722
757
|
"Bar charts",
|
|
723
758
|
renderBarChartExample(options),
|
|
724
759
|
"",
|
|
@@ -912,6 +947,26 @@ function renderSparklineExample(options) {
|
|
|
912
947
|
.join("\n");
|
|
913
948
|
}
|
|
914
949
|
|
|
950
|
+
/**
|
|
951
|
+
* Render static loading, success, and fail frames for the spinner primitive.
|
|
952
|
+
*
|
|
953
|
+
* The gallery shows fixed snapshots rather than a live animation, so no timer is started.
|
|
954
|
+
*
|
|
955
|
+
* @param {object} options
|
|
956
|
+
* Rendering options.
|
|
957
|
+
* @returns {string}
|
|
958
|
+
* Spinner state examples.
|
|
959
|
+
*/
|
|
960
|
+
function renderSpinnerExample(options) {
|
|
961
|
+
const label = "Running task";
|
|
962
|
+
|
|
963
|
+
return [
|
|
964
|
+
renderSpinnerFrame(label, 0, options),
|
|
965
|
+
status(resultTypes.SUCCESS, "", { ...options, label }),
|
|
966
|
+
status(resultTypes.FAILED, "", { ...options, label }),
|
|
967
|
+
].join("\n");
|
|
968
|
+
}
|
|
969
|
+
|
|
915
970
|
/**
|
|
916
971
|
* Render common progress values and tones.
|
|
917
972
|
*
|
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";
|
|
@@ -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
|
+
}
|