@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/cli-style",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Shared terminal output styles for Lewis Howles tools, diagnostics, and package CLIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }