diff --git a/.changeset/five-chairs-poke.md b/.changeset/five-chairs-poke.md new file mode 100644 index 00000000..babddf46 --- /dev/null +++ b/.changeset/five-chairs-poke.md @@ -0,0 +1,32 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Add support for customizable spinner cancel and error messages. Users can now customize these messages either per spinner instance or globally via the `updateSettings` function to support multilingual CLIs. + +This update also improves the architecture by exposing the core settings to the prompts package, enabling more consistent default message handling across the codebase. + +```ts +// Per-instance customization +const spinner = prompts.spinner({ + cancelMessage: 'Operación cancelada', // "Operation cancelled" in Spanish + errorMessage: 'Se produjo un error' // "An error occurred" in Spanish +}); + +// Global customization via updateSettings +prompts.updateSettings({ + messages: { + cancel: 'Operación cancelada', // "Operation cancelled" in Spanish + error: 'Se produjo un error' // "An error occurred" in Spanish + } +}); + +// Settings can now be accessed directly +console.log(prompts.settings.messages.cancel); // "Operación cancelada" + +// Direct options take priority over global settings +const spinner = prompts.spinner({ + cancelMessage: 'Cancelled', // This will be used instead of the global setting +}); +``` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af4413ed..67e48162 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,4 +10,4 @@ export { default as SelectPrompt } from './prompts/select.js'; export { default as SelectKeyPrompt } from './prompts/select-key.js'; export { default as TextPrompt } from './prompts/text.js'; export { block, isCancel } from './utils/index.js'; -export { updateSettings } from './utils/settings.js'; +export { updateSettings, settings } from './utils/settings.js'; diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index 2d785f7a..bc5f84ff 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -5,6 +5,10 @@ export type Action = (typeof actions)[number]; interface InternalClackSettings { actions: Set; aliases: Map; + messages: { + cancel: string; + error: string; + }; } export const settings: InternalClackSettings = { @@ -19,6 +23,10 @@ export const settings: InternalClackSettings = { // opinionated defaults! ['escape', 'cancel'], ]), + messages: { + cancel: 'Canceled', + error: 'Something went wrong', + }, }; export interface ClackSettings { @@ -29,27 +37,50 @@ export interface ClackSettings { * @param aliases - An object that maps aliases to actions * @default { k: 'up', j: 'down', h: 'left', l: 'right', '\x03': 'cancel', 'escape': 'cancel' } */ - aliases: Record; + aliases?: Record; + + /** + * Custom messages for prompts + */ + messages?: { + /** + * Custom message to display when a spinner is cancelled + * @default "Canceled" + */ + cancel?: string; + /** + * Custom message to display when a spinner encounters an error + * @default "Something went wrong" + */ + error?: string; + }; } export function updateSettings(updates: ClackSettings) { - for (const _key in updates) { - const key = _key as keyof ClackSettings; - if (!Object.hasOwn(updates, key)) continue; - const value = updates[key]; - - switch (key) { - case 'aliases': { - for (const alias in value) { - if (!Object.hasOwn(value, alias)) continue; - if (!settings.aliases.has(alias)) { - settings.aliases.set(alias, value[alias]); - } - } - break; + // Handle each property in the updates + if (updates.aliases !== undefined) { + const aliases = updates.aliases; + for (const alias in aliases) { + if (!Object.hasOwn(aliases, alias)) continue; + + const action = aliases[alias]; + if (!settings.actions.has(action)) continue; + + if (!settings.aliases.has(alias)) { + settings.aliases.set(alias, action); } } } + + if (updates.messages !== undefined) { + const messages = updates.messages; + if (messages.cancel !== undefined) { + settings.messages.cancel = messages.cancel; + } + if (messages.error !== undefined) { + settings.messages.error = messages.error; + } + } } /** diff --git a/packages/prompts/src/__snapshots__/index.test.ts.snap b/packages/prompts/src/__snapshots__/index.test.ts.snap index 3399371a..6437aefe 100644 --- a/packages/prompts/src/__snapshots__/index.test.ts.snap +++ b/packages/prompts/src/__snapshots__/index.test.ts.snap @@ -1612,6 +1612,83 @@ exports[`prompts (isCI = false) > spinner > message > sets message for next fram ] `; +exports[`prompts (isCI = false) > spinner > process exit handling > prioritizes direct options over global settings 1`] = ` +[ + "[?25l", + "│ +", + "■ Spinner cancel message +", + "[?25h", +] +`; + +exports[`prompts (isCI = false) > spinner > process exit handling > prioritizes direct options over global settings 2`] = ` +[ + "[?25l", + "│ +", + "▲ Spinner error message +", + "[?25h", +] +`; + +exports[`prompts (isCI = false) > spinner > process exit handling > uses custom cancel message when provided directly 1`] = ` +[ + "[?25l", + "│ +", + "■ Custom cancel message +", + "[?25h", +] +`; + +exports[`prompts (isCI = false) > spinner > process exit handling > uses custom error message when provided directly 1`] = ` +[ + "[?25l", + "│ +", + "▲ Custom error message +", + "[?25h", +] +`; + +exports[`prompts (isCI = false) > spinner > process exit handling > uses default cancel message 1`] = ` +[ + "[?25l", + "│ +", + "■ Canceled +", + "[?25h", +] +`; + +exports[`prompts (isCI = false) > spinner > process exit handling > uses global custom cancel message from settings 1`] = ` +[ + "[?25l", + "│ +", + "■ Global cancel message +", + "[?25h", +] +`; + +exports[`prompts (isCI = false) > spinner > process exit handling > uses global custom error message from settings 1`] = ` +[ + "[?25l", + "│ +", + "▲ Global error message +", + "[?25h", +] +`; + exports[`prompts (isCI = false) > spinner > start > renders frames at interval 1`] = ` [ "[?25l", @@ -3529,6 +3606,83 @@ exports[`prompts (isCI = true) > spinner > message > sets message for next frame ] `; +exports[`prompts (isCI = true) > spinner > process exit handling > prioritizes direct options over global settings 1`] = ` +[ + "[?25l", + "│ +", + "■ Spinner cancel message +", + "[?25h", +] +`; + +exports[`prompts (isCI = true) > spinner > process exit handling > prioritizes direct options over global settings 2`] = ` +[ + "[?25l", + "│ +", + "▲ Spinner error message +", + "[?25h", +] +`; + +exports[`prompts (isCI = true) > spinner > process exit handling > uses custom cancel message when provided directly 1`] = ` +[ + "[?25l", + "│ +", + "■ Custom cancel message +", + "[?25h", +] +`; + +exports[`prompts (isCI = true) > spinner > process exit handling > uses custom error message when provided directly 1`] = ` +[ + "[?25l", + "│ +", + "▲ Custom error message +", + "[?25h", +] +`; + +exports[`prompts (isCI = true) > spinner > process exit handling > uses default cancel message 1`] = ` +[ + "[?25l", + "│ +", + "■ Canceled +", + "[?25h", +] +`; + +exports[`prompts (isCI = true) > spinner > process exit handling > uses global custom cancel message from settings 1`] = ` +[ + "[?25l", + "│ +", + "■ Global cancel message +", + "[?25h", +] +`; + +exports[`prompts (isCI = true) > spinner > process exit handling > uses global custom error message from settings 1`] = ` +[ + "[?25l", + "│ +", + "▲ Global error message +", + "[?25h", +] +`; + exports[`prompts (isCI = true) > spinner > start > renders frames at interval 1`] = ` [ "[?25l", diff --git a/packages/prompts/src/index.test.ts b/packages/prompts/src/index.test.ts index f6b85e42..7a478056 100644 --- a/packages/prompts/src/index.test.ts +++ b/packages/prompts/src/index.test.ts @@ -1,4 +1,4 @@ -import { Readable, Writable } from 'node:stream'; +import { EventEmitter, Readable, Writable } from 'node:stream'; import colors from 'picocolors'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import * as prompts from './index.js'; @@ -184,6 +184,132 @@ describe.each(['true', 'false'])('prompts (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); }); + + describe('process exit handling', () => { + let processEmitter: EventEmitter; + + beforeEach(() => { + processEmitter = new EventEmitter(); + + // Spy on process methods + vi.spyOn(process, 'on').mockImplementation((ev, listener) => { + processEmitter.on(ev, listener); + return process; + }); + vi.spyOn(process, 'removeListener').mockImplementation((ev, listener) => { + processEmitter.removeListener(ev, listener); + return process; + }); + }); + + afterEach(() => { + processEmitter.removeAllListeners(); + }); + + test('uses default cancel message', () => { + const result = prompts.spinner({ output }); + result.start('Test operation'); + + processEmitter.emit('SIGINT'); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('uses custom cancel message when provided directly', () => { + const result = prompts.spinner({ + output, + cancelMessage: 'Custom cancel message', + }); + result.start('Test operation'); + + processEmitter.emit('SIGINT'); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('uses custom error message when provided directly', () => { + const result = prompts.spinner({ + output, + errorMessage: 'Custom error message', + }); + result.start('Test operation'); + + processEmitter.emit('exit', 2); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('uses global custom cancel message from settings', () => { + // Store original message + const originalCancelMessage = prompts.settings.messages.cancel; + // Set custom message + prompts.settings.messages.cancel = 'Global cancel message'; + + const result = prompts.spinner({ output }); + result.start('Test operation'); + + processEmitter.emit('SIGINT'); + + expect(output.buffer).toMatchSnapshot(); + + // Reset to original + prompts.settings.messages.cancel = originalCancelMessage; + }); + + test('uses global custom error message from settings', () => { + // Store original message + const originalErrorMessage = prompts.settings.messages.error; + // Set custom message + prompts.settings.messages.error = 'Global error message'; + + const result = prompts.spinner({ output }); + result.start('Test operation'); + + processEmitter.emit('exit', 2); + + expect(output.buffer).toMatchSnapshot(); + + // Reset to original + prompts.settings.messages.error = originalErrorMessage; + }); + + test('prioritizes direct options over global settings', () => { + // Store original messages + const originalCancelMessage = prompts.settings.messages.cancel; + const originalErrorMessage = prompts.settings.messages.error; + + // Set custom global messages + prompts.settings.messages.cancel = 'Global cancel message'; + prompts.settings.messages.error = 'Global error message'; + + const result = prompts.spinner({ + output, + cancelMessage: 'Spinner cancel message', + errorMessage: 'Spinner error message', + }); + result.start('Test operation'); + + processEmitter.emit('SIGINT'); + expect(output.buffer).toMatchSnapshot(); + + // Reset buffer + output.buffer = []; + + const result2 = prompts.spinner({ + output, + cancelMessage: 'Spinner cancel message', + errorMessage: 'Spinner error message', + }); + result2.start('Test operation'); + + processEmitter.emit('exit', 2); + expect(output.buffer).toMatchSnapshot(); + + // Reset to original values + prompts.settings.messages.cancel = originalCancelMessage; + prompts.settings.messages.error = originalErrorMessage; + }); + }); }); describe('text', () => { diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index af0471ad..2ecdd617 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -12,13 +12,14 @@ import { TextPrompt, block, isCancel, + updateSettings, + settings } from '@clack/core'; import isUnicodeSupported from 'is-unicode-supported'; import color from 'picocolors'; import { cursor, erase } from 'sisteransi'; -export { isCancel } from '@clack/core'; -export { updateSettings, type ClackSettings } from '@clack/core'; +export { isCancel, updateSettings, settings, type ClackSettings } from '@clack/core'; const unicode = isUnicodeSupported(); const s = (c: string, fallback: string) => (unicode ? c : fallback); @@ -778,6 +779,8 @@ export const stream = { export interface SpinnerOptions extends CommonOptions { indicator?: 'dots' | 'timer'; onCancel?: () => void; + cancelMessage?: string; + errorMessage?: string; } export interface SpinnerResult { @@ -791,6 +794,8 @@ export const spinner = ({ indicator = 'dots', onCancel, output = process.stdout, + cancelMessage, + errorMessage, }: SpinnerOptions = {}): SpinnerResult => { const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0']; const delay = unicode ? 80 : 120; @@ -805,7 +810,9 @@ export const spinner = ({ let _origin: number = performance.now(); const handleExit = (code: number) => { - const msg = code > 1 ? 'Something went wrong' : 'Canceled'; + const msg = code > 1 + ? (errorMessage ?? settings.messages.error) + : (cancelMessage ?? settings.messages.cancel); isCancelled = code === 1; if (isSpinnerActive) { stop(msg, code);