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,