Skip to content

feat(prompts): add cancellation support for spinners #264

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 4, 2025
Merged
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
5 changes: 5 additions & 0 deletions .changeset/orange-deers-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": minor
---

Adds support for detecting spinner cancellation via CTRL+C. This allows for graceful handling of user interruptions during long-running operations.
150 changes: 150 additions & 0 deletions examples/basic/spinner-cancel-advanced.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { setTimeout as sleep } from 'node:timers/promises';
import * as p from '@clack/prompts';

async function main() {
p.intro('Advanced Spinner Cancellation Demo');

// First demonstrate a visible spinner with no user input needed
p.note('First, we will show a basic spinner (press CTRL+C to cancel)', 'Demo Part 1');

const demoSpinner = p.spinner({
indicator: 'dots',
onCancel: () => {
p.note('Initial spinner was cancelled with CTRL+C', 'Demo Cancelled');
},
});

demoSpinner.start('Loading demo resources');

// Update spinner message a few times to show activity
for (let i = 0; i < 5; i++) {
if (demoSpinner.isCancelled) break;
await sleep(1000);
demoSpinner.message(`Loading demo resources (${i + 1}/5)`);
}

if (!demoSpinner.isCancelled) {
demoSpinner.stop('Demo resources loaded successfully');
}

// Only continue with the rest of the demo if the initial spinner wasn't cancelled
if (!demoSpinner.isCancelled) {
// Stage 1: Get user input with multiselect
p.note("Now let's select some languages to process", 'Demo Part 2');

const languages = await p.multiselect({
message: 'Select programming languages to process:',
options: [
{ value: 'typescript', label: 'TypeScript' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'python', label: 'Python' },
{ value: 'rust', label: 'Rust' },
{ value: 'go', label: 'Go' },
],
required: true,
});

// Handle cancellation of the multiselect
if (p.isCancel(languages)) {
p.cancel('Operation cancelled during language selection.');
process.exit(0);
}

// Stage 2: Show a spinner that can be cancelled
const processSpinner = p.spinner({
indicator: 'dots',
onCancel: () => {
p.note(
'You cancelled during processing. Any completed work will be saved.',
'Processing Cancelled'
);
},
});

processSpinner.start('Starting to process selected languages...');

// Process each language with individual progress updates
let completedCount = 0;
const totalLanguages = languages.length;

for (const language of languages) {
// Skip the rest if cancelled
if (processSpinner.isCancelled) break;

// Update spinner message with current language
processSpinner.message(`Processing ${language} (${completedCount + 1}/${totalLanguages})`);

try {
// Simulate work - longer pause to give time to test CTRL+C
await sleep(2000);
completedCount++;
} catch (error) {
// Handle errors but continue if not cancelled
if (!processSpinner.isCancelled) {
p.note(`Error processing ${language}: ${error.message}`, 'Error');
}
}
}

// Stage 3: Handle completion based on cancellation status
if (!processSpinner.isCancelled) {
processSpinner.stop(`Processed ${completedCount}/${totalLanguages} languages successfully`);

// Stage 4: Additional user input based on processing results
if (completedCount > 0) {
const action = await p.select({
message: 'What would you like to do with the processed data?',
options: [
{ value: 'save', label: 'Save results', hint: 'Write to disk' },
{ value: 'share', label: 'Share results', hint: 'Upload to server' },
{ value: 'analyze', label: 'Further analysis', hint: 'Generate reports' },
],
});

if (p.isCancel(action)) {
p.cancel('Operation cancelled at final stage.');
process.exit(0);
}

// Stage 5: Final action with a timer spinner
p.note('Now demonstrating a timer-style spinner', 'Final Stage');

const finalSpinner = p.spinner({
indicator: 'timer', // Use timer indicator for variety
onCancel: () => {
p.note(
'Final operation was cancelled, but processing results are still valid.',
'Final Stage Cancelled'
);
},
});

finalSpinner.start(`Performing ${action} operation...`);

try {
// Simulate final action with incremental updates
for (let i = 0; i < 3; i++) {
if (finalSpinner.isCancelled) break;
await sleep(1500);
finalSpinner.message(`Performing ${action} operation... Step ${i + 1}/3`);
}

if (!finalSpinner.isCancelled) {
finalSpinner.stop(`${action} operation completed successfully`);
}
} catch (error) {
if (!finalSpinner.isCancelled) {
finalSpinner.stop(`Error during ${action}: ${error.message}`);
}
}
}
}
}

p.outro('Advanced demo completed. Thanks for trying out the spinner cancellation features!');
}

main().catch((error) => {
console.error('Unexpected error:', error);
process.exit(1);
});
42 changes: 42 additions & 0 deletions examples/basic/spinner-cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as p from '@clack/prompts';

p.intro('Spinner with cancellation detection');

// Example 1: Using onCancel callback
const spin1 = p.spinner({
indicator: 'dots',
onCancel: () => {
p.note('You cancelled the spinner with CTRL-C!', 'Callback detected');
},
});

spin1.start('Press CTRL-C to cancel this spinner (using callback)');

// Sleep for 10 seconds, allowing time for user to press CTRL-C
await sleep(10000).then(() => {
// Only show success message if not cancelled
if (!spin1.isCancelled) {
spin1.stop('Spinner completed without cancellation');
}
});

// Example 2: Checking the isCancelled property
p.note('Starting second example...', 'Example 2');

const spin2 = p.spinner({ indicator: 'timer' });
spin2.start('Press CTRL-C to cancel this spinner (polling isCancelled)');

await sleep(10000).then(() => {
if (spin2.isCancelled) {
p.note('Spinner was cancelled by the user!', 'Property check');
} else {
spin2.stop('Spinner completed without cancellation');
}
});

p.outro('Example completed');

// Helper function
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
19 changes: 17 additions & 2 deletions packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,17 +732,20 @@ export const stream = {

export interface SpinnerOptions {
indicator?: 'dots' | 'timer';
onCancel?: () => void;
output?: Writable;
}

export interface SpinnerResult {
start(msg?: string): void;
stop(msg?: string, code?: number): void;
message(msg?: string): void;
readonly isCancelled: boolean;
}

export const spinner = ({
indicator = 'dots',
onCancel,
output = process.stdout,
}: SpinnerOptions = {}): SpinnerResult => {
const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'];
Expand All @@ -752,13 +755,20 @@ export const spinner = ({
let unblock: () => void;
let loop: NodeJS.Timeout;
let isSpinnerActive = false;
let isCancelled = false;
let _message = '';
let _prevMessage: string | undefined = undefined;
let _origin: number = performance.now();

const handleExit = (code: number) => {
const msg = code > 1 ? 'Something went wrong' : 'Canceled';
if (isSpinnerActive) stop(msg, code);
isCancelled = code === 1;
if (isSpinnerActive) {
stop(msg, code);
if (isCancelled && typeof onCancel === 'function') {
onCancel();
}
}
};

const errorEventHandler = () => handleExit(2);
Expand Down Expand Up @@ -861,6 +871,9 @@ export const spinner = ({
start,
stop,
message,
get isCancelled() {
return isCancelled;
},
};
};

Expand All @@ -873,7 +886,9 @@ export interface PromptGroupOptions<T> {
* Control how the group can be canceled
* if one of the prompts is canceled.
*/
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;
onCancel?: (opts: {
results: Prettify<Partial<PromptGroupAwaitedReturn<T>>>;
}) => void;
}

type Prettify<T> = {
Expand Down