diff --git a/.changeset/every-aliens-cough.md b/.changeset/every-aliens-cough.md new file mode 100644 index 00000000..f7eea7b1 --- /dev/null +++ b/.changeset/every-aliens-cough.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Adds support for loading autocomplete results from an async function diff --git a/examples/basic/autocomplete-async-api.ts b/examples/basic/autocomplete-async-api.ts new file mode 100644 index 00000000..939c8d58 --- /dev/null +++ b/examples/basic/autocomplete-async-api.ts @@ -0,0 +1,103 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +/** + * Example demonstrating async autocomplete with API search + * Uses the REST Countries API to search for countries by name + */ + +interface Country { + code: string; + name: string; + region: string; +} + +async function searchCountries(query: string, signal?: AbortSignal): Promise { + if (!query) { + // Return empty array for empty query + return []; + } + + try { + const response = await fetch( + `https://restcountries.com/v3.1/name/${encodeURIComponent(query)}`, + { + signal, + } + ); + + if (!response.ok) { + if (response.status === 404) { + // No countries found + return []; + } + throw new Error(`API error: ${response.status}`); + } + + const countries: Array<{ cca2: string; name: { common: string }; region: string }> = + await response.json(); + + return countries.map((country) => ({ + code: country.cca2, + name: country.name.common, + region: country.region, + })); + } catch (error) { + // If request was aborted, return empty array + if (signal?.aborted) { + return []; + } + throw error; + } +} + +async function main() { + console.clear(); + + p.intro(`${color.bgCyan(color.black(' Async Autocomplete - API Search '))}`); + + p.note( + ` +${color.cyan('This example demonstrates async autocomplete with API search:')} +- Type to search countries via ${color.yellow('REST Countries API')} +- Search is ${color.yellow('debounced')} to avoid excessive requests +- Previous requests are ${color.yellow('cancelled')} when you type more +- Shows ${color.yellow('loading spinner')} during API calls +- Handles ${color.yellow('errors gracefully')} + `, + 'Instructions' + ); + + const result = await p.autocomplete({ + message: 'Search for a country', + filteredOptions: async (query, signal) => { + const countries = await searchCountries(query, signal); + return countries.map((country) => ({ + value: country, + label: country.name, + hint: country.region, + })); + }, + debounce: 300, // Wait 300ms after user stops typing + }); + + if (p.isCancel(result)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + // Display result + const country = result as Country; + p.note( + ` +${color.green('Name:')} ${country.name} +${color.green('Code:')} ${country.code} +${color.green('Region:')} ${country.region} + `, + 'Selected Country' + ); + + p.outro('Done!'); +} + +main().catch(console.error); diff --git a/examples/basic/autocomplete-multiselect-required.ts b/examples/basic/autocomplete-multiselect-required.ts new file mode 100644 index 00000000..f0f25231 --- /dev/null +++ b/examples/basic/autocomplete-multiselect-required.ts @@ -0,0 +1,71 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +/** + * Example demonstrating the autocomplete multiselect with required validation + * This reproduces the behavior from the skipped test: + * "renders error when empty selection & required is true" + */ + +async function main() { + console.clear(); + + p.intro(`${color.bgCyan(color.black(' Autocomplete Multiselect - Required Validation '))}`); + + p.note( + ` +${color.cyan('This example demonstrates required validation:')} +- ${color.yellow('Type')} to filter the list +- Use ${color.yellow('up/down arrows')} to navigate +- Press ${color.yellow('Space')} or ${color.yellow('Tab')} to select items +- Press ${color.yellow('Enter')} to submit ${color.red('(must select at least one item)')} +- Try submitting without selecting - you should see an error! + `, + 'Instructions' + ); + + const testOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'grape', label: 'Grape' }, + { value: 'orange', label: 'Orange' }, + ]; + + // Use autocompleteMultiselect with required: true + const result = await p.autocompleteMultiselect({ + message: 'Select a fruit', + options: testOptions, + required: true, // This should trigger validation if nothing is selected + }); + + if (p.isCancel(result)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + // Type guard + function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); + } + + if (!isStringArray(result)) { + throw new Error('Unexpected result type'); + } + + const selectedFruits = result; + + // Display result + if (selectedFruits.length === 0) { + p.note( + color.yellow('No fruits were selected (this should not happen with required: true)'), + 'Warning' + ); + } else { + p.note(`You selected: ${color.green(selectedFruits.join(', '))}`, 'Selection Complete'); + } + + p.outro('Done!'); +} + +main().catch(console.error); diff --git a/examples/basic/autocomplete-sync-custom.ts b/examples/basic/autocomplete-sync-custom.ts new file mode 100644 index 00000000..6e8e044b --- /dev/null +++ b/examples/basic/autocomplete-sync-custom.ts @@ -0,0 +1,106 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +/** + * Example demonstrating sync autocomplete with custom filtering logic + * Uses filteredOptions to implement fuzzy matching + */ + +interface Package { + name: string; + description: string; + downloads: number; +} + +const packages: Package[] = [ + { + name: 'react', + description: 'A JavaScript library for building user interfaces', + downloads: 20000000, + }, + { name: 'vue', description: 'Progressive JavaScript Framework', downloads: 5000000 }, + { + name: 'angular', + description: 'Platform for building mobile and desktop apps', + downloads: 3000000, + }, + { name: 'svelte', description: 'Cybernetically enhanced web apps', downloads: 1000000 }, + { name: 'next', description: 'The React Framework for Production', downloads: 8000000 }, + { name: 'nuxt', description: 'The Intuitive Vue Framework', downloads: 2000000 }, + { name: 'express', description: 'Fast, unopinionated web framework', downloads: 15000000 }, + { name: 'fastify', description: 'Fast and low overhead web framework', downloads: 500000 }, + { name: 'vite', description: 'Next generation frontend tooling', downloads: 4000000 }, + { name: 'webpack', description: 'Module bundler', downloads: 12000000 }, +]; + +/** + * Custom fuzzy matching filter + * Matches if all characters in query appear in order in the name + */ +function fuzzyMatch(query: string, text: string): boolean { + if (!query) return true; + + const queryChars = query.toLowerCase().split(''); + const textLower = text.toLowerCase(); + let textIndex = 0; + + for (const char of queryChars) { + const foundIndex = textLower.indexOf(char, textIndex); + if (foundIndex === -1) return false; + textIndex = foundIndex + 1; + } + + return true; +} + +async function main() { + console.clear(); + + p.intro(`${color.bgCyan(color.black(' Sync Autocomplete - Custom Filtering '))}`); + + p.note( + ` +${color.cyan('This example demonstrates sync autocomplete with custom filtering:')} +- Uses ${color.yellow('fuzzy matching')} algorithm +- Type partial characters: "${color.yellow('rx')}" matches "${color.green('react')}" +- ${color.yellow('Instant results')} - no debouncing needed for sync +- Also searches in ${color.yellow('descriptions')} + `, + 'Instructions' + ); + + const result = await p.autocomplete({ + message: 'Select a package', + filteredOptions: (query) => { + // Custom filtering logic with fuzzy matching + return packages + .filter((pkg) => fuzzyMatch(query, pkg.name) || fuzzyMatch(query, pkg.description)) + .sort((a, b) => b.downloads - a.downloads) // Sort by popularity + .map((pkg) => ({ + value: pkg, + label: pkg.name, + hint: `${(pkg.downloads / 1000000).toFixed(1)}M downloads`, + })); + }, + }); + + if (p.isCancel(result)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + // Display result + const pkg = result as Package; + p.note( + ` +${color.green('Name:')} ${pkg.name} +${color.green('Description:')} ${pkg.description} +${color.green('Downloads:')} ${(pkg.downloads / 1000000).toFixed(1)}M/month + `, + 'Selected Package' + ); + + p.outro('Done!'); +} + +main().catch(console.error); diff --git a/examples/basic/package.json b/examples/basic/package.json index f8e617e7..3ff8afc3 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -16,7 +16,10 @@ "path": "jiti ./path.ts", "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", "spinner-timer": "jiti ./spinner-timer.ts", - "task-log": "jiti ./task-log.ts" + "task-log": "jiti ./task-log.ts", + "autocomplete-multiselect-required": "jiti ./autocomplete-multiselect-required.ts", + "autocomplete-async-api": "jiti ./autocomplete-async-api.ts", + "autocomplete-sync-custom": "jiti ./autocomplete-sync-custom.ts" }, "devDependencies": { "cross-env": "^7.0.3" diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 6e308ff5..ebf07e80 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -8,6 +8,14 @@ interface OptionLike { } type FilterFunction = (search: string, opt: T) => boolean; +type FilteredOptionsFunction = ( + query: string, + signal?: AbortSignal +) => T[] | PromiseLike; + +function isThenable(value: any): value is PromiseLike { + return value != null && typeof value.then === 'function'; +} function getCursorForValue( selected: T['value'] | undefined, @@ -44,13 +52,30 @@ function normalisedValue(multiple: boolean, values: T[] | undefined): T | T[] return values[0]; } -interface AutocompleteOptions +// Pass an array of options and optional filter function and it will perform filtering automatically +interface AutocompleteUnfilteredOptions extends PromptOptions> { options: T[] | ((this: AutocompletePrompt) => T[]); filter?: FilterFunction; + filteredOptions?: never; + debounce?: never; + multiple?: boolean; +} + +// Pass a function that returns filtered options based on user input +interface AutocompleteFilteredOptions + extends PromptOptions> { + filteredOptions: FilteredOptionsFunction; + debounce?: number; + options?: never; + filter?: never; multiple?: boolean; } +type AutocompleteOptions = + | AutocompleteUnfilteredOptions + | AutocompleteFilteredOptions; + export default class AutocompletePrompt extends Prompt< T['value'] | T['value'][] > { @@ -58,12 +83,22 @@ export default class AutocompletePrompt extends Prompt< multiple: boolean; isNavigating = false; selectedValues: Array = []; - focusedValue: T['value'] | undefined; + + // Async state (only used when filteredOptions returns a Promise) + isLoading = false; + searchError: Error | undefined; + spinnerFrameIndex = 0; + #cursor = 0; #lastUserInput = ''; - #filterFn: FilterFunction; - #options: T[] | (() => T[]); + + #filteredOptionsFn: FilteredOptionsFunction; + #isAsync = false; + #debounceMs = 300; + #debounceTimer: NodeJS.Timeout | null = null; + #abortController: AbortController | null = null; + #spinnerInterval: NodeJS.Timeout | null = null; get cursor(): number { return this.#cursor; @@ -81,50 +116,89 @@ export default class AutocompletePrompt extends Prompt< return `${s1}${color.inverse(s2)}${s3.join('')}`; } - get options(): T[] { - if (typeof this.#options === 'function') { - return this.#options(); - } - return this.#options; - } - constructor(opts: AutocompleteOptions) { super(opts); - this.#options = opts.options; - const options = this.options; - this.filteredOptions = [...options]; this.multiple = opts.multiple === true; - this.#filterFn = opts.filter ?? defaultFilter; - let initialValues: unknown[] | undefined; - if (opts.initialValue && Array.isArray(opts.initialValue)) { - if (this.multiple) { - initialValues = opts.initialValue; - } else { - initialValues = opts.initialValue.slice(0, 1); + this.filteredOptions = []; + + if (opts.filteredOptions) { + // User provided a function to get filtered options + + // Validate incompatible options + if (opts.options) { + throw new Error('AutocompletePrompt: "options" cannot be used with "filteredOptions"'); + } + if (opts.filter) { + throw new Error('AutocompletePrompt: "filter" cannot be used with "filteredOptions"'); } + + this.#filteredOptionsFn = opts.filteredOptions; + this.#debounceMs = opts.debounce ?? 300; } else { - if (!this.multiple && this.options.length > 0) { - initialValues = [this.options[0].value]; + // Otherwise, user passed static options and optional filter function, and we'll handle filtering + + // Validate incompatible options + if (opts.debounce) { + throw new Error('AutocompletePrompt: "debounce" is only valid with "filteredOptions"'); } - } - if (initialValues) { - for (const selectedValue of initialValues) { - const selectedIndex = options.findIndex((opt) => opt.value === selectedValue); - if (selectedIndex !== -1) { - this.toggleSelected(selectedValue); - this.#cursor = selectedIndex; + const getOptions: () => T[] = + typeof opts.options === 'function' ? opts.options.bind(this) : () => opts.options as T[]; + const filterFn = opts.filter ?? defaultFilter; + + this.#filteredOptionsFn = (query: string) => { + const allOptions = getOptions(); + if (!query) { + return [...allOptions]; + } + return allOptions.filter((opt) => filterFn(query, opt)); + }; + + // Set initial filteredOptions + this.filteredOptions = [...getOptions()]; + + // Handle initial values and focus (only for static options mode) + if (this.filteredOptions.length > 0) { + let initialValues: unknown[] | undefined; + + if (Array.isArray(opts.initialValue)) { + initialValues = this.multiple ? opts.initialValue : opts.initialValue.slice(0, 1); + } else if (!this.multiple) { + // For single-select, default to first option + initialValues = [this.filteredOptions[0].value]; + } + + if (initialValues) { + for (const selectedValue of initialValues) { + const selectedIndex = this.filteredOptions.findIndex( + (opt) => opt.value === selectedValue + ); + if (selectedIndex !== -1) { + this.toggleSelected(selectedValue); + this.#cursor = selectedIndex; + } + } } + + // Set focusedValue to enable tab/space selection + this.focusedValue = this.filteredOptions[this.#cursor]?.value; } } - this.focusedValue = this.options[this.#cursor]?.value; - this.on('key', (char, key) => this.#onKey(char, key)); this.on('userInput', (value) => this.#onUserInputChanged(value)); } + override prompt() { + // Trigger initial search for filteredOptions mode (static mode already has options set) + if (this.filteredOptions.length === 0) { + this.#performSearch(''); + } + + return super.prompt(); + } + protected override _isActionKey(char: string | undefined, key: Key): boolean { return ( char === '\t' || @@ -194,24 +268,106 @@ export default class AutocompletePrompt extends Prompt< } #onUserInputChanged(value: string): void { - if (value !== this.#lastUserInput) { - this.#lastUserInput = value; + if (value === this.#lastUserInput) { + return; + } - const options = this.options; + this.#lastUserInput = value; - if (value) { - this.filteredOptions = options.filter((opt) => this.#filterFn(value, opt)); - } else { - this.filteredOptions = [...options]; + if (this.#isAsync) { + this.#triggerDebouncedSearch(value); + } else { + this.#performSearch(value); + } + } + + #triggerDebouncedSearch(query: string): void { + if (this.#debounceTimer) { + clearTimeout(this.#debounceTimer); + } + + // Show loading state immediately + if (!this.isLoading) { + this.#setLoadingState(true); + } + + this.#debounceTimer = setTimeout(() => { + this.#performSearch(query); + }, this.#debounceMs); + } + + #performSearch(query: string): void { + // Create AbortController for this search (sync functions ignore signal, async can use for cancellation) + if (this.#abortController) { + this.#abortController.abort(); + } + this.#abortController = new AbortController(); + const result = this.#filteredOptionsFn(query, this.#abortController.signal); + + if (isThenable(result)) { + // Detected async - enable debouncing for subsequent searches + this.#isAsync = true; + this.#handleAsyncResult(result); + } else { + // Sync result - apply immediately + this.filteredOptions = result as T[]; + this.#updateCursorAndFocus(); + this.render(); + } + } + + async #handleAsyncResult(resultPromise: PromiseLike): Promise { + // Store reference to detect if a newer request aborts this one + const currentController = this.#abortController; + this.#setLoadingState(true); + + try { + const results = await resultPromise; + // Check if this request was aborted (a new request came in) + if (currentController?.signal.aborted) { + return; } - this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions); - this.focusedValue = this.filteredOptions[this.#cursor]?.value; - if (!this.multiple) { - if (this.focusedValue !== undefined) { - this.toggleSelected(this.focusedValue); - } else { - this.deselectAll(); - } + + this.filteredOptions = results; + this.#updateCursorAndFocus(); + } catch (error) { + if (currentController?.signal.aborted) { + return; + } + this.searchError = error instanceof Error ? error : new Error(String(error)); + } finally { + // Turn off loading state when done (unless request was aborted) + if (!currentController?.signal.aborted) { + this.#setLoadingState(false); + } + } + } + + #setLoadingState(isLoading: boolean): void { + this.isLoading = isLoading; + this.spinnerFrameIndex = 0; + + if (this.#spinnerInterval) { + clearInterval(this.#spinnerInterval); + } + this.#spinnerInterval = isLoading + ? setInterval(() => { + this.spinnerFrameIndex = (this.spinnerFrameIndex + 1) % 4; + this.render(); + }, 80) + : null; + + this.render(); + } + + #updateCursorAndFocus(): void { + this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions); + this.focusedValue = this.filteredOptions[this.#cursor]?.value; + if (!this.multiple) { + if (this.focusedValue !== undefined) { + this.toggleSelected(this.focusedValue); + } else { + this.deselectAll(); } } } diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 5c996e2c..3267874b 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -263,7 +263,7 @@ export default class Prompt { this.output.write(cursor.move(-999, lines * -1)); } - private render() { + protected render() { const frame = wrapAnsi(this._render(this) ?? '', process.stdout.columns, { hard: true, trim: false, diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts index f51ed05b..57b7a762 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -197,4 +197,245 @@ describe('AutocompletePrompt', () => { expect(instance.selectedValues).to.deep.equal([]); expect(result).to.deep.equal([]); }); + + // Sync filtered mode tests + describe('sync filtered mode', () => { + test('triggers initial search with empty query', () => { + const filterFn = vi.fn((query: string, _signal?: AbortSignal) => { + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: filterFn, + }); + + instance.prompt(); + + // Should call with empty query and signal + expect(filterFn).toHaveBeenCalledWith('', expect.any(AbortSignal)); + }); + + test('calls filteredOptions function on user input', () => { + const filterFn = vi.fn((query: string, _signal?: AbortSignal) => { + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: filterFn, + }); + + instance.prompt(); + + // Type 'a' + input.emit('keypress', 'a', { name: 'a' }); + + expect(filterFn).toHaveBeenCalledWith('a', expect.any(AbortSignal)); + expect(instance.filteredOptions).toEqual([ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'grape', label: 'Grape' }, + { value: 'orange', label: 'Orange' }, + ]); + }); + + test('updates filteredOptions based on function result', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: (query, _signal?) => { + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }, + }); + + instance.prompt(); + + // Type 'app' + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'p', { name: 'p' }); + input.emit('keypress', 'p', { name: 'p' }); + + expect(instance.filteredOptions).toEqual([{ value: 'apple', label: 'Apple' }]); + }); + }); + + // Async filtered mode tests + describe('async filtered mode', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('triggers initial search with empty query', async () => { + const searchFn = vi.fn(async () => testOptions); + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: searchFn, + }); + + const promise = instance.prompt(); + + // Wait for initial search to be triggered + await vi.runAllTimersAsync(); + + // Should call with signal even on first call + expect(searchFn).toHaveBeenCalledWith('', expect.any(AbortSignal)); + + input.emit('keypress', '', { name: 'return' }); + await promise; + }); + + test('debounces search requests', async () => { + const searchFn = vi.fn(async (query: string) => { + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: searchFn, + debounce: 300, + }); + + const promise = instance.prompt(); + + // Wait for initial search + await vi.runAllTimersAsync(); + + // Type 'a' + input.emit('keypress', 'a', { name: 'a' }); + + // Search should not have been called yet (debouncing) + expect(searchFn).toHaveBeenCalledTimes(1); // Only initial search + + // Wait for debounce + await vi.advanceTimersByTimeAsync(300); + await vi.runAllTimersAsync(); + + // Now search should have been called + expect(searchFn).toHaveBeenCalledWith('a', expect.any(AbortSignal)); + + input.emit('keypress', '', { name: 'return' }); + await promise; + }); + + test('sets isLoading flag during search', async () => { + let resolveSearch!: (value: any) => void; + const searchPromise = new Promise((resolve) => { + resolveSearch = resolve; + }); + + const searchFn = vi.fn(async () => { + await searchPromise; + return testOptions; + }); + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: searchFn, + }); + + const promise = instance.prompt(); + + // Advance timers to trigger initial search + await vi.runOnlyPendingTimersAsync(); + + // Should be loading + expect(instance.isLoading).to.equal(true); + + // Resolve the search + resolveSearch?.(testOptions); + await vi.runAllTimersAsync(); + + // Should not be loading anymore + expect(instance.isLoading).to.equal(false); + + input.emit('keypress', '', { name: 'return' }); + await promise; + }); + + test('handles search errors', async () => { + const error = new Error('Search failed'); + const searchFn = vi.fn(async () => { + throw error; + }); + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: searchFn, + }); + + const promise = instance.prompt(); + + // Wait for initial search + await vi.runAllTimersAsync(); + + // Error should be set + expect(instance.searchError).to.equal(error); + expect(instance.isLoading).to.equal(false); + + input.emit('keypress', '', { name: 'return' }); + await promise; + }); + + test('cancels in-flight requests when query changes', async () => { + const searchFn = vi.fn(async (query: string, signal?: AbortSignal) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal?.aborted) throw new Error('Aborted'); + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + filteredOptions: searchFn, + debounce: 100, + }); + + const promise = instance.prompt(); + + // Wait for initial search + await vi.runAllTimersAsync(); + + // Type 'a' + input.emit('keypress', 'a', { name: 'a' }); + + // Wait for debounce + await vi.advanceTimersByTimeAsync(100); + + // Before first search completes, type another character + input.emit('keypress', 'p', { name: 'p' }); + + // Wait for debounce + await vi.advanceTimersByTimeAsync(100); + + // The first search should have been aborted + expect(searchFn).toHaveBeenCalledWith('a', expect.any(AbortSignal)); + expect(searchFn).toHaveBeenCalledWith('ap', expect.any(AbortSignal)); + + // Wait for all timers to complete + await vi.runAllTimersAsync(); + + input.emit('keypress', '', { name: 'return' }); + await promise; + }); + }); }); diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index e55b285f..b6635b03 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -8,6 +8,7 @@ import { S_CHECKBOX_SELECTED, S_RADIO_ACTIVE, S_RADIO_INACTIVE, + S_SPINNER_FRAMES, symbol, } from './common.js'; import { limitOptions } from './limit-options.js'; @@ -41,15 +42,11 @@ function getSelectedOptions(values: T[], options: Option[]): Option[] { return results; } -interface AutocompleteSharedOptions extends CommonOptions { +interface AutocompleteBaseOptions extends CommonOptions { /** * The message to display to the user. */ message: string; - /** - * Available options for the autocomplete prompt. - */ - options: Option[] | ((this: AutocompletePrompt>) => Option[]); /** * Maximum number of items to display at once. */ @@ -62,9 +59,6 @@ interface AutocompleteSharedOptions extends CommonOptions { * Validates the value */ validate?: (value: Value | Value[] | undefined) => string | Error | undefined; -} - -export interface AutocompleteOptions extends AutocompleteSharedOptions { /** * The initial selected value. */ @@ -75,24 +69,66 @@ export interface AutocompleteOptions extends AutocompleteSharedOptions extends AutocompleteBaseOptions { + /** + * Available options for the autocomplete prompt. + */ + options: Option[] | ((this: AutocompletePrompt>) => Option[]); + filteredOptions?: never; + debounce?: never; +} + +// Lower-level API: custom filtered options function (sync or async) +interface AutocompleteFilteredOptions extends AutocompleteBaseOptions { + /** + * Function that returns filtered options based on search query. + * Can be sync or async. If async, results will be debounced. + */ + filteredOptions: ( + query: string, + signal?: AbortSignal + ) => Option[] | PromiseLike[]>; + /** + * Debounce time in milliseconds for async searches (default: 300) + */ + debounce?: number; + options?: never; +} + +export type AutocompleteOptions = + | AutocompleteUnfilteredOptions + | AutocompleteFilteredOptions; + export const autocomplete = (opts: AutocompleteOptions) => { const prompt = new AutocompletePrompt({ - options: opts.options, + // Conditionally pass either options+filter or filteredOptions+debounce + ...(opts.filteredOptions + ? { + filteredOptions: opts.filteredOptions, + debounce: opts.debounce, + } + : { + options: opts.options, + filter: (search: string, opt: Option) => { + return getFilteredOption(search, opt); + }, + }), initialValue: opts.initialValue ? [opts.initialValue] : undefined, initialUserInput: opts.initialUserInput, - filter: (search: string, opt: Option) => { - return getFilteredOption(search, opt); - }, signal: opts.signal, input: opts.input, output: opts.output, validate: opts.validate, render() { - // Title and message display - const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`]; + // Title and message display - show spinner when loading + const promptSymbol = this.isLoading + ? color.magenta(S_SPINNER_FRAMES[this.spinnerFrameIndex]) + : symbol(this.state); + const headings = [`${color.gray(S_BAR)}`, `${promptSymbol} ${opts.message}`]; const userInput = this.userInput; const valueAsString = String(this.value ?? ''); - const options = this.options; + const options = this.filteredOptions; const placeholder = opts.placeholder; const showPlaceholder = valueAsString === '' && placeholder !== undefined; @@ -129,9 +165,9 @@ export const autocomplete = (opts: AutocompleteOptions) => { ) : ''; - // No matches message + // No matches message (only if not loading) const noResults = - this.filteredOptions.length === 0 && userInput + this.filteredOptions.length === 0 && userInput && !this.isLoading ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] : []; @@ -196,8 +232,20 @@ export const autocomplete = (opts: AutocompleteOptions) => { return prompt.prompt() as Promise; }; -// Type definition for the autocompleteMultiselect component -export interface AutocompleteMultiSelectOptions extends AutocompleteSharedOptions { +// Base options for multiselect +interface AutocompleteMultiSelectBaseOptions extends CommonOptions { + /** + * The message to display to the user. + */ + message: string; + /** + * Maximum number of items to display at once. + */ + maxItems?: number; + /** + * Placeholder text to display when no input is provided. + */ + placeholder?: string; /** * The initial selected values */ @@ -208,6 +256,39 @@ export interface AutocompleteMultiSelectOptions extends AutocompleteShare required?: boolean; } +// Higher-level API: unfiltered options (automatic filtering) +interface AutocompleteMultiSelectUnfilteredOptions + extends AutocompleteMultiSelectBaseOptions { + /** + * Available options for the autocomplete prompt. + */ + options: Option[] | ((this: AutocompletePrompt>) => Option[]); + filteredOptions?: never; + debounce?: never; +} + +// Lower-level API: custom filtered options function (sync or async) +interface AutocompleteMultiSelectFilteredOptions + extends AutocompleteMultiSelectBaseOptions { + /** + * Function that returns filtered options based on search query. + * Can be sync or async. If async, results will be debounced. + */ + filteredOptions: ( + query: string, + signal?: AbortSignal + ) => Option[] | PromiseLike[]>; + /** + * Debounce time in milliseconds for async searches (default: 300) + */ + debounce?: number; + options?: never; +} + +export type AutocompleteMultiSelectOptions = + | AutocompleteMultiSelectUnfilteredOptions + | AutocompleteMultiSelectFilteredOptions; + /** * Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI */ @@ -234,11 +315,19 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // Create text prompt which we'll use as foundation const prompt = new AutocompletePrompt>({ - options: opts.options, + // Conditionally pass either options+filter or filteredOptions+debounce + ...(opts.filteredOptions + ? { + filteredOptions: opts.filteredOptions, + debounce: opts.debounce, + } + : { + options: opts.options, + filter: (search, opt) => { + return getFilteredOption(search, opt); + }, + }), multiple: true, - filter: (search, opt) => { - return getFilteredOption(search, opt); - }, validate: () => { if (opts.required && prompt.selectedValues.length === 0) { return 'Please select at least one item'; @@ -250,8 +339,11 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti input: opts.input, output: opts.output, render() { - // Title and symbol - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + // Title and symbol - show spinner when loading + const promptSymbol = this.isLoading + ? color.magenta(S_SPINNER_FRAMES[this.spinnerFrameIndex]) + : symbol(this.state); + const title = `${color.gray(S_BAR)}\n${promptSymbol} ${opts.message}\n`; // Selection counter const userInput = this.userInput; @@ -264,14 +356,12 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti ? color.dim(showPlaceholder ? placeholder : userInput) // Just show plain text when in navigation mode : this.userInputWithCursor; - const options = this.options; - - const matches = - this.filteredOptions.length !== options.length - ? color.dim( - ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` - ) - : ''; + // Only show match count when user has typed something + const matches = userInput + ? color.dim( + ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` + ) + : ''; // Render prompt state switch (this.state) { @@ -290,9 +380,9 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti `${color.dim('Type:')} to search`, ]; - // No results message + // No results message (only if not loading) const noResults = - this.filteredOptions.length === 0 && userInput + this.filteredOptions.length === 0 && userInput && !this.isLoading ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] : []; diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index 2489a815..ecdc478a 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -39,6 +39,8 @@ export const S_SUCCESS = unicodeOr('◆', '*'); export const S_WARN = unicodeOr('▲', '!'); export const S_ERROR = unicodeOr('■', 'x'); +export const S_SPINNER_FRAMES = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0']; + export const symbol = (state: State) => { switch (state) { case 'initial': diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 65dd591e..8b59dbd6 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -6,6 +6,7 @@ import { type CommonOptions, isCI as isCIFn, S_BAR, + S_SPINNER_FRAMES, S_STEP_CANCEL, S_STEP_ERROR, S_STEP_SUBMIT, @@ -39,7 +40,7 @@ export const spinner = ({ output = process.stdout, cancelMessage, errorMessage, - frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], + frames = S_SPINNER_FRAMES, delay = unicode ? 80 : 120, signal, ...opts diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 632967dc..50f397eb 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -235,7 +235,7 @@ exports[`autocomplete > shows no matches message when search has no results 1`] "", "", "", - "│ Search: z█ (0 matches) + "│ Search: z█ │ No matches found │ ↑/↓ to select • Enter: confirm • Type: to search └", @@ -311,6 +311,41 @@ exports[`autocomplete > shows strikethrough in cancel state 1`] = ` ] `; +exports[`autocomplete > supports filteredOptions with sync function 1`] = ` +[ + "", + "│ +◆ Select a fruit +│ +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: a█ +│ ● Apple +│ ○ Banana +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ Apple", + " +", + "", +] +`; + exports[`autocomplete > supports initialValue 1`] = ` [ "", @@ -462,3 +497,43 @@ exports[`autocompleteMultiselect > renders error when empty selection & required "", ] `; + +exports[`autocompleteMultiselect > supports filteredOptions with sync function 1`] = ` +[ + "", + "│ +◆ Select fruits + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: a█ (4 matches) +│ ◻ Apple +│ ◻ Banana +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◼ Apple", + "", + "", + "", + "", + "◇ Select fruits +│ 1 items selected", + " +", + "", +] +`; diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index dcd27891..0dd6d46a 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -225,6 +225,61 @@ describe('autocomplete', () => { await result; expect(output.buffer).toMatchSnapshot(); }); + + test('supports filteredOptions with sync function', async () => { + const filterFn = vi.fn((query: string) => { + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const result = autocomplete({ + message: 'Select a fruit', + filteredOptions: filterFn, + input, + output, + }); + + // Type to filter + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', '', { name: 'return' }); + await result; + + expect(filterFn).toHaveBeenCalledWith('', expect.any(AbortSignal)); + expect(filterFn).toHaveBeenCalledWith('a', expect.any(AbortSignal)); + expect(output.buffer).toMatchSnapshot(); + }); + + test('supports filteredOptions with async function', async () => { + vi.useFakeTimers(); + + const searchFn = vi.fn(async (query: string) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const result = autocomplete({ + message: 'Select a fruit', + filteredOptions: searchFn, + debounce: 200, + input, + output, + }); + + await vi.runAllTimersAsync(); + + // Type to filter + input.emit('keypress', 'b', { name: 'b' }); + await vi.advanceTimersByTimeAsync(200); + await vi.runAllTimersAsync(); + + input.emit('keypress', '', { name: 'return' }); + const value = await result; + + expect(searchFn).toHaveBeenCalledWith('', expect.any(AbortSignal)); + expect(searchFn).toHaveBeenCalledWith('b', expect.any(AbortSignal)); + expect(value).toBe('banana'); + + vi.useRealTimers(); + }); }); describe('autocompleteMultiselect', () => { @@ -297,4 +352,63 @@ describe('autocompleteMultiselect', () => { expect(value).toEqual(['banana', 'cherry']); expect(output.buffer).toMatchSnapshot(); }); + + test('supports filteredOptions with sync function', async () => { + const filterFn = vi.fn((query: string) => { + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const result = autocompleteMultiselect({ + message: 'Select fruits', + filteredOptions: filterFn, + input, + output, + }); + + // Type to filter + input.emit('keypress', 'a', { name: 'a' }); + // Select first match + input.emit('keypress', '', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + await result; + + expect(filterFn).toHaveBeenCalledWith('', expect.any(AbortSignal)); + expect(filterFn).toHaveBeenCalledWith('a', expect.any(AbortSignal)); + expect(output.buffer).toMatchSnapshot(); + }); + + test('supports filteredOptions with async function', async () => { + vi.useFakeTimers(); + + const searchFn = vi.fn(async (query: string) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return testOptions.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + }); + + const result = autocompleteMultiselect({ + message: 'Select fruits', + filteredOptions: searchFn, + debounce: 200, + input, + output, + }); + + await vi.runAllTimersAsync(); + + // Type to filter + input.emit('keypress', 'b', { name: 'b' }); + await vi.advanceTimersByTimeAsync(200); + await vi.runAllTimersAsync(); + + // Select first match + input.emit('keypress', '', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + const value = await result; + + expect(searchFn).toHaveBeenCalledWith('', expect.any(AbortSignal)); + expect(searchFn).toHaveBeenCalledWith('b', expect.any(AbortSignal)); + expect(value).toEqual(['banana']); + + vi.useRealTimers(); + }); });