[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

feat: add support for signals to prompts (#340)

authored by

James Garbutt and committed by
GitHub
(Jun 3, 2025, 7:53 PM +0100) 9e5bc6cb df4eea1c

+407
+5
.changeset/mean-years-remain.md
··· 1 + --- 2 + "@clack/prompts": minor 3 + --- 4 + 5 + Add support for signals in prompts, allowing them to be aborted.
+2
packages/prompts/src/autocomplete.ts
··· 83 83 filter: (search: string, opt: Option<Value>) => { 84 84 return getFilteredOption(search, opt); 85 85 }, 86 + signal: opts.signal, 86 87 input: opts.input, 87 88 output: opts.output, 88 89 validate: opts.validate, ··· 230 231 return undefined; 231 232 }, 232 233 initialValue: opts.initialValues, 234 + signal: opts.signal, 233 235 input: opts.input, 234 236 output: opts.output, 235 237 render() {
+1
packages/prompts/src/common.ts
··· 49 49 export interface CommonOptions { 50 50 input?: Readable; 51 51 output?: Writable; 52 + signal?: AbortSignal; 52 53 }
+1
packages/prompts/src/confirm.ts
··· 21 21 return new ConfirmPrompt({ 22 22 active, 23 23 inactive, 24 + signal: opts.signal, 24 25 input: opts.input, 25 26 output: opts.output, 26 27 initialValue: opts.initialValue ?? true,
+1
packages/prompts/src/group-multi-select.ts
··· 78 78 79 79 return new GroupMultiSelectPrompt({ 80 80 options: opts.options, 81 + signal: opts.signal, 81 82 input: opts.input, 82 83 output: opts.output, 83 84 initialValues: opts.initialValues,
+1
packages/prompts/src/multi-select.ts
··· 53 53 54 54 return new MultiSelectPrompt({ 55 55 options: opts.options, 56 + signal: opts.signal, 56 57 input: opts.input, 57 58 output: opts.output, 58 59 initialValues: opts.initialValues,
+1
packages/prompts/src/password.ts
··· 11 11 return new PasswordPrompt({ 12 12 validate: opts.validate, 13 13 mask: opts.mask ?? S_PASSWORD_MASK, 14 + signal: opts.signal, 14 15 input: opts.input, 15 16 output: opts.output, 16 17 render() {
+1
packages/prompts/src/select-key.ts
··· 27 27 28 28 return new SelectKeyPrompt({ 29 29 options: opts.options, 30 + signal: opts.signal, 30 31 input: opts.input, 31 32 output: opts.output, 32 33 initialValue: opts.initialValue,
+1
packages/prompts/src/select.ts
··· 76 76 77 77 return new SelectPrompt({ 78 78 options: opts.options, 79 + signal: opts.signal, 79 80 input: opts.input, 80 81 output: opts.output, 81 82 initialValue: opts.initialValue,
+9
packages/prompts/src/spinner.ts
··· 35 35 errorMessage, 36 36 frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], 37 37 delay = unicode ? 80 : 120, 38 + signal, 38 39 }: SpinnerOptions = {}): SpinnerResult => { 39 40 const isCI = isCIFn(); 40 41 ··· 72 73 process.on('SIGINT', signalEventHandler); 73 74 process.on('SIGTERM', signalEventHandler); 74 75 process.on('exit', handleExit); 76 + 77 + if (signal) { 78 + signal.addEventListener('abort', signalEventHandler); 79 + } 75 80 }; 76 81 77 82 const clearHooks = () => { ··· 80 85 process.removeListener('SIGINT', signalEventHandler); 81 86 process.removeListener('SIGTERM', signalEventHandler); 82 87 process.removeListener('exit', handleExit); 88 + 89 + if (signal) { 90 + signal.removeEventListener('abort', signalEventHandler); 91 + } 83 92 }; 84 93 85 94 const clearPrevMessage = () => {
+1
packages/prompts/src/text.ts
··· 17 17 defaultValue: opts.defaultValue, 18 18 initialValue: opts.initialValue, 19 19 output: opts.output, 20 + signal: opts.signal, 20 21 input: opts.input, 21 22 render() { 22 23 const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
+40
packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`autocomplete > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ foo 8 + 9 + │ Search: _ 10 + │ ● Apple 11 + │ ○ Banana 12 + │ ○ Cherry 13 + │ ○ Grape 14 + │ ○ Orange 15 + │ ↑/↓ to select • Enter: confirm • Type: to search 16 + └", 17 + " 18 + ", 19 + "<cursor.show>", 20 + ] 21 + `; 22 + 3 23 exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` 4 24 [ 5 25 "<cursor.hide>", ··· 260 280 "<erase.down>", 261 281 "◇ Select a fruit 262 282 │ Cherry", 283 + " 284 + ", 285 + "<cursor.show>", 286 + ] 287 + `; 288 + 289 + exports[`autocompleteMultiselect > can be aborted by a signal 1`] = ` 290 + [ 291 + "<cursor.hide>", 292 + "│ 293 + ◆ foo 294 + 295 + │ Search: _ 296 + │ ◻ Apple 297 + │ ◻ Banana 298 + │ ◻ Cherry 299 + │ ◻ Grape 300 + │ ◻ Orange 301 + │ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search 302 + └", 263 303 " 264 304 ", 265 305 "<cursor.show>",
+28
packages/prompts/test/__snapshots__/confirm.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`confirm (isCI = false) > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ yes? 8 + │ ● Yes / ○ No 9 + └ 10 + ", 11 + " 12 + ", 13 + "<cursor.show>", 14 + ] 15 + `; 16 + 3 17 exports[`confirm (isCI = false) > can cancel 1`] = ` 4 18 [ 5 19 "<cursor.hide>", ··· 143 157 "<erase.down>", 144 158 "◇ foo 145 159 │ No", 160 + " 161 + ", 162 + "<cursor.show>", 163 + ] 164 + `; 165 + 166 + exports[`confirm (isCI = true) > can be aborted by a signal 1`] = ` 167 + [ 168 + "<cursor.hide>", 169 + "│ 170 + ◆ yes? 171 + │ ● Yes / ○ No 172 + └ 173 + ", 146 174 " 147 175 ", 148 176 "<cursor.show>",
+34
packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`groupMultiselect (isCI = false) > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ Select a fruit 8 + │ ◻ group1 9 + │ └ ◻ group1value0 10 + │ ◻ group2 11 + │ └ ◻ group2value0 12 + └ 13 + ", 14 + " 15 + ", 16 + "<cursor.show>", 17 + ] 18 + `; 19 + 3 20 exports[`groupMultiselect (isCI = false) > can deselect an option 1`] = ` 4 21 [ 5 22 "<cursor.hide>", ··· 515 532 "<erase.down>", 516 533 "◇ foo 517 534 │ value0", 535 + " 536 + ", 537 + "<cursor.show>", 538 + ] 539 + `; 540 + 541 + exports[`groupMultiselect (isCI = true) > can be aborted by a signal 1`] = ` 542 + [ 543 + "<cursor.hide>", 544 + "│ 545 + ◆ Select a fruit 546 + │ ◻ group1 547 + │ └ ◻ group1value0 548 + │ ◻ group2 549 + │ └ ◻ group2value0 550 + └ 551 + ", 518 552 " 519 553 ", 520 554 "<cursor.show>",
+30
packages/prompts/test/__snapshots__/multi-select.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`multiselect (isCI = false) > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ foo 8 + │ ◻ opt0 9 + │ ◻ opt1 10 + └ 11 + ", 12 + " 13 + ", 14 + "<cursor.show>", 15 + ] 16 + `; 17 + 3 18 exports[`multiselect (isCI = false) > can cancel 1`] = ` 4 19 [ 5 20 "<cursor.hide>", ··· 589 604 "<erase.down>", 590 605 "◇ foo 591 606 │ opt11", 607 + " 608 + ", 609 + "<cursor.show>", 610 + ] 611 + `; 612 + 613 + exports[`multiselect (isCI = true) > can be aborted by a signal 1`] = ` 614 + [ 615 + "<cursor.hide>", 616 + "│ 617 + ◆ foo 618 + │ ◻ opt0 619 + │ ◻ opt1 620 + └ 621 + ", 592 622 " 593 623 ", 594 624 "<cursor.show>",
+28
packages/prompts/test/__snapshots__/password.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`password (isCI = false) > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ foo 8 + │ _ 9 + └ 10 + ", 11 + " 12 + ", 13 + "<cursor.show>", 14 + ] 15 + `; 16 + 3 17 exports[`password (isCI = false) > renders and clears validation errors 1`] = ` 4 18 [ 5 19 "<cursor.hide>", ··· 134 148 "<erase.down>", 135 149 "◇ foo 136 150 │", 151 + " 152 + ", 153 + "<cursor.show>", 154 + ] 155 + `; 156 + 157 + exports[`password (isCI = true) > can be aborted by a signal 1`] = ` 158 + [ 159 + "<cursor.hide>", 160 + "│ 161 + ◆ foo 162 + │ _ 163 + └ 164 + ", 137 165 " 138 166 ", 139 167 "<cursor.show>",
+30
packages/prompts/test/__snapshots__/select.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`select (isCI = false) > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ foo 8 + │ ● opt0 9 + │ ○ opt1 10 + └ 11 + ", 12 + " 13 + ", 14 + "<cursor.show>", 15 + ] 16 + `; 17 + 3 18 exports[`select (isCI = false) > can cancel 1`] = ` 4 19 [ 5 20 "<cursor.hide>", ··· 136 151 "<erase.down>", 137 152 "◇ foo 138 153 │ opt0", 154 + " 155 + ", 156 + "<cursor.show>", 157 + ] 158 + `; 159 + 160 + exports[`select (isCI = true) > can be aborted by a signal 1`] = ` 161 + [ 162 + "<cursor.hide>", 163 + "│ 164 + ◆ foo 165 + │ ● opt0 166 + │ ○ opt1 167 + └ 168 + ", 139 169 " 140 170 ", 141 171 "<cursor.show>",
+22
packages/prompts/test/__snapshots__/spinner.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`spinner (isCI = false) > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ", 8 + "■ Canceled 9 + ", 10 + "<cursor.show>", 11 + ] 12 + `; 13 + 3 14 exports[`spinner (isCI = false) > indicator customization > custom delay 1`] = ` 4 15 [ 5 16 "<cursor.hide>", ··· 459 470 "<cursor.backward count=999>", 460 471 "<erase.down>", 461 472 "◇ 473 + ", 474 + "<cursor.show>", 475 + ] 476 + `; 477 + 478 + exports[`spinner (isCI = true) > can be aborted by a signal 1`] = ` 479 + [ 480 + "<cursor.hide>", 481 + "│ 482 + ", 483 + "■ Canceled 462 484 ", 463 485 "<cursor.show>", 464 486 ]
+28
packages/prompts/test/__snapshots__/text.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 + exports[`text (isCI = false) > can be aborted by a signal 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ foo 8 + │ _ 9 + └ 10 + ", 11 + " 12 + ", 13 + "<cursor.show>", 14 + ] 15 + `; 16 + 3 17 exports[`text (isCI = false) > can cancel 1`] = ` 4 18 [ 5 19 "<cursor.hide>", ··· 243 257 "<erase.down>", 244 258 "◇ foo 245 259 │ xy", 260 + " 261 + ", 262 + "<cursor.show>", 263 + ] 264 + `; 265 + 266 + exports[`text (isCI = true) > can be aborted by a signal 1`] = ` 267 + [ 268 + "<cursor.hide>", 269 + "│ 270 + ◆ foo 271 + │ _ 272 + └ 273 + ", 246 274 " 247 275 ", 248 276 "<cursor.show>",
+33
packages/prompts/test/autocomplete.test.ts
··· 1 1 import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 2 import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js'; 3 + import { isCancel } from '../src/index.js'; 3 4 import { MockReadable, MockWritable } from './test-utils.js'; 4 5 5 6 describe('autocomplete', () => { ··· 151 152 expect(value).toBe('cherry'); 152 153 expect(output.buffer).toMatchSnapshot(); 153 154 }); 155 + 156 + test('can be aborted by a signal', async () => { 157 + const controller = new AbortController(); 158 + const result = autocomplete({ 159 + message: 'foo', 160 + options: testOptions, 161 + input, 162 + output, 163 + signal: controller.signal, 164 + }); 165 + 166 + controller.abort(); 167 + const value = await result; 168 + expect(isCancel(value)).toBe(true); 169 + expect(output.buffer).toMatchSnapshot(); 170 + }); 154 171 }); 155 172 156 173 describe('autocompleteMultiselect', () => { ··· 186 203 input.emit('keypress', '', { name: 'tab' }); 187 204 input.emit('keypress', '', { name: 'return' }); 188 205 await result; 206 + expect(output.buffer).toMatchSnapshot(); 207 + }); 208 + 209 + test('can be aborted by a signal', async () => { 210 + const controller = new AbortController(); 211 + const result = autocompleteMultiselect({ 212 + message: 'foo', 213 + options: testOptions, 214 + input, 215 + output, 216 + signal: controller.signal, 217 + }); 218 + 219 + controller.abort(); 220 + const value = await result; 221 + expect(isCancel(value)).toBe(true); 189 222 expect(output.buffer).toMatchSnapshot(); 190 223 }); 191 224 });
+15
packages/prompts/test/confirm.test.ts
··· 135 135 expect(value).toBe(false); 136 136 expect(output.buffer).toMatchSnapshot(); 137 137 }); 138 + 139 + test('can be aborted by a signal', async () => { 140 + const controller = new AbortController(); 141 + const result = prompts.confirm({ 142 + message: 'yes?', 143 + input, 144 + output, 145 + signal: controller.signal, 146 + }); 147 + 148 + controller.abort(); 149 + const value = await result; 150 + expect(prompts.isCancel(value)).toBe(true); 151 + expect(output.buffer).toMatchSnapshot(); 152 + }); 138 153 });
+19
packages/prompts/test/group-multi-select.test.ts
··· 348 348 expect(output.buffer).toMatchSnapshot(); 349 349 }); 350 350 }); 351 + 352 + test('can be aborted by a signal', async () => { 353 + const controller = new AbortController(); 354 + const result = prompts.groupMultiselect({ 355 + message: 'Select a fruit', 356 + options: { 357 + group1: [{ value: 'group1value0' }], 358 + group2: [{ value: 'group2value0' }], 359 + }, 360 + input, 361 + output, 362 + signal: controller.signal, 363 + }); 364 + 365 + controller.abort(); 366 + const value = await result; 367 + expect(prompts.isCancel(value)).toBe(true); 368 + expect(output.buffer).toMatchSnapshot(); 369 + }); 351 370 });
+16
packages/prompts/test/multi-select.test.ts
··· 299 299 expect(prompts.isCancel(value)).toBe(true); 300 300 expect(output.buffer).toMatchSnapshot(); 301 301 }); 302 + 303 + test('can be aborted by a signal', async () => { 304 + const controller = new AbortController(); 305 + const result = prompts.multiselect({ 306 + message: 'foo', 307 + options: [{ value: 'opt0' }, { value: 'opt1' }], 308 + input, 309 + output, 310 + signal: controller.signal, 311 + }); 312 + 313 + controller.abort(); 314 + const value = await result; 315 + expect(prompts.isCancel(value)).toBe(true); 316 + expect(output.buffer).toMatchSnapshot(); 317 + }); 302 318 });
+15
packages/prompts/test/password.test.ts
··· 112 112 expect(prompts.isCancel(value)).toBe(true); 113 113 expect(output.buffer).toMatchSnapshot(); 114 114 }); 115 + 116 + test('can be aborted by a signal', async () => { 117 + const controller = new AbortController(); 118 + const result = prompts.password({ 119 + message: 'foo', 120 + input, 121 + output, 122 + signal: controller.signal, 123 + }); 124 + 125 + controller.abort(); 126 + const value = await result; 127 + expect(prompts.isCancel(value)).toBe(true); 128 + expect(output.buffer).toMatchSnapshot(); 129 + }); 115 130 });
+16
packages/prompts/test/select.test.ts
··· 129 129 expect(value).toBe('opt0'); 130 130 expect(output.buffer).toMatchSnapshot(); 131 131 }); 132 + 133 + test('can be aborted by a signal', async () => { 134 + const controller = new AbortController(); 135 + const result = prompts.select({ 136 + message: 'foo', 137 + options: [{ value: 'opt0' }, { value: 'opt1' }], 138 + input, 139 + output, 140 + signal: controller.signal, 141 + }); 142 + 143 + controller.abort(); 144 + const value = await result; 145 + expect(prompts.isCancel(value)).toBe(true); 146 + expect(output.buffer).toMatchSnapshot(); 147 + }); 132 148 });
+14
packages/prompts/test/spinner.test.ts
··· 340 340 } 341 341 }); 342 342 }); 343 + 344 + test('can be aborted by a signal', async () => { 345 + const controller = new AbortController(); 346 + const result = prompts.spinner({ 347 + output, 348 + signal: controller.signal, 349 + }); 350 + 351 + result.start('Testing'); 352 + 353 + controller.abort(); 354 + 355 + expect(output.buffer).toMatchSnapshot(); 356 + }); 343 357 });
+15
packages/prompts/test/text.test.ts
··· 190 190 expect(value).toBe(''); 191 191 expect(output.buffer).toMatchSnapshot(); 192 192 }); 193 + 194 + test('can be aborted by a signal', async () => { 195 + const controller = new AbortController(); 196 + const result = prompts.text({ 197 + message: 'foo', 198 + input, 199 + output, 200 + signal: controller.signal, 201 + }); 202 + 203 + controller.abort(); 204 + const value = await result; 205 + expect(prompts.isCancel(value)).toBe(true); 206 + expect(output.buffer).toMatchSnapshot(); 207 + }); 193 208 });