diff --git a/.changeset/mean-years-remain.md b/.changeset/mean-years-remain.md new file mode 100644 index 00000000..ca384735 --- /dev/null +++ b/.changeset/mean-years-remain.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": minor +--- + +Add support for signals in prompts, allowing them to be aborted. diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index c9b36696..71173c0f 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -83,6 +83,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { filter: (search: string, opt: Option) => { return getFilteredOption(search, opt); }, + signal: opts.signal, input: opts.input, output: opts.output, validate: opts.validate, @@ -230,6 +231,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti return undefined; }, initialValue: opts.initialValues, + signal: opts.signal, input: opts.input, output: opts.output, render() { diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index 4992f332..ffae42d9 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -49,4 +49,5 @@ export const symbol = (state: State) => { export interface CommonOptions { input?: Readable; output?: Writable; + signal?: AbortSignal; } diff --git a/packages/prompts/src/confirm.ts b/packages/prompts/src/confirm.ts index c771ba38..4ee30acc 100644 --- a/packages/prompts/src/confirm.ts +++ b/packages/prompts/src/confirm.ts @@ -21,6 +21,7 @@ export const confirm = (opts: ConfirmOptions) => { return new ConfirmPrompt({ active, inactive, + signal: opts.signal, input: opts.input, output: opts.output, initialValue: opts.initialValue ?? true, diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index ad330613..8962c6df 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -78,6 +78,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => return new GroupMultiSelectPrompt({ options: opts.options, + signal: opts.signal, input: opts.input, output: opts.output, initialValues: opts.initialValues, diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 1512ae3b..c717958d 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -53,6 +53,7 @@ export const multiselect = (opts: MultiSelectOptions) => { return new MultiSelectPrompt({ options: opts.options, + signal: opts.signal, input: opts.input, output: opts.output, initialValues: opts.initialValues, diff --git a/packages/prompts/src/password.ts b/packages/prompts/src/password.ts index 83cef569..58632aa7 100644 --- a/packages/prompts/src/password.ts +++ b/packages/prompts/src/password.ts @@ -11,6 +11,7 @@ export const password = (opts: PasswordOptions) => { return new PasswordPrompt({ validate: opts.validate, mask: opts.mask ?? S_PASSWORD_MASK, + signal: opts.signal, input: opts.input, output: opts.output, render() { diff --git a/packages/prompts/src/select-key.ts b/packages/prompts/src/select-key.ts index a76ae205..f5bbbf69 100644 --- a/packages/prompts/src/select-key.ts +++ b/packages/prompts/src/select-key.ts @@ -27,6 +27,7 @@ export const selectKey = (opts: SelectOptions) => { return new SelectKeyPrompt({ options: opts.options, + signal: opts.signal, input: opts.input, output: opts.output, initialValue: opts.initialValue, diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index c66ca0fc..efb7c486 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -76,6 +76,7 @@ export const select = (opts: SelectOptions) => { return new SelectPrompt({ options: opts.options, + signal: opts.signal, input: opts.input, output: opts.output, initialValue: opts.initialValue, diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 5bfb6b38..803b26ce 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -35,6 +35,7 @@ export const spinner = ({ errorMessage, frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], delay = unicode ? 80 : 120, + signal, }: SpinnerOptions = {}): SpinnerResult => { const isCI = isCIFn(); @@ -72,6 +73,10 @@ export const spinner = ({ process.on('SIGINT', signalEventHandler); process.on('SIGTERM', signalEventHandler); process.on('exit', handleExit); + + if (signal) { + signal.addEventListener('abort', signalEventHandler); + } }; const clearHooks = () => { @@ -80,6 +85,10 @@ export const spinner = ({ process.removeListener('SIGINT', signalEventHandler); process.removeListener('SIGTERM', signalEventHandler); process.removeListener('exit', handleExit); + + if (signal) { + signal.removeEventListener('abort', signalEventHandler); + } }; const clearPrevMessage = () => { diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index bad4224b..2bfcd8eb 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -17,6 +17,7 @@ export const text = (opts: TextOptions) => { defaultValue: opts.defaultValue, initialValue: opts.initialValue, output: opts.output, + signal: opts.signal, input: opts.input, render() { const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 1409e5c6..b414a4eb 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -1,5 +1,25 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`autocomplete > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + " +", + "", +] +`; + exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` [ "", @@ -266,6 +286,26 @@ exports[`autocomplete > supports initialValue 1`] = ` ] `; +exports[`autocompleteMultiselect > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +└", + " +", + "", +] +`; + exports[`autocompleteMultiselect > renders error when empty selection & required is true 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/confirm.test.ts.snap b/packages/prompts/test/__snapshots__/confirm.test.ts.snap index 4c72d125..254e48f8 100644 --- a/packages/prompts/test/__snapshots__/confirm.test.ts.snap +++ b/packages/prompts/test/__snapshots__/confirm.test.ts.snap @@ -1,5 +1,19 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`confirm (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ yes? +│ ● Yes / ○ No +└ +", + " +", + "", +] +`; + exports[`confirm (isCI = false) > can cancel 1`] = ` [ "", @@ -149,6 +163,20 @@ exports[`confirm (isCI = false) > right arrow moves to next choice 1`] = ` ] `; +exports[`confirm (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ yes? +│ ● Yes / ○ No +└ +", + " +", + "", +] +`; + exports[`confirm (isCI = true) > can cancel 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap index 32d48197..942a30ce 100644 --- a/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap @@ -1,5 +1,22 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`groupMultiselect (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ Select a fruit +│ ◻ group1 +│ └ ◻ group1value0 +│ ◻ group2 +│ └ ◻ group2value0 +└ +", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = false) > can deselect an option 1`] = ` [ "", @@ -521,6 +538,23 @@ exports[`groupMultiselect (isCI = false) > values can be non-primitive 1`] = ` ] `; +exports[`groupMultiselect (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ Select a fruit +│ ◻ group1 +│ └ ◻ group1value0 +│ ◻ group2 +│ └ ◻ group2value0 +└ +", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = true) > can deselect an option 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap index 01268cf2..50c57dde 100644 --- a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap @@ -1,5 +1,20 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`multiselect (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ opt0 +│ ◻ opt1 +└ +", + " +", + "", +] +`; + exports[`multiselect (isCI = false) > can cancel 1`] = ` [ "", @@ -595,6 +610,21 @@ exports[`multiselect (isCI = false) > sliding window loops upwards 1`] = ` ] `; +exports[`multiselect (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ opt0 +│ ◻ opt1 +└ +", + " +", + "", +] +`; + exports[`multiselect (isCI = true) > can cancel 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/password.test.ts.snap b/packages/prompts/test/__snapshots__/password.test.ts.snap index b12f0e9e..c6c508d6 100644 --- a/packages/prompts/test/__snapshots__/password.test.ts.snap +++ b/packages/prompts/test/__snapshots__/password.test.ts.snap @@ -1,5 +1,19 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`password (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + " +", + "", +] +`; + exports[`password (isCI = false) > renders and clears validation errors 1`] = ` [ "", @@ -140,6 +154,20 @@ exports[`password (isCI = false) > renders message 1`] = ` ] `; +exports[`password (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + " +", + "", +] +`; + exports[`password (isCI = true) > renders and clears validation errors 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index 61985257..05d3d2d2 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -1,5 +1,20 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`select (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ ● opt0 +│ ○ opt1 +└ +", + " +", + "", +] +`; + exports[`select (isCI = false) > can cancel 1`] = ` [ "", @@ -142,6 +157,21 @@ exports[`select (isCI = false) > up arrow selects previous option 1`] = ` ] `; +exports[`select (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ ● opt0 +│ ○ opt1 +└ +", + " +", + "", +] +`; + exports[`select (isCI = true) > can cancel 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/spinner.test.ts.snap b/packages/prompts/test/__snapshots__/spinner.test.ts.snap index 8ed3304d..244e3ef4 100644 --- a/packages/prompts/test/__snapshots__/spinner.test.ts.snap +++ b/packages/prompts/test/__snapshots__/spinner.test.ts.snap @@ -1,5 +1,16 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`spinner (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +", + "■ Canceled +", + "", +] +`; + exports[`spinner (isCI = false) > indicator customization > custom delay 1`] = ` [ "", @@ -464,6 +475,17 @@ exports[`spinner (isCI = false) > stop > renders submit symbol and stops spinner ] `; +exports[`spinner (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +", + "■ Canceled +", + "", +] +`; + exports[`spinner (isCI = true) > indicator customization > custom delay 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/text.test.ts.snap b/packages/prompts/test/__snapshots__/text.test.ts.snap index cd421cc0..ea52ce78 100644 --- a/packages/prompts/test/__snapshots__/text.test.ts.snap +++ b/packages/prompts/test/__snapshots__/text.test.ts.snap @@ -1,5 +1,19 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`text (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + " +", + "", +] +`; + exports[`text (isCI = false) > can cancel 1`] = ` [ "", @@ -249,6 +263,20 @@ exports[`text (isCI = false) > validation errors render and clear 1`] = ` ] `; +exports[`text (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + " +", + "", +] +`; + exports[`text (isCI = true) > can cancel 1`] = ` [ "", diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index f462ae58..7c913334 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js'; +import { isCancel } from '../src/index.js'; import { MockReadable, MockWritable } from './test-utils.js'; describe('autocomplete', () => { @@ -151,6 +152,22 @@ describe('autocomplete', () => { expect(value).toBe('cherry'); expect(output.buffer).toMatchSnapshot(); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = autocomplete({ + message: 'foo', + options: testOptions, + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); }); describe('autocompleteMultiselect', () => { @@ -188,4 +205,20 @@ describe('autocompleteMultiselect', () => { await result; expect(output.buffer).toMatchSnapshot(); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = autocompleteMultiselect({ + message: 'foo', + options: testOptions, + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/confirm.test.ts b/packages/prompts/test/confirm.test.ts index 28da116a..0268059c 100644 --- a/packages/prompts/test/confirm.test.ts +++ b/packages/prompts/test/confirm.test.ts @@ -135,4 +135,19 @@ describe.each(['true', 'false'])('confirm (isCI = %s)', (isCI) => { expect(value).toBe(false); expect(output.buffer).toMatchSnapshot(); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.confirm({ + message: 'yes?', + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/group-multi-select.test.ts b/packages/prompts/test/group-multi-select.test.ts index 126fae32..c4b5376d 100644 --- a/packages/prompts/test/group-multi-select.test.ts +++ b/packages/prompts/test/group-multi-select.test.ts @@ -348,4 +348,23 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.groupMultiselect({ + message: 'Select a fruit', + options: { + group1: [{ value: 'group1value0' }], + group2: [{ value: 'group2value0' }], + }, + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/multi-select.test.ts b/packages/prompts/test/multi-select.test.ts index 612f1107..cd524001 100644 --- a/packages/prompts/test/multi-select.test.ts +++ b/packages/prompts/test/multi-select.test.ts @@ -299,4 +299,20 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.multiselect({ + message: 'foo', + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/password.test.ts b/packages/prompts/test/password.test.ts index 3bfa621f..9c8c9b7e 100644 --- a/packages/prompts/test/password.test.ts +++ b/packages/prompts/test/password.test.ts @@ -112,4 +112,19 @@ describe.each(['true', 'false'])('password (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.password({ + message: 'foo', + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index 2047eb48..a9c93fc1 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -129,4 +129,20 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(value).toBe('opt0'); expect(output.buffer).toMatchSnapshot(); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.select({ + message: 'foo', + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/spinner.test.ts b/packages/prompts/test/spinner.test.ts index 35089d45..21c02ce5 100644 --- a/packages/prompts/test/spinner.test.ts +++ b/packages/prompts/test/spinner.test.ts @@ -340,4 +340,18 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { } }); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.spinner({ + output, + signal: controller.signal, + }); + + result.start('Testing'); + + controller.abort(); + + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/text.test.ts b/packages/prompts/test/text.test.ts index bd22e1f5..13a00bab 100644 --- a/packages/prompts/test/text.test.ts +++ b/packages/prompts/test/text.test.ts @@ -190,4 +190,19 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { expect(value).toBe(''); expect(output.buffer).toMatchSnapshot(); }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.text({ + message: 'foo', + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); });