From f95d408ebc7df693990c19d512fbd4dceb0bb789 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:44:21 +0000 Subject: [PATCH 1/5] wip: move spinner to use prompt API --- packages/core/src/index.ts | 2 + packages/core/src/prompts/prompt.ts | 2 +- packages/core/src/prompts/spinner.ts | 170 ++++++++++++++++++++++ packages/prompts/src/spinner.ts | 206 +++++++-------------------- 4 files changed, 221 insertions(+), 159 deletions(-) create mode 100644 packages/core/src/prompts/spinner.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7299d075..66752310 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,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'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index b30deb02..7c54c4b4 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -270,7 +270,7 @@ export default class Prompt { 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, diff --git a/packages/core/src/prompts/spinner.ts b/packages/core/src/prompts/spinner.ts new file mode 100644 index 00000000..e8e0b9e7 --- /dev/null +++ b/packages/core/src/prompts/spinner.ts @@ -0,0 +1,170 @@ +import Prompt, { type PromptOptions } from './prompt.js'; +import {settings} from '../utils/index.js'; + +const removeTrailingDots = (msg: string): string => { + return msg.replace(/\.+$/, ''); +}; + +export interface SpinnerOptions extends PromptOptions { + indicator?: 'dots' | 'timer'; + onCancel?: () => void; + cancelMessage?: string; + errorMessage?: string; + frames: string[]; + delay: number; + styleFrame?: (frame: string) => string; +} + +export default class SpinnerPrompt extends Prompt { + #isCancelled = false; + #isActive = false; + #startTime: number = 0; + #frameIndex: number = 0; + #indicatorTimer: number = 0; + #intervalId: ReturnType | undefined; + #delay: number; + #frames: string[]; + #cancelMessage: string; + #errorMessage: string; + #onCancel?: () => void; + #message: string = ''; + #silentExit: boolean = false; + #exitCode: number | undefined = undefined; + + 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 { + this.#isActive = true; + this.#message = removeTrailingDots(msg ?? ''); + this.#startTime = performance.now(); + this.#frameIndex = 0; + this.#indicatorTimer = 0; + + this.#intervalId = setInterval(() => this.#onInterval(), this.#delay); + + this.#addGlobalListeners(); + } + + stop(msg?: string, exitCode?: number, silent?: boolean): void { + if (!this.#isActive) { + return; + } + + this.#isActive = false; + this.#silentExit = silent === true; + this.#exitCode = exitCode; + + if (msg !== undefined) { + this.#message = msg; + } + + if (this.#intervalId) { + clearInterval(this.#intervalId); + this.#intervalId = undefined; + } + + this.#removeGlobalListeners(); + this.state = '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]`; + } + + #onInterval(): void { + 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; + + this.render(); + } + + #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); + } +} diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 8b3f6d01..fa43214e 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -1,7 +1,5 @@ -import { block, getColumns, settings } from '@clack/core'; -import { wrapAnsi } from 'fast-wrap-ansi'; +import { SpinnerPrompt } from '@clack/core'; import color from 'picocolors'; -import { cursor, erase } from 'sisteransi'; import { type CommonOptions, isCI as isCIFn, @@ -37,179 +35,71 @@ const defaultStyleFn: SpinnerOptions['styleFrame'] = color.magenta; export const spinner = ({ indicator = 'dots', onCancel, - output = process.stdout, cancelMessage, errorMessage, frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], delay = unicode ? 80 : 120, - signal, ...opts }: SpinnerOptions = {}): SpinnerResult => { const isCI = isCIFn(); - - let unblock: () => void; - let loop: NodeJS.Timeout; - let isSpinnerActive = false; - let isCancelled = false; - let _message = ''; - let _prevMessage: string | undefined; - let _origin: number = performance.now(); - const columns = getColumns(output); const styleFn = opts?.styleFrame ?? defaultStyleFn; - - const handleExit = (code: number) => { - const msg = - code > 1 - ? (errorMessage ?? settings.messages.error) - : (cancelMessage ?? settings.messages.cancel); - isCancelled = code === 1; - if (isSpinnerActive) { - _stop(msg, code); - if (isCancelled && typeof onCancel === 'function') { - onCancel(); + const prompt = new SpinnerPrompt({ + indicator, + onCancel, + cancelMessage, + errorMessage, + frames, + delay, + output: opts.output, + signal: opts.signal, + input: opts.input, + render() { + if (!this.isActive) { + if (this.silentExit) { + return ''; + } + const step = + this.exitCode === 0 + ? color.green(S_STEP_SUBMIT) + : this.exitCode === 1 + ? color.red(S_STEP_CANCEL) + : color.red(S_STEP_ERROR); + if (indicator === 'timer') { + return `${step} ${this.message} ${this.getFormattedTimer()}\n`; + } else { + return `${step} ${this.message}\n`; + } } - } - }; - - const errorEventHandler = () => handleExit(2); - const signalEventHandler = () => handleExit(1); - - const registerHooks = () => { - // Reference: https://nodejs.org/api/process.html#event-uncaughtexception - process.on('uncaughtExceptionMonitor', errorEventHandler); - // Reference: https://nodejs.org/api/process.html#event-unhandledrejection - process.on('unhandledRejection', errorEventHandler); - // Reference Signal Events: https://nodejs.org/api/process.html#signal-events - process.on('SIGINT', signalEventHandler); - process.on('SIGTERM', signalEventHandler); - process.on('exit', handleExit); - - if (signal) { - signal.addEventListener('abort', signalEventHandler); - } - }; - - const clearHooks = () => { - process.removeListener('uncaughtExceptionMonitor', errorEventHandler); - process.removeListener('unhandledRejection', errorEventHandler); - process.removeListener('SIGINT', signalEventHandler); - process.removeListener('SIGTERM', signalEventHandler); - process.removeListener('exit', handleExit); - - if (signal) { - signal.removeEventListener('abort', signalEventHandler); - } - }; - - const clearPrevMessage = () => { - if (_prevMessage === undefined) return; - if (isCI) output.write('\n'); - const wrapped = wrapAnsi(_prevMessage, columns, { - hard: true, - trim: false, - }); - const prevLines = wrapped.split('\n'); - if (prevLines.length > 1) { - output.write(cursor.up(prevLines.length - 1)); - } - output.write(cursor.to(0)); - output.write(erase.down()); - }; - - const removeTrailingDots = (msg: string): string => { - return msg.replace(/\.+$/, ''); - }; - - const formatTimer = (origin: number): string => { - const duration = (performance.now() - origin) / 1000; - const min = Math.floor(duration / 60); - const secs = Math.floor(duration % 60); - return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; - }; - - const start = (msg = ''): void => { - isSpinnerActive = true; - unblock = block({ output }); - _message = removeTrailingDots(msg); - _origin = performance.now(); - output.write(`${color.gray(S_BAR)}\n`); - let frameIndex = 0; - let indicatorTimer = 0; - registerHooks(); - loop = setInterval(() => { - if (isCI && _message === _prevMessage) { - return; - } - clearPrevMessage(); - _prevMessage = _message; - const frame = styleFn(frames[frameIndex]); + const frame = styleFn(frames[this.frameIndex]); + const message = this.message; let outputMessage: string; - if (isCI) { - outputMessage = `${frame} ${_message}...`; + outputMessage = `${frame} ${message}...`; } else if (indicator === 'timer') { - outputMessage = `${frame} ${_message} ${formatTimer(_origin)}`; + outputMessage = `${frame} ${message} ${this.getFormattedTimer()}`; } else { - const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3); - outputMessage = `${frame} ${_message}${loadingDots}`; + const loadingDots = '.'.repeat(Math.floor(this.indicatorTimer)).slice(0, 3); + outputMessage = `${frame} ${message}${loadingDots}`; } + return `${color.gray(S_BAR)}\n${outputMessage}`; + }, + }); - const wrapped = wrapAnsi(outputMessage, columns, { - hard: true, - trim: false, - }); - output.write(wrapped); - - frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; - // indicator increase by 1 every 8 frames - indicatorTimer = indicatorTimer < 4 ? indicatorTimer + 0.125 : 0; - }, delay); - }; - - const _stop = (msg = '', code = 0, silent: boolean = false): void => { - if (!isSpinnerActive) return; - isSpinnerActive = false; - clearInterval(loop); - clearPrevMessage(); - const step = - code === 0 - ? color.green(S_STEP_SUBMIT) - : code === 1 - ? color.red(S_STEP_CANCEL) - : color.red(S_STEP_ERROR); - _message = msg ?? _message; - if (!silent) { - if (indicator === 'timer') { - output.write(`${step} ${_message} ${formatTimer(_origin)}\n`); - } else { - output.write(`${step} ${_message}\n`); - } - } - clearHooks(); - unblock(); - }; - - const stop = (msg = ''): void => _stop(msg, 0); - const cancel = (msg = ''): void => _stop(msg, 1); - const error = (msg = ''): void => _stop(msg, 2); - // TODO (43081j): this will leave the initial S_BAR since we purposely - // don't erase that in `clearPrevMessage`. In future, we may want to treat - // `clear` as a special case and remove the bar too. - const clear = (): void => _stop('', 0, true); - - const message = (msg = ''): void => { - _message = removeTrailingDots(msg ?? _message); - }; + prompt.prompt(); return { - start, - stop, - message, - cancel, - error, - clear, + start: (msg?: string) => prompt.start(msg), + stop: (msg?: string) => prompt.stop(msg, 0), + message: (msg?: string) => { + if (msg !== undefined) { + prompt.message = msg; + } + }, + cancel: (msg: string = '') => prompt.stop(msg, 1), + error: (msg: string = '') => prompt.stop(msg, 2), + clear: () => prompt.stop('', 0, true), get isCancelled() { - return isCancelled; + return prompt.isCancelled; }, }; }; From c6c2768095799ab78416d19044826e86a75f3faa Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:35:27 +0000 Subject: [PATCH 2/5] test: add core tests --- packages/core/src/prompts/spinner.ts | 23 +++- packages/core/test/prompts/spinner.test.ts | 151 +++++++++++++++++++++ 2 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 packages/core/test/prompts/spinner.test.ts diff --git a/packages/core/src/prompts/spinner.ts b/packages/core/src/prompts/spinner.ts index 4160cce2..497db421 100644 --- a/packages/core/src/prompts/spinner.ts +++ b/packages/core/src/prompts/spinner.ts @@ -43,6 +43,9 @@ export default class SpinnerPrompt extends Prompt { } start(msg?: string): void { + if (this.#isActive) { + this.#reset(); + } this.#isActive = true; this.#message = removeTrailingDots(msg ?? ''); this.#startTime = performance.now(); @@ -59,7 +62,7 @@ export default class SpinnerPrompt extends Prompt { return; } - this.#isActive = false; + this.#reset(); this.#silentExit = silent === true; this.#exitCode = exitCode; @@ -67,12 +70,6 @@ export default class SpinnerPrompt extends Prompt { this.#message = msg; } - if (this.#intervalId) { - clearInterval(this.#intervalId); - this.#intervalId = undefined; - } - - this.#removeGlobalListeners(); this.state = 'cancel'; this.render(); this.close(); @@ -117,6 +114,18 @@ export default class SpinnerPrompt extends Prompt { return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; } + #reset(): void { + this.#isActive = false; + this.#exitCode = undefined; + + if (this.#intervalId) { + clearInterval(this.#intervalId); + this.#intervalId = undefined; + } + + this.#removeGlobalListeners(); + } + #onInterval(): void { this.#frameIndex = this.#frameIndex + 1 < this.#frames.length ? this.#frameIndex + 1 : 0; // indicator increase by 1 every 8 frames diff --git a/packages/core/test/prompts/spinner.test.ts b/packages/core/test/prompts/spinner.test.ts new file mode 100644 index 00000000..7ca19d0a --- /dev/null +++ b/packages/core/test/prompts/spinner.test.ts @@ -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(undefined); + 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]'); + }); + }); +}); From 505e7ff15fb278dbe1f14ee7b61098091e4633d5 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:33:32 +0000 Subject: [PATCH 3/5] chore: fix rendering order --- packages/core/src/prompts/spinner.ts | 10 +++++----- packages/prompts/src/spinner.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/prompts/spinner.ts b/packages/core/src/prompts/spinner.ts index 497db421..88ac8f7b 100644 --- a/packages/core/src/prompts/spinner.ts +++ b/packages/core/src/prompts/spinner.ts @@ -29,7 +29,7 @@ export default class SpinnerPrompt extends Prompt { #onCancel?: () => void; #message: string = ''; #silentExit: boolean = false; - #exitCode: number | undefined = undefined; + #exitCode: number = 0; constructor(opts: SpinnerOptions) { super(opts); @@ -64,7 +64,7 @@ export default class SpinnerPrompt extends Prompt { this.#reset(); this.#silentExit = silent === true; - this.#exitCode = exitCode; + this.#exitCode = exitCode ?? 0; if (msg !== undefined) { this.#message = msg; @@ -116,7 +116,7 @@ export default class SpinnerPrompt extends Prompt { #reset(): void { this.#isActive = false; - this.#exitCode = undefined; + this.#exitCode = 0; if (this.#intervalId) { clearInterval(this.#intervalId); @@ -127,11 +127,11 @@ export default class SpinnerPrompt extends Prompt { } #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; - - this.render(); } #onProcessError: () => void = () => { diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 4e073ddd..e42ea2f0 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -57,7 +57,7 @@ export const spinner = ({ const hasGuide = opts.withGuide ?? settings.withGuide; if (!this.isActive) { - if (this.silentExit) { + if (this.silentExit || this.state === 'initial') { return ''; } const step = From 3d2ca73c5b388d5ae4a3c831846c82b4b3a4e0bc Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:24:50 +0000 Subject: [PATCH 4/5] fix: add guide prefix --- packages/prompts/src/spinner.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index e42ea2f0..08d89680 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -55,10 +55,11 @@ export const spinner = ({ input: opts.input, render() { const hasGuide = opts.withGuide ?? settings.withGuide; + const prefix = hasGuide ? `${styleText('grey', S_BAR)}\n` : ''; if (!this.isActive) { if (this.silentExit || this.state === 'initial') { - return ''; + return prefix; } const step = this.exitCode === 0 @@ -67,9 +68,9 @@ export const spinner = ({ ? styleText('red', S_STEP_CANCEL) : styleText('red', S_STEP_ERROR); if (indicator === 'timer') { - return `${step} ${this.message} ${this.getFormattedTimer()}\n`; + return `${prefix}${step} ${this.message} ${this.getFormattedTimer()}\n`; } else { - return `${step} ${this.message}\n`; + return `${prefix}${step} ${this.message}\n`; } } const frame = styleFn(frames[this.frameIndex]); @@ -83,7 +84,6 @@ export const spinner = ({ const loadingDots = '.'.repeat(Math.floor(this.indicatorTimer)).slice(0, 3); outputMessage = `${frame} ${message}${loadingDots}`; } - const prefix = hasGuide ? `${styleText('grey', S_BAR)}\n` : ''; return `${prefix}${outputMessage}`; }, }); From b53162cb9df1a44cc38c83100df05b23da2ec314 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:27:33 +0000 Subject: [PATCH 5/5] fix: remove extra newline --- packages/prompts/src/spinner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 08d89680..5f137b00 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -68,9 +68,9 @@ export const spinner = ({ ? styleText('red', S_STEP_CANCEL) : styleText('red', S_STEP_ERROR); if (indicator === 'timer') { - return `${prefix}${step} ${this.message} ${this.getFormattedTimer()}\n`; + return `${prefix}${step} ${this.message} ${this.getFormattedTimer()}`; } else { - return `${prefix}${step} ${this.message}\n`; + return `${prefix}${step} ${this.message}`; } } const frame = styleFn(frames[this.frameIndex]);