Skip to content

Commit 9d4aaed

Browse files
authored
Merge branch 'main' into placeholder
2 parents fe84565 + 3ec796e commit 9d4aaed

File tree

12 files changed

+5005
-56
lines changed

12 files changed

+5005
-56
lines changed

.changeset/dirty-papayas-happen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": patch
3+
---
4+
5+
Exposes a new `SpinnerResult` type to describe the return type of `spinner`
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": patch
3+
---
4+
5+
Updates all prompts to accept a custom `output` and `input` stream

.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+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"format": "biome check --write",
1111
"lint": "biome lint --write --unsafe",
1212
"type-check": "biome lint && tsc",
13-
"test": "pnpm -r run test",
13+
"test": "pnpm --color -r run test",
1414
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
1515
"ci:publish": "changeset publish"
1616
},

packages/core/src/utils/index.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { stdin, stdout } from 'node:process';
22
import type { Key } from 'node:readline';
33
import * as readline from 'node:readline';
4-
import type { Readable } from 'node:stream';
4+
import type { Readable, Writable } from 'node:stream';
5+
import { ReadStream } from 'node:tty';
56
import { cursor } from 'sisteransi';
67
import { isActionKey } from './settings.js';
78

@@ -22,20 +23,30 @@ export function setRawMode(input: Readable, value: boolean) {
2223
if (i.isTTY) i.setRawMode(value);
2324
}
2425

26+
export interface BlockOptions {
27+
input?: Readable;
28+
output?: Writable;
29+
overwrite?: boolean;
30+
hideCursor?: boolean;
31+
}
32+
2533
export function block({
2634
input = stdin,
2735
output = stdout,
2836
overwrite = true,
2937
hideCursor = true,
30-
} = {}) {
38+
}: BlockOptions = {}) {
3139
const rl = readline.createInterface({
3240
input,
3341
output,
3442
prompt: '',
3543
tabSize: 1,
3644
});
3745
readline.emitKeypressEvents(input, rl);
38-
if (input.isTTY) input.setRawMode(true);
46+
47+
if (input instanceof ReadStream && input.isTTY) {
48+
input.setRawMode(true);
49+
}
3950

4051
const clear = (data: Buffer, { name, sequence }: Key) => {
4152
const str = String(data);
@@ -62,7 +73,9 @@ export function block({
6273
if (hideCursor) output.write(cursor.show);
6374

6475
// Prevent Windows specific issues: https://github.com/bombshell-dev/clack/issues/176
65-
if (input.isTTY && !isWindows) input.setRawMode(false);
76+
if (input instanceof ReadStream && input.isTTY && !isWindows) {
77+
input.setRawMode(false);
78+
}
6679

6780
// @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907
6881
rl.terminal = false;

packages/prompts/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,16 @@
4646
"packageManager": "pnpm@8.6.12",
4747
"scripts": {
4848
"build": "unbuild",
49-
"prepack": "pnpm build"
49+
"prepack": "pnpm build",
50+
"test": "vitest run"
5051
},
5152
"dependencies": {
5253
"@clack/core": "workspace:*",
5354
"picocolors": "^1.0.0",
5455
"sisteransi": "^1.0.5"
5556
},
5657
"devDependencies": {
57-
"is-unicode-supported": "^1.3.0"
58+
"is-unicode-supported": "^1.3.0",
59+
"vitest": "^1.6.0"
5860
}
5961
}

0 commit comments

Comments
 (0)