diff --git a/.changeset/calm-keys-clear.md b/.changeset/calm-keys-clear.md new file mode 100644 index 00000000..d2666858 --- /dev/null +++ b/.changeset/calm-keys-clear.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Pressing Escape in `autocomplete` and `autocompleteMultiselect` now clears the search input when it has text, and only cancels the prompt once the input is empty. Selections are kept. Ctrl+C still cancels right away, and the `path` prompt keeps cancelling on Escape. Pass `clearOnCancel: false` to keep the previous behavior. Prompts built on `@clack/core` can override the new `_shouldCancel()` hook to skip a cancel. diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 68091dff..f5967d32 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -63,6 +63,12 @@ export interface AutocompleteOptions * currently focused option's value, as if the user had typed it. */ completeOnTab?: boolean; + /** + * When `true` (the default), triggering the cancel action (e.g. pressing Escape) + * while the input has text clears the input instead of cancelling the prompt. + * Ctrl+C always cancels. + */ + clearOnCancel?: boolean; } export default class AutocompletePrompt extends Prompt< @@ -80,6 +86,7 @@ export default class AutocompletePrompt extends Prompt< #options: T[] | (() => T[]); #placeholder: string | undefined; #completeOnTab: boolean; + #clearOnCancel: boolean; get cursor(): number { return this.#cursor; @@ -111,6 +118,7 @@ export default class AutocompletePrompt extends Prompt< this.#options = opts.options; this.#placeholder = opts.placeholder; this.#completeOnTab = opts.completeOnTab === true; + this.#clearOnCancel = opts.clearOnCancel !== false; const options = this.options; this.filteredOptions = [...options]; this.multiple = opts.multiple === true; @@ -156,6 +164,14 @@ export default class AutocompletePrompt extends Prompt< ); } + protected override _shouldCancel(char: string | undefined, _key: Key): boolean { + if (this.#clearOnCancel && this.userInput !== '' && char !== '\x03') { + this._clearUserInput(); + return false; + } + return true; + } + #onKey(_char: string | undefined, key: Key): void { const isUpKey = key.name === 'up'; const isDownKey = key.name === 'down'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index d1c61961..d035181e 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -205,6 +205,10 @@ export default class Prompt { return true; } + protected _shouldCancel(_char: string | undefined, _key: Key): boolean { + return true; + } + protected _setValue(value: TValue | undefined): void { this.value = value; this.emit('value', this.value); @@ -220,6 +224,7 @@ export default class Prompt { } protected _clearUserInput(): void { + this.rl?.write(null, { ctrl: true, name: 'e' }); this.rl?.write(null, { ctrl: true, name: 'u' }); this._setUserInput(''); } @@ -277,7 +282,7 @@ export default class Prompt { } } - if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { + if (isActionKey([char, key?.name, key?.sequence], 'cancel') && this._shouldCancel(char, key)) { this.state = 'cancel'; } diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts index e8aabd98..8b566c96 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -1,6 +1,7 @@ import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { default as AutocompletePrompt } from '../../src/prompts/autocomplete.js'; +import { isCancel } from '../../src/utils/index.js'; import { MockReadable } from '../mock-readable.js'; import { MockWritable } from '../mock-writable.js'; @@ -328,4 +329,120 @@ describe('AutocompletePrompt', () => { expect(instance.userInput).to.equal('ban'); }); + + test('escape clears the search input instead of cancelling', async () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + const promise = instance.prompt(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', 'a', { name: 'a' }); + expect(instance.filteredOptions.length).to.equal(1); + + input.emit('keypress', '', { name: 'escape' }); + + expect(instance.state).to.equal('active'); + expect(instance.userInput).to.equal(''); + expect(instance.filteredOptions.length).to.equal(testOptions.length); + + input.emit('keypress', 'c', { name: 'c' }); + expect(instance.userInput).to.equal('c'); + + input.emit('keypress', '', { name: 'return' }); + expect(await promise).to.equal('cherry'); + }); + + test('escape cancels once the search input is empty', async () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + const promise = instance.prompt(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'escape' }); + input.emit('keypress', '', { name: 'escape' }); + + expect(isCancel(await promise)).to.equal(true); + expect(instance.state).to.equal('cancel'); + }); + + test('escape clears the whole search input when the cursor is not at the end', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + instance.prompt(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'n', { name: 'n' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'escape' }); + + expect(instance.userInput).to.equal(''); + + input.emit('keypress', 'g', { name: 'g' }); + expect(instance.userInput).to.equal('g'); + }); + + test('escape keeps selections when clearing the search input in multiple mode', async () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + multiple: true, + }); + + const promise = instance.prompt(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'escape' }); + input.emit('keypress', 'c', { name: 'c' }); + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + + expect(await promise).to.deep.equal(['banana', 'cherry']); + }); + + test('ctrl-c cancels even when the search input has text', async () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + const promise = instance.prompt(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '\x03', { name: 'c', ctrl: true }); + + expect(isCancel(await promise)).to.equal(true); + }); + + test('escape cancels with search input when clearOnCancel is false', async () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + clearOnCancel: false, + }); + + const promise = instance.prompt(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'escape' }); + + expect(isCancel(await promise)).to.equal(true); + }); }); diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index f3dd1465..d4ae2fb7 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -81,6 +81,12 @@ interface AutocompleteSharedOptions extends CommonOptions { * Custom filter function to match options against the search input. */ filter?: (search: string, option: Option) => boolean; + + /** + * When `true` (the default), pressing Escape while the search input has text + * clears the search instead of cancelling the prompt. Ctrl+C always cancels. + */ + clearOnCancel?: boolean; } export interface AutocompleteOptions extends AutocompleteSharedOptions { @@ -133,6 +139,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { initialUserInput: opts.initialUserInput, placeholder: opts.placeholder, completeOnTab: opts.completeOnTab, + clearOnCancel: opts.clearOnCancel, filter: opts.filter ?? ((search: string, opt: Option) => { @@ -342,6 +349,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti options: opts.options, multiple: true, placeholder: opts.placeholder, + clearOnCancel: opts.clearOnCancel, filter: opts.filter ?? ((search, opt) => { diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index f9de592c..a4e2d0f5 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -66,6 +66,7 @@ export const path = (opts: PathOptions) => { initialUserInput: opts.initialValue ?? opts.root ?? process.cwd(), maxItems: 5, completeOnTab: true, + clearOnCancel: false, validate(value) { if (Array.isArray(value)) { // Shouldn't ever happen since we don't enable `multiple: true` diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index fe55444c..a5211427 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -496,6 +496,41 @@ describe('autocompleteMultiselect', () => { const value = await result; expect(value).toEqual([]); }); + + test('Escape clears the search and keeps current selections', async () => { + const result = autocompleteMultiselect({ + message: 'Select fruits', + options: testOptions, + input, + output, + }); + + input.emit('keypress', 'g', { name: 'g' }); + input.emit('keypress', '', { name: 'tab' }); + input.emit('keypress', '', { name: 'escape' }); + input.emit('keypress', 'o', { name: 'o' }); + input.emit('keypress', '', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + expect(value).toEqual(['grape', 'orange']); + }); + + test('Escape cancels when clearOnCancel is false', async () => { + const result = autocompleteMultiselect({ + message: 'Select fruits', + options: testOptions, + clearOnCancel: false, + input, + output, + }); + + input.emit('keypress', 'g', { name: 'g' }); + input.emit('keypress', '', { name: 'escape' }); + + const value = await result; + expect(isCancel(value)).toBe(true); + }); }); describe('autocomplete with custom filter', () => {