[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

wip: move spinner to use prompt API

James Garbutt (Jan 29, 2026, 5:44 PM UTC) f95d408e bc31ee8f

+221 -159
+2
packages/core/src/index.ts
··· 14 14 export { default as SelectPrompt } from './prompts/select.js'; 15 15 export type { SelectKeyOptions } from './prompts/select-key.js'; 16 16 export { default as SelectKeyPrompt } from './prompts/select-key.js'; 17 + export type { SpinnerOptions } from './prompts/spinner.js'; 18 + export { default as SpinnerPrompt } from './prompts/spinner.js'; 17 19 export type { TextOptions } from './prompts/text.js'; 18 20 export { default as TextPrompt } from './prompts/text.js'; 19 21 export type { ClackState as State } from './types.js';
+1 -1
packages/core/src/prompts/prompt.ts
··· 270 270 this.output.write(cursor.move(-999, lines * -1)); 271 271 } 272 272 273 - private render() { 273 + protected render() { 274 274 const frame = wrapAnsi(this._render(this) ?? '', process.stdout.columns, { 275 275 hard: true, 276 276 trim: false,
+170
packages/core/src/prompts/spinner.ts
··· 1 + import Prompt, { type PromptOptions } from './prompt.js'; 2 + import {settings} from '../utils/index.js'; 3 + 4 + const removeTrailingDots = (msg: string): string => { 5 + return msg.replace(/\.+$/, ''); 6 + }; 7 + 8 + export interface SpinnerOptions extends PromptOptions<undefined, SpinnerPrompt> { 9 + indicator?: 'dots' | 'timer'; 10 + onCancel?: () => void; 11 + cancelMessage?: string; 12 + errorMessage?: string; 13 + frames: string[]; 14 + delay: number; 15 + styleFrame?: (frame: string) => string; 16 + } 17 + 18 + export default class SpinnerPrompt extends Prompt<undefined> { 19 + #isCancelled = false; 20 + #isActive = false; 21 + #startTime: number = 0; 22 + #frameIndex: number = 0; 23 + #indicatorTimer: number = 0; 24 + #intervalId: ReturnType<typeof setInterval> | undefined; 25 + #delay: number; 26 + #frames: string[]; 27 + #cancelMessage: string; 28 + #errorMessage: string; 29 + #onCancel?: () => void; 30 + #message: string = ''; 31 + #silentExit: boolean = false; 32 + #exitCode: number | undefined = undefined; 33 + 34 + constructor(opts: SpinnerOptions) { 35 + super(opts); 36 + this.#delay = opts.delay; 37 + this.#frames = opts.frames; 38 + this.#cancelMessage = opts.cancelMessage ?? settings.messages.cancel; 39 + this.#errorMessage = opts.errorMessage ?? settings.messages.error; 40 + this.#onCancel = opts.onCancel; 41 + 42 + this.on('cancel', () => this.#onExit(1)); 43 + } 44 + 45 + start(msg?: string): void { 46 + this.#isActive = true; 47 + this.#message = removeTrailingDots(msg ?? ''); 48 + this.#startTime = performance.now(); 49 + this.#frameIndex = 0; 50 + this.#indicatorTimer = 0; 51 + 52 + this.#intervalId = setInterval(() => this.#onInterval(), this.#delay); 53 + 54 + this.#addGlobalListeners(); 55 + } 56 + 57 + stop(msg?: string, exitCode?: number, silent?: boolean): void { 58 + if (!this.#isActive) { 59 + return; 60 + } 61 + 62 + this.#isActive = false; 63 + this.#silentExit = silent === true; 64 + this.#exitCode = exitCode; 65 + 66 + if (msg !== undefined) { 67 + this.#message = msg; 68 + } 69 + 70 + if (this.#intervalId) { 71 + clearInterval(this.#intervalId); 72 + this.#intervalId = undefined; 73 + } 74 + 75 + this.#removeGlobalListeners(); 76 + this.state = 'cancel'; 77 + this.render(); 78 + this.close(); 79 + } 80 + 81 + get isCancelled(): boolean { 82 + return this.#isCancelled; 83 + } 84 + 85 + get message(): string { 86 + return this.#message; 87 + } 88 + 89 + set message(msg: string) { 90 + this.#message = removeTrailingDots(msg); 91 + } 92 + 93 + get exitCode(): number | undefined { 94 + return this.#exitCode; 95 + } 96 + 97 + get frameIndex(): number { 98 + return this.#frameIndex; 99 + } 100 + 101 + get indicatorTimer(): number { 102 + return this.#indicatorTimer; 103 + } 104 + 105 + get isActive(): boolean { 106 + return this.#isActive; 107 + } 108 + 109 + get silentExit(): boolean { 110 + return this.#silentExit; 111 + } 112 + 113 + getFormattedTimer(): string { 114 + const duration = (performance.now() - this.#startTime) / 1000; 115 + const min = Math.floor(duration / 60); 116 + const secs = Math.floor(duration % 60); 117 + return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; 118 + } 119 + 120 + #onInterval(): void { 121 + this.#frameIndex = this.#frameIndex + 1 < this.#frames.length ? this.#frameIndex + 1 : 0; 122 + // indicator increase by 1 every 8 frames 123 + this.#indicatorTimer = this.#indicatorTimer < 4 ? this.#indicatorTimer + 0.125 : 0; 124 + 125 + this.render(); 126 + } 127 + 128 + #onProcessError: () => void = () => { 129 + this.#onExit(2); 130 + }; 131 + 132 + #onProcessSignal: () => void = () => { 133 + this.#onExit(1); 134 + }; 135 + 136 + #onExit: (exitCode: number) => void = (exitCode) => { 137 + this.#exitCode = exitCode; 138 + if (exitCode > 1) { 139 + this.#message = this.#errorMessage; 140 + } else { 141 + this.#message = this.#cancelMessage; 142 + } 143 + this.#isCancelled = exitCode === 1; 144 + if (this.#isActive) { 145 + this.stop(this.#message, exitCode); 146 + if (this.#isCancelled && this.#onCancel) { 147 + this.#onCancel(); 148 + } 149 + } 150 + }; 151 + 152 + #addGlobalListeners(): void { 153 + // Reference: https://nodejs.org/api/process.html#event-uncaughtexception 154 + process.on('uncaughtExceptionMonitor', this.#onProcessError); 155 + // Reference: https://nodejs.org/api/process.html#event-unhandledrejection 156 + process.on('unhandledRejection', this.#onProcessError); 157 + // Reference Signal Events: https://nodejs.org/api/process.html#signal-events 158 + process.on('SIGINT', this.#onProcessSignal); 159 + process.on('SIGTERM', this.#onProcessSignal); 160 + process.on('exit', this.#onExit); 161 + } 162 + 163 + #removeGlobalListeners(): void { 164 + process.removeListener('uncaughtExceptionMonitor', this.#onProcessError); 165 + process.removeListener('unhandledRejection', this.#onProcessError); 166 + process.removeListener('SIGINT', this.#onProcessSignal); 167 + process.removeListener('SIGTERM', this.#onProcessSignal); 168 + process.removeListener('exit', this.#onExit); 169 + } 170 + }
+48 -158
packages/prompts/src/spinner.ts
··· 1 - import { block, getColumns, settings } from '@clack/core'; 2 - import { wrapAnsi } from 'fast-wrap-ansi'; 1 + import { SpinnerPrompt } from '@clack/core'; 3 2 import color from 'picocolors'; 4 - import { cursor, erase } from 'sisteransi'; 5 3 import { 6 4 type CommonOptions, 7 5 isCI as isCIFn, ··· 37 35 export const spinner = ({ 38 36 indicator = 'dots', 39 37 onCancel, 40 - output = process.stdout, 41 38 cancelMessage, 42 39 errorMessage, 43 40 frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], 44 41 delay = unicode ? 80 : 120, 45 - signal, 46 42 ...opts 47 43 }: SpinnerOptions = {}): SpinnerResult => { 48 44 const isCI = isCIFn(); 49 - 50 - let unblock: () => void; 51 - let loop: NodeJS.Timeout; 52 - let isSpinnerActive = false; 53 - let isCancelled = false; 54 - let _message = ''; 55 - let _prevMessage: string | undefined; 56 - let _origin: number = performance.now(); 57 - const columns = getColumns(output); 58 45 const styleFn = opts?.styleFrame ?? defaultStyleFn; 59 - 60 - const handleExit = (code: number) => { 61 - const msg = 62 - code > 1 63 - ? (errorMessage ?? settings.messages.error) 64 - : (cancelMessage ?? settings.messages.cancel); 65 - isCancelled = code === 1; 66 - if (isSpinnerActive) { 67 - _stop(msg, code); 68 - if (isCancelled && typeof onCancel === 'function') { 69 - onCancel(); 70 - } 71 - } 72 - }; 73 - 74 - const errorEventHandler = () => handleExit(2); 75 - const signalEventHandler = () => handleExit(1); 76 - 77 - const registerHooks = () => { 78 - // Reference: https://nodejs.org/api/process.html#event-uncaughtexception 79 - process.on('uncaughtExceptionMonitor', errorEventHandler); 80 - // Reference: https://nodejs.org/api/process.html#event-unhandledrejection 81 - process.on('unhandledRejection', errorEventHandler); 82 - // Reference Signal Events: https://nodejs.org/api/process.html#signal-events 83 - process.on('SIGINT', signalEventHandler); 84 - process.on('SIGTERM', signalEventHandler); 85 - process.on('exit', handleExit); 86 - 87 - if (signal) { 88 - signal.addEventListener('abort', signalEventHandler); 89 - } 90 - }; 91 - 92 - const clearHooks = () => { 93 - process.removeListener('uncaughtExceptionMonitor', errorEventHandler); 94 - process.removeListener('unhandledRejection', errorEventHandler); 95 - process.removeListener('SIGINT', signalEventHandler); 96 - process.removeListener('SIGTERM', signalEventHandler); 97 - process.removeListener('exit', handleExit); 98 - 99 - if (signal) { 100 - signal.removeEventListener('abort', signalEventHandler); 101 - } 102 - }; 103 - 104 - const clearPrevMessage = () => { 105 - if (_prevMessage === undefined) return; 106 - if (isCI) output.write('\n'); 107 - const wrapped = wrapAnsi(_prevMessage, columns, { 108 - hard: true, 109 - trim: false, 110 - }); 111 - const prevLines = wrapped.split('\n'); 112 - if (prevLines.length > 1) { 113 - output.write(cursor.up(prevLines.length - 1)); 114 - } 115 - output.write(cursor.to(0)); 116 - output.write(erase.down()); 117 - }; 118 - 119 - const removeTrailingDots = (msg: string): string => { 120 - return msg.replace(/\.+$/, ''); 121 - }; 122 - 123 - const formatTimer = (origin: number): string => { 124 - const duration = (performance.now() - origin) / 1000; 125 - const min = Math.floor(duration / 60); 126 - const secs = Math.floor(duration % 60); 127 - return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; 128 - }; 129 - 130 - const start = (msg = ''): void => { 131 - isSpinnerActive = true; 132 - unblock = block({ output }); 133 - _message = removeTrailingDots(msg); 134 - _origin = performance.now(); 135 - output.write(`${color.gray(S_BAR)}\n`); 136 - let frameIndex = 0; 137 - let indicatorTimer = 0; 138 - registerHooks(); 139 - loop = setInterval(() => { 140 - if (isCI && _message === _prevMessage) { 141 - return; 46 + const prompt = new SpinnerPrompt({ 47 + indicator, 48 + onCancel, 49 + cancelMessage, 50 + errorMessage, 51 + frames, 52 + delay, 53 + output: opts.output, 54 + signal: opts.signal, 55 + input: opts.input, 56 + render() { 57 + if (!this.isActive) { 58 + if (this.silentExit) { 59 + return ''; 60 + } 61 + const step = 62 + this.exitCode === 0 63 + ? color.green(S_STEP_SUBMIT) 64 + : this.exitCode === 1 65 + ? color.red(S_STEP_CANCEL) 66 + : color.red(S_STEP_ERROR); 67 + if (indicator === 'timer') { 68 + return `${step} ${this.message} ${this.getFormattedTimer()}\n`; 69 + } else { 70 + return `${step} ${this.message}\n`; 71 + } 142 72 } 143 - clearPrevMessage(); 144 - _prevMessage = _message; 145 - const frame = styleFn(frames[frameIndex]); 73 + const frame = styleFn(frames[this.frameIndex]); 74 + const message = this.message; 146 75 let outputMessage: string; 147 - 148 76 if (isCI) { 149 - outputMessage = `${frame} ${_message}...`; 77 + outputMessage = `${frame} ${message}...`; 150 78 } else if (indicator === 'timer') { 151 - outputMessage = `${frame} ${_message} ${formatTimer(_origin)}`; 79 + outputMessage = `${frame} ${message} ${this.getFormattedTimer()}`; 152 80 } else { 153 - const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3); 154 - outputMessage = `${frame} ${_message}${loadingDots}`; 81 + const loadingDots = '.'.repeat(Math.floor(this.indicatorTimer)).slice(0, 3); 82 + outputMessage = `${frame} ${message}${loadingDots}`; 155 83 } 84 + return `${color.gray(S_BAR)}\n${outputMessage}`; 85 + }, 86 + }); 156 87 157 - const wrapped = wrapAnsi(outputMessage, columns, { 158 - hard: true, 159 - trim: false, 160 - }); 161 - output.write(wrapped); 88 + prompt.prompt(); 162 89 163 - frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; 164 - // indicator increase by 1 every 8 frames 165 - indicatorTimer = indicatorTimer < 4 ? indicatorTimer + 0.125 : 0; 166 - }, delay); 167 - }; 168 - 169 - const _stop = (msg = '', code = 0, silent: boolean = false): void => { 170 - if (!isSpinnerActive) return; 171 - isSpinnerActive = false; 172 - clearInterval(loop); 173 - clearPrevMessage(); 174 - const step = 175 - code === 0 176 - ? color.green(S_STEP_SUBMIT) 177 - : code === 1 178 - ? color.red(S_STEP_CANCEL) 179 - : color.red(S_STEP_ERROR); 180 - _message = msg ?? _message; 181 - if (!silent) { 182 - if (indicator === 'timer') { 183 - output.write(`${step} ${_message} ${formatTimer(_origin)}\n`); 184 - } else { 185 - output.write(`${step} ${_message}\n`); 90 + return { 91 + start: (msg?: string) => prompt.start(msg), 92 + stop: (msg?: string) => prompt.stop(msg, 0), 93 + message: (msg?: string) => { 94 + if (msg !== undefined) { 95 + prompt.message = msg; 186 96 } 187 - } 188 - clearHooks(); 189 - unblock(); 190 - }; 191 - 192 - const stop = (msg = ''): void => _stop(msg, 0); 193 - const cancel = (msg = ''): void => _stop(msg, 1); 194 - const error = (msg = ''): void => _stop(msg, 2); 195 - // TODO (43081j): this will leave the initial S_BAR since we purposely 196 - // don't erase that in `clearPrevMessage`. In future, we may want to treat 197 - // `clear` as a special case and remove the bar too. 198 - const clear = (): void => _stop('', 0, true); 199 - 200 - const message = (msg = ''): void => { 201 - _message = removeTrailingDots(msg ?? _message); 202 - }; 203 - 204 - return { 205 - start, 206 - stop, 207 - message, 208 - cancel, 209 - error, 210 - clear, 97 + }, 98 + cancel: (msg: string = '') => prompt.stop(msg, 1), 99 + error: (msg: string = '') => prompt.stop(msg, 2), 100 + clear: () => prompt.stop('', 0, true), 211 101 get isCancelled() { 212 - return isCancelled; 102 + return prompt.isCancelled; 213 103 }, 214 104 }; 215 105 };