Skip to content

Commit c45b9fb

Browse files
dreyfus9243081j
andauthored
feat(prompts): add cancellation support for spinners (bombshell-dev#264)
Co-authored-by: James Garbutt <[email protected]>
1 parent e6ff090 commit c45b9fb

File tree

4 files changed

+214
-2
lines changed

4 files changed

+214
-2
lines changed

.changeset/orange-deers-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": minor
3+
---
4+
5+
Adds support for detecting spinner cancellation via CTRL+C. This allows for graceful handling of user interruptions during long-running operations.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { setTimeout as sleep } from 'node:timers/promises';
2+
import * as p from '@clack/prompts';
3+
4+
async function main() {
5+
p.intro('Advanced Spinner Cancellation Demo');
6+
7+
// First demonstrate a visible spinner with no user input needed
8+
p.note('First, we will show a basic spinner (press CTRL+C to cancel)', 'Demo Part 1');
9+
10+
const demoSpinner = p.spinner({
11+
indicator: 'dots',
12+
onCancel: () => {
13+
p.note('Initial spinner was cancelled with CTRL+C', 'Demo Cancelled');
14+
},
15+
});
16+
17+
demoSpinner.start('Loading demo resources');
18+
19+
// Update spinner message a few times to show activity
20+
for (let i = 0; i < 5; i++) {
21+
if (demoSpinner.isCancelled) break;
22+
await sleep(1000);
23+
demoSpinner.message(`Loading demo resources (${i + 1}/5)`);
24+
}
25+
26+
if (!demoSpinner.isCancelled) {
27+
demoSpinner.stop('Demo resources loaded successfully');
28+
}
29+
30+
// Only continue with the rest of the demo if the initial spinner wasn't cancelled
31+
if (!demoSpinner.isCancelled) {
32+
// Stage 1: Get user input with multiselect
33+
p.note("Now let's select some languages to process", 'Demo Part 2');
34+
35+
const languages = await p.multiselect({
36+
message: 'Select programming languages to process:',
37+
options: [
38+
{ value: 'typescript', label: 'TypeScript' },
39+
{ value: 'javascript', label: 'JavaScript' },
40+
{ value: 'python', label: 'Python' },
41+
{ value: 'rust', label: 'Rust' },
42+
{ value: 'go', label: 'Go' },
43+
],
44+
required: true,
45+
});
46+
47+
// Handle cancellation of the multiselect
48+
if (p.isCancel(languages)) {
49+
p.cancel('Operation cancelled during language selection.');
50+
process.exit(0);
51+
}
52+
53+
// Stage 2: Show a spinner that can be cancelled
54+
const processSpinner = p.spinner({
55+
indicator: 'dots',
56+
onCancel: () => {
57+
p.note(
58+
'You cancelled during processing. Any completed work will be saved.',
59+
'Processing Cancelled'
60+
);
61+
},
62+
});
63+
64+
processSpinner.start('Starting to process selected languages...');
65+
66+
// Process each language with individual progress updates
67+
let completedCount = 0;
68+
const totalLanguages = languages.length;
69+
70+
for (const language of languages) {
71+
// Skip the rest if cancelled
72+
if (processSpinner.isCancelled) break;
73+
74+
// Update spinner message with current language
75+
processSpinner.message(`Processing ${language} (${completedCount + 1}/${totalLanguages})`);
76+
77+
try {
78+
// Simulate work - longer pause to give time to test CTRL+C
79+
await sleep(2000);
80+
completedCount++;
81+
} catch (error) {
82+
// Handle errors but continue if not cancelled
83+
if (!processSpinner.isCancelled) {
84+
p.note(`Error processing ${language}: ${error.message}`, 'Error');
85+
}
86+
}
87+
}
88+
89+
// Stage 3: Handle completion based on cancellation status
90+
if (!processSpinner.isCancelled) {
91+
processSpinner.stop(`Processed ${completedCount}/${totalLanguages} languages successfully`);
92+
93+
// Stage 4: Additional user input based on processing results
94+
if (completedCount > 0) {
95+
const action = await p.select({
96+
message: 'What would you like to do with the processed data?',
97+
options: [
98+
{ value: 'save', label: 'Save results', hint: 'Write to disk' },
99+
{ value: 'share', label: 'Share results', hint: 'Upload to server' },
100+
{ value: 'analyze', label: 'Further analysis', hint: 'Generate reports' },
101+
],
102+
});
103+
104+
if (p.isCancel(action)) {
105+
p.cancel('Operation cancelled at final stage.');
106+
process.exit(0);
107+
}
108+
109+
// Stage 5: Final action with a timer spinner
110+
p.note('Now demonstrating a timer-style spinner', 'Final Stage');
111+
112+
const finalSpinner = p.spinner({
113+
indicator: 'timer', // Use timer indicator for variety
114+
onCancel: () => {
115+
p.note(
116+
'Final operation was cancelled, but processing results are still valid.',
117+
'Final Stage Cancelled'
118+
);
119+
},
120+
});
121+
122+
finalSpinner.start(`Performing ${action} operation...`);
123+
124+
try {
125+
// Simulate final action with incremental updates
126+
for (let i = 0; i < 3; i++) {
127+
if (finalSpinner.isCancelled) break;
128+
await sleep(1500);
129+
finalSpinner.message(`Performing ${action} operation... Step ${i + 1}/3`);
130+
}
131+
132+
if (!finalSpinner.isCancelled) {
133+
finalSpinner.stop(`${action} operation completed successfully`);
134+
}
135+
} catch (error) {
136+
if (!finalSpinner.isCancelled) {
137+
finalSpinner.stop(`Error during ${action}: ${error.message}`);
138+
}
139+
}
140+
}
141+
}
142+
}
143+
144+
p.outro('Advanced demo completed. Thanks for trying out the spinner cancellation features!');
145+
}
146+
147+
main().catch((error) => {
148+
console.error('Unexpected error:', error);
149+
process.exit(1);
150+
});

examples/basic/spinner-cancel.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as p from '@clack/prompts';
2+
3+
p.intro('Spinner with cancellation detection');
4+
5+
// Example 1: Using onCancel callback
6+
const spin1 = p.spinner({
7+
indicator: 'dots',
8+
onCancel: () => {
9+
p.note('You cancelled the spinner with CTRL-C!', 'Callback detected');
10+
},
11+
});
12+
13+
spin1.start('Press CTRL-C to cancel this spinner (using callback)');
14+
15+
// Sleep for 10 seconds, allowing time for user to press CTRL-C
16+
await sleep(10000).then(() => {
17+
// Only show success message if not cancelled
18+
if (!spin1.isCancelled) {
19+
spin1.stop('Spinner completed without cancellation');
20+
}
21+
});
22+
23+
// Example 2: Checking the isCancelled property
24+
p.note('Starting second example...', 'Example 2');
25+
26+
const spin2 = p.spinner({ indicator: 'timer' });
27+
spin2.start('Press CTRL-C to cancel this spinner (polling isCancelled)');
28+
29+
await sleep(10000).then(() => {
30+
if (spin2.isCancelled) {
31+
p.note('Spinner was cancelled by the user!', 'Property check');
32+
} else {
33+
spin2.stop('Spinner completed without cancellation');
34+
}
35+
});
36+
37+
p.outro('Example completed');
38+
39+
// Helper function
40+
function sleep(ms: number) {
41+
return new Promise((resolve) => setTimeout(resolve, ms));
42+
}

packages/prompts/src/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -732,17 +732,20 @@ export const stream = {
732732

733733
export interface SpinnerOptions {
734734
indicator?: 'dots' | 'timer';
735+
onCancel?: () => void;
735736
output?: Writable;
736737
}
737738

738739
export interface SpinnerResult {
739740
start(msg?: string): void;
740741
stop(msg?: string, code?: number): void;
741742
message(msg?: string): void;
743+
readonly isCancelled: boolean;
742744
}
743745

744746
export const spinner = ({
745747
indicator = 'dots',
748+
onCancel,
746749
output = process.stdout,
747750
}: SpinnerOptions = {}): SpinnerResult => {
748751
const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'];
@@ -752,13 +755,20 @@ export const spinner = ({
752755
let unblock: () => void;
753756
let loop: NodeJS.Timeout;
754757
let isSpinnerActive = false;
758+
let isCancelled = false;
755759
let _message = '';
756760
let _prevMessage: string | undefined = undefined;
757761
let _origin: number = performance.now();
758762

759763
const handleExit = (code: number) => {
760764
const msg = code > 1 ? 'Something went wrong' : 'Canceled';
761-
if (isSpinnerActive) stop(msg, code);
765+
isCancelled = code === 1;
766+
if (isSpinnerActive) {
767+
stop(msg, code);
768+
if (isCancelled && typeof onCancel === 'function') {
769+
onCancel();
770+
}
771+
}
762772
};
763773

764774
const errorEventHandler = () => handleExit(2);
@@ -861,6 +871,9 @@ export const spinner = ({
861871
start,
862872
stop,
863873
message,
874+
get isCancelled() {
875+
return isCancelled;
876+
},
864877
};
865878
};
866879

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

879894
type Prettify<T> = {

0 commit comments

Comments
 (0)