diff --git a/.changeset/swift-spinners-cancel.md b/.changeset/swift-spinners-cancel.md new file mode 100644 index 00000000..a758adae --- /dev/null +++ b/.changeset/swift-spinners-cancel.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Run spinner `onCancel` handlers when Ctrl+C cancels through blocked input. diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 9b427e3a..51ff8793 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -55,17 +55,18 @@ export const spinner = ({ let _prevMessage: string | undefined; let _origin: number = performance.now(); const columns = getColumns(output); + const input = opts.input ?? process.stdin; const styleFn = opts?.styleFrame ?? defaultStyleFn; const handleExit = (code: number) => { - const msg = - code > 1 - ? (errorMessage ?? settings.messages.error) - : (cancelMessage ?? settings.messages.cancel); - isCancelled = code === 1; + const cancelled = code <= 1; + const msg = cancelled + ? (cancelMessage ?? settings.messages.cancel) + : (errorMessage ?? settings.messages.error); + isCancelled = cancelled; if (isSpinnerActive) { - _stop(msg, code); - if (isCancelled && typeof onCancel === 'function') { + _stop(msg, cancelled ? 1 : code); + if (cancelled && typeof onCancel === 'function') { onCancel(); } } @@ -131,7 +132,7 @@ export const spinner = ({ const start = (msg = ''): void => { isSpinnerActive = true; - unblock = block({ output }); + unblock = block({ input, output }); _message = removeTrailingDots(msg); _origin = performance.now(); if (hasGuide) { diff --git a/packages/prompts/test/spinner.test.ts b/packages/prompts/test/spinner.test.ts index 18896121..ec0cb4a9 100644 --- a/packages/prompts/test/spinner.test.ts +++ b/packages/prompts/test/spinner.test.ts @@ -3,7 +3,7 @@ import { styleText } from 'node:util'; import { getColumns, updateSettings } from '@clack/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import * as prompts from '../src/index.js'; -import { MockWritable } from './test-utils.js'; +import { MockReadable, MockWritable } from './test-utils.js'; describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { let originalCI: string | undefined; @@ -305,6 +305,23 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + test('runs onCancel when Ctrl+C exits through blocked input', () => { + const input = new MockReadable(); + const onCancel = vi.fn(); + const result = prompts.spinner({ input, output, onCancel }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => { + processEmitter.emit('exit', typeof code === 'number' ? code : 0); + return undefined as never; + }) as typeof process.exit); + + result.start('Test operation'); + input.emit('keypress', Buffer.from('\x03'), { name: 'c' }); + + expect(exitSpy).toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledOnce(); + expect(result.isCancelled).toBe(true); + }); + test('uses custom cancel message when provided directly', () => { const result = prompts.spinner({ output,