Skip to content
6 changes: 6 additions & 0 deletions .changeset/empty-bananas-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Rework the spinner prompt to use the `Prompt` base class for rendering.
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type { SelectOptions } from './prompts/select.js';
export { default as SelectPrompt } from './prompts/select.js';
export type { SelectKeyOptions } from './prompts/select-key.js';
export { default as SelectKeyPrompt } from './prompts/select-key.js';
export type { SpinnerOptions } from './prompts/spinner.js';
export { default as SpinnerPrompt } from './prompts/spinner.js';
export type { TextOptions } from './prompts/text.js';
export { default as TextPrompt } from './prompts/text.js';
export type { ClackState as State } from './types.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export default class Prompt<TValue> {
this.output.write(cursor.move(-999, lines * -1));
}

private render() {
protected render() {
const frame = wrapAnsi(this._render(this) ?? '', process.stdout.columns, {
hard: true,
trim: false,
Expand Down
187 changes: 187 additions & 0 deletions packages/core/src/prompts/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { settings } from '../utils/index.js';
import Prompt, { type PromptOptions } from './prompt.js';

const removeTrailingDots = (msg: string): string => {
return msg.replace(/\.+$/, '');
};

export interface SpinnerOptions extends PromptOptions<undefined, SpinnerPrompt> {
indicator?: 'dots' | 'timer';
onCancel?: () => void;
cancelMessage?: string;
errorMessage?: string;
frames: string[];
delay: number;
styleFrame?: (frame: string) => string;
}

export default class SpinnerPrompt extends Prompt<undefined> {
#isCancelled = false;
#isActive = false;
#startTime: number = 0;
#frameIndex: number = 0;
#indicatorTimer: number = 0;
#intervalId: ReturnType<typeof setInterval> | undefined;
#delay: number;
#frames: string[];
#cancelMessage: string;
#errorMessage: string;
#onCancel?: () => void;
#message: string = '';
#silentExit: boolean = false;
#exitCode: number = 0;

constructor(opts: SpinnerOptions) {
super(opts);
this.#delay = opts.delay;
this.#frames = opts.frames;
this.#cancelMessage = opts.cancelMessage ?? settings.messages.cancel;
this.#errorMessage = opts.errorMessage ?? settings.messages.error;
this.#onCancel = opts.onCancel;

this.on('cancel', () => this.#onExit(1));
}

start(msg?: string): void {
if (this.#isActive) {
this.#reset();
}
this.#isActive = true;
this.#message = removeTrailingDots(msg ?? '');
this.#startTime = performance.now();
this.#frameIndex = 0;
this.#indicatorTimer = 0;

if (Number.isFinite(this.#delay)) {
this.#intervalId = setInterval(() => this.#onInterval(), this.#delay);
} else {
this.render();
}

this.#addGlobalListeners();
}

stop(msg?: string, exitCode?: number, silent?: boolean): void {
if (!this.#isActive) {
return;
}

this.#reset();
this.#silentExit = silent === true;
this.#exitCode = exitCode ?? 0;

if (msg !== undefined) {
this.#message = msg;
}

this.state = 'cancel';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do this.state = 'submit' when the exitCode === 0? Currently this would treat every exit as cancel.

this.render();
this.close();
}

get isCancelled(): boolean {
return this.#isCancelled;
}

get message(): string {
return this.#message;
}

set message(msg: string) {
this.#message = removeTrailingDots(msg);
}

get exitCode(): number | undefined {
return this.#exitCode;
}

get frameIndex(): number {
return this.#frameIndex;
}

get indicatorTimer(): number {
return this.#indicatorTimer;
}

get isActive(): boolean {
return this.#isActive;
}

get silentExit(): boolean {
return this.#silentExit;
}

getFormattedTimer(): string {
const duration = (performance.now() - this.#startTime) / 1000;
const min = Math.floor(duration / 60);
const secs = Math.floor(duration % 60);
return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`;
}

protected override _shouldSubmit(): boolean {
return false;
}

#reset(): void {
this.#isActive = false;
this.#exitCode = 0;

if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = undefined;
}

this.#removeGlobalListeners();
}

#onInterval(): void {
this.render();

this.#frameIndex = this.#frameIndex + 1 < this.#frames.length ? this.#frameIndex + 1 : 0;
// indicator increase by 1 every 8 frames
this.#indicatorTimer = this.#indicatorTimer < 4 ? this.#indicatorTimer + 0.125 : 0;
}

#onProcessError: () => void = () => {
this.#onExit(2);
};

#onProcessSignal: () => void = () => {
this.#onExit(1);
};

#onExit: (exitCode: number) => void = (exitCode) => {
this.#exitCode = exitCode;
if (exitCode > 1) {
this.#message = this.#errorMessage;
} else {
this.#message = this.#cancelMessage;
}
this.#isCancelled = exitCode === 1;
if (this.#isActive) {
this.stop(this.#message, exitCode);
if (this.#isCancelled && this.#onCancel) {
this.#onCancel();
}
}
};

#addGlobalListeners(): void {
// Reference: https://nodejs.org/api/process.html#event-uncaughtexception
process.on('uncaughtExceptionMonitor', this.#onProcessError);
// Reference: https://nodejs.org/api/process.html#event-unhandledrejection
process.on('unhandledRejection', this.#onProcessError);
// Reference Signal Events: https://nodejs.org/api/process.html#signal-events
process.on('SIGINT', this.#onProcessSignal);
process.on('SIGTERM', this.#onProcessSignal);
process.on('exit', this.#onExit);
}

#removeGlobalListeners(): void {
process.removeListener('uncaughtExceptionMonitor', this.#onProcessError);
process.removeListener('unhandledRejection', this.#onProcessError);
process.removeListener('SIGINT', this.#onProcessSignal);
process.removeListener('SIGTERM', this.#onProcessSignal);
process.removeListener('exit', this.#onExit);
}
}
151 changes: 151 additions & 0 deletions packages/core/test/prompts/spinner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as SpinnerPrompt } from '../../src/prompts/spinner.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';

describe('SpinnerPrompt', () => {
let input: MockReadable;
let output: MockWritable;
let instance: SpinnerPrompt;

beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
instance.stop();
});

test('renders render() result', () => {
instance = new SpinnerPrompt({
input,
output,
frames: ['J', 'A', 'M', 'E', 'S'],
delay: 5,
render: () => 'foo',
});
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});

describe('start', () => {
test('starts the spinner and updates frames', () => {
instance = new SpinnerPrompt({
input,
output,
frames: ['J', 'A', 'M', 'E', 'S'],
delay: 5,
render: () => 'foo',
});
instance.start('Loading');
expect(instance.message).to.equal('Loading');
expect(instance.frameIndex).to.equal(0);
expect(instance.indicatorTimer).to.equal(0);
vi.advanceTimersByTime(5);
expect(instance.frameIndex).to.equal(1);
expect(instance.indicatorTimer).to.equal(0.125);
vi.advanceTimersByTime(5);
expect(instance.frameIndex).to.equal(2);
expect(instance.indicatorTimer).to.equal(0.25);
vi.advanceTimersByTime(5);
expect(instance.frameIndex).to.equal(3);
expect(instance.indicatorTimer).to.equal(0.375);
});

test('starting again resets the spinner', () => {
instance = new SpinnerPrompt({
input,
output,
frames: ['J', 'A', 'M', 'E', 'S'],
delay: 5,
render: () => 'foo',
});
instance.start('Loading');
vi.advanceTimersByTime(10);
expect(instance.frameIndex).to.equal(2);
expect(instance.indicatorTimer).to.equal(0.25);
expect(instance.message).to.equal('Loading');
instance.start('Loading again');
expect(instance.message).to.equal('Loading again');
expect(instance.frameIndex).to.equal(0);
expect(instance.indicatorTimer).to.equal(0);
});
});

describe('stop', () => {
test('stops the spinner and sets message', () => {
instance = new SpinnerPrompt({
input,
output,
frames: ['J', 'A', 'M', 'E', 'S'],
delay: 5,
render: () => 'foo',
});
instance.start('Loading');
vi.advanceTimersByTime(10);
instance.stop('Done');
expect(instance.message).to.equal('Canceled');
expect(instance.isActive).to.equal(false);
expect(instance.isCancelled).to.equal(true);
expect(instance.silentExit).to.equal(false);
expect(instance.exitCode).to.equal(1);
expect(instance.state).to.equal('cancel');
expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n']);
});

test('does nothing if spinner is not active', () => {
instance = new SpinnerPrompt({
input,
output,
frames: ['J', 'A', 'M', 'E', 'S'],
delay: 5,
render: () => 'foo',
});
instance.stop('Done');
expect(instance.message).to.equal('');
expect(instance.isActive).to.equal(false);
expect(instance.silentExit).to.equal(false);
expect(instance.exitCode).to.equal(0);
expect(instance.state).to.equal('initial');
expect(output.buffer).to.deep.equal([]);
});
});

test('message strips trailing dots', () => {
instance = new SpinnerPrompt({
input,
output,
frames: ['J', 'A', 'M', 'E', 'S'],
delay: 5,
render: () => 'foo',
});
instance.start('Loading...');
expect(instance.message).to.equal('Loading');

instance.message = 'Still loading....';
expect(instance.message).to.equal('Still loading');
});

describe('getFormattedTimer', () => {
test('formats timer correctly', () => {
instance = new SpinnerPrompt({
input,
output,
frames: ['J', 'A', 'M', 'E', 'S'],
delay: 5,
render: () => 'foo',
});
instance.start();
expect(instance.getFormattedTimer()).to.equal('[0s]');
vi.advanceTimersByTime(1500);
expect(instance.getFormattedTimer()).to.equal('[1s]');
vi.advanceTimersByTime(600_000);
expect(instance.getFormattedTimer()).to.equal('[10m 1s]');
});
});
});
Loading
Loading