Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/clever-rules-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Fixed spinner onCancel not being called on Ctrl+C and prevents process.exit call
16 changes: 12 additions & 4 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ interface BlockOptions {
output?: Writable;
overwrite?: boolean;
hideCursor?: boolean;
onSoftCancel?: () => void;
}

export function block({
input = stdin,
output = stdout,
overwrite = true,
hideCursor = true,
onSoftCancel,
}: BlockOptions = {}) {
const rl = readline.createInterface({
input,
Expand All @@ -52,8 +54,12 @@ export function block({
const clear = (data: Buffer, { name, sequence }: Key) => {
const str = String(data);
if (isActionKey([str, name, sequence], 'cancel')) {
if (hideCursor) output.write(cursor.show);
process.exit(0);
if (typeof onSoftCancel === 'function') {
onSoftCancel();
} else {
if (hideCursor) output.write(cursor.show);
process.exit(0);
}
return;
}
if (!overwrite) return;
Expand All @@ -78,8 +84,10 @@ export function block({
input.setRawMode(false);
}

// @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907
rl.terminal = false;
if (typeof onSoftCancel !== 'function') {
// @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907
rl.terminal = false;
}
rl.close();
};
}
Expand Down
21 changes: 21 additions & 0 deletions packages/prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,27 @@ s.start('Installing via npm');
s.stop('Installed via npm');
```

Note: The `Ctrl+C` input will hard-stop the process with a `process.exit(0)` call.
If you wish to avoid this behavior, pass an `onCancel` callback to the spinner initialization function.

```js
import { spinner, log } from '@clack/prompts';

const s = spinner({
onCancel: () => {
// Do something...or nothing
log.warn('Coolness reached 3.14%');
}
});

s.start('Downloading coolness...');
// Do stuff...then hit Ctrl+C to cancel
s.stop('Maximum coolness reached! :)'); // Note: This does nothing if the spinner was cancelled

// Carry on... (teardown connections, etc.)

```

### Progress

The progress component extends the spinner component to add a progress bar to visualize the progression of an action.
Expand Down
3 changes: 2 additions & 1 deletion packages/prompts/src/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const spinner = ({
let _origin: number = performance.now();
const columns = getColumns(output);
const styleFn = opts?.styleFrame ?? defaultStyleFn;
const isSoftCancel = typeof onCancel === 'function';

const handleExit = (code: number) => {
const msg =
Expand Down Expand Up @@ -131,7 +132,7 @@ export const spinner = ({

const start = (msg = ''): void => {
isSpinnerActive = true;
unblock = block({ output });
unblock = block({ output, onSoftCancel: isSoftCancel ? () => handleExit(1) : undefined });
_message = removeTrailingDots(msg);
_origin = performance.now();
if (hasGuide) {
Expand Down
40 changes: 40 additions & 0 deletions packages/prompts/test/__snapshots__/spinner.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ exports[`spinner (isCI = false) > can be aborted by a signal 1`] = `
]
`;

exports[`spinner (isCI = false) > can be cancelled by Ctrl+C without exiting process 1`] = `
[
"<cursor.hide>",
"│
",
"■ Canceled
",
"<cursor.show>",
]
`;

exports[`spinner (isCI = false) > clear > stops and clears the spinner from the output 1`] = `
[
"<cursor.hide>",
Expand Down Expand Up @@ -324,6 +335,15 @@ exports[`spinner (isCI = false) > message > sets message for next frame 1`] = `
]
`;

exports[`spinner (isCI = false) > process exit handling > hard-exits the process on Ctrl+C when no onCancel callback is provided 1`] = `
[
"<cursor.hide>",
"│
",
"<cursor.show>",
]
`;

exports[`spinner (isCI = false) > process exit handling > prioritizes cancel option over global setting 1`] = `
[
"<cursor.hide>",
Expand Down Expand Up @@ -606,6 +626,17 @@ exports[`spinner (isCI = true) > can be aborted by a signal 1`] = `
]
`;

exports[`spinner (isCI = true) > can be cancelled by Ctrl+C without exiting process 1`] = `
[
"<cursor.hide>",
"│
",
"■ Canceled
",
"<cursor.show>",
]
`;

exports[`spinner (isCI = true) > clear > stops and clears the spinner from the output 1`] = `
[
"<cursor.hide>",
Expand Down Expand Up @@ -719,6 +750,15 @@ exports[`spinner (isCI = true) > message > sets message for next frame 1`] = `
]
`;

exports[`spinner (isCI = true) > process exit handling > hard-exits the process on Ctrl+C when no onCancel callback is provided 1`] = `
[
"<cursor.hide>",
"│
",
"<cursor.show>",
]
`;

exports[`spinner (isCI = true) > process exit handling > prioritizes cancel option over global setting 1`] = `
[
"<cursor.hide>",
Expand Down
30 changes: 30 additions & 0 deletions packages/prompts/test/spinner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,36 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => {
prompts.settings.messages.cancel = originalCancelMessage;
}
});

test('hard-exits the process on Ctrl+C when no onCancel callback is provided', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
return code ?? 0;
}) as typeof process.exit);
const result = prompts.spinner({ output });

result.start('Testing');

// Simulate Ctrl+C keypress
const ctrlCEvent = Buffer.from([0x03]); // ASCII for Ctrl+C
process.stdin.emit('keypress', ctrlCEvent, { name: 'c', ctrl: true });

expect(output.buffer).toMatchSnapshot();
expect(exitSpy).toHaveBeenCalledWith(0);
});
});

test('can be cancelled by Ctrl+C without exiting process', () => {
const onCancel = vi.fn();
const result = prompts.spinner({ output, onCancel });

result.start('Testing');

// Simulate Ctrl+C keypress
const ctrlCEvent = Buffer.from([0x03]); // ASCII for Ctrl+C
process.stdin.emit('keypress', ctrlCEvent, { name: 'c', ctrl: true });

expect(onCancel).toHaveBeenCalled();
expect(output.buffer).toMatchSnapshot();
});

test('can be aborted by a signal', async () => {
Expand Down
Loading