diff --git a/.changeset/nasty-parrots-laugh.md b/.changeset/nasty-parrots-laugh.md new file mode 100644 index 00000000..1e428d80 --- /dev/null +++ b/.changeset/nasty-parrots-laugh.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package. diff --git a/examples/basic/autocomplete-multiselect.ts b/examples/basic/autocomplete-multiselect.ts new file mode 100644 index 00000000..c34420c6 --- /dev/null +++ b/examples/basic/autocomplete-multiselect.ts @@ -0,0 +1,100 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +/** + * Example demonstrating the integrated autocomplete multiselect component + * Which combines filtering and selection in a single interface + */ + +async function main() { + console.clear(); + + p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`); + + p.note( + ` +${color.cyan('Filter and select multiple items in a single interface:')} +- ${color.yellow('Type')} to filter the list in real-time +- Use ${color.yellow('up/down arrows')} to navigate with improved stability +- Press ${color.yellow('Space')} to select/deselect the highlighted item ${color.green('(multiple selections allowed)')} +- Use ${color.yellow('Backspace')} to modify your filter text when searching for different options +- Press ${color.yellow('Enter')} when done selecting all items +- Press ${color.yellow('Ctrl+C')} to cancel + `, + 'Instructions' + ); + + // Frameworks in alphabetical order + const frameworks = [ + { value: 'angular', label: 'Angular', hint: 'Frontend/UI' }, + { value: 'django', label: 'Django', hint: 'Python Backend' }, + { value: 'dotnet', label: '.NET Core', hint: 'C# Backend' }, + { value: 'electron', label: 'Electron', hint: 'Desktop' }, + { value: 'express', label: 'Express', hint: 'Node.js Backend' }, + { value: 'flask', label: 'Flask', hint: 'Python Backend' }, + { value: 'flutter', label: 'Flutter', hint: 'Mobile' }, + { value: 'laravel', label: 'Laravel', hint: 'PHP Backend' }, + { value: 'nestjs', label: 'NestJS', hint: 'Node.js Backend' }, + { value: 'nextjs', label: 'Next.js', hint: 'React Framework' }, + { value: 'nuxt', label: 'Nuxt.js', hint: 'Vue Framework' }, + { value: 'rails', label: 'Ruby on Rails', hint: 'Ruby Backend' }, + { value: 'react', label: 'React', hint: 'Frontend/UI' }, + { value: 'reactnative', label: 'React Native', hint: 'Mobile' }, + { value: 'spring', label: 'Spring Boot', hint: 'Java Backend' }, + { value: 'svelte', label: 'Svelte', hint: 'Frontend/UI' }, + { value: 'tauri', label: 'Tauri', hint: 'Desktop' }, + { value: 'vue', label: 'Vue.js', hint: 'Frontend/UI' }, + ]; + + // Use the new integrated autocompleteMultiselect component + const result = await p.autocompleteMultiselect({ + message: 'Select frameworks (type to filter)', + options: frameworks, + placeholder: 'Type to filter...', + maxItems: 8, + }); + + if (p.isCancel(result)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + // Type guard: if not a cancel symbol, result must be a string array + function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); + } + + // We can now use the type guard to ensure type safety + if (!isStringArray(result)) { + throw new Error('Unexpected result type'); + } + + const selectedFrameworks = result; + + // If no items selected, show a message + if (selectedFrameworks.length === 0) { + p.note('No frameworks were selected', 'Empty Selection'); + process.exit(0); + } + + // Display selected frameworks with detailed information + p.note( + `You selected ${color.green(selectedFrameworks.length)} frameworks:`, + 'Selection Complete' + ); + + // Show each selected framework with its details + const selectedDetails = selectedFrameworks + .map((value) => { + const framework = frameworks.find((f) => f.value === value); + return framework + ? `${color.cyan(framework.label)} ${color.dim(`- ${framework.hint}`)}` + : value; + }) + .join('\n'); + + p.log.message(selectedDetails); + p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`); +} + +main().catch(console.error); diff --git a/examples/basic/autocomplete.ts b/examples/basic/autocomplete.ts new file mode 100644 index 00000000..ea94ece1 --- /dev/null +++ b/examples/basic/autocomplete.ts @@ -0,0 +1,59 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +async function main() { + console.clear(); + + p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`); + + p.note( + ` +${color.cyan('This example demonstrates the type-ahead autocomplete feature:')} +- ${color.yellow('Type')} to filter the list in real-time +- Use ${color.yellow('up/down arrows')} to navigate the filtered results +- Press ${color.yellow('Enter')} to select the highlighted option +- Press ${color.yellow('Ctrl+C')} to cancel + `, + 'Instructions' + ); + + const countries = [ + { value: 'us', label: 'United States', hint: 'NA' }, + { value: 'ca', label: 'Canada', hint: 'NA' }, + { value: 'mx', label: 'Mexico', hint: 'NA' }, + { value: 'br', label: 'Brazil', hint: 'SA' }, + { value: 'ar', label: 'Argentina', hint: 'SA' }, + { value: 'uk', label: 'United Kingdom', hint: 'EU' }, + { value: 'fr', label: 'France', hint: 'EU' }, + { value: 'de', label: 'Germany', hint: 'EU' }, + { value: 'it', label: 'Italy', hint: 'EU' }, + { value: 'es', label: 'Spain', hint: 'EU' }, + { value: 'pt', label: 'Portugal', hint: 'EU' }, + { value: 'ru', label: 'Russia', hint: 'EU/AS' }, + { value: 'cn', label: 'China', hint: 'AS' }, + { value: 'jp', label: 'Japan', hint: 'AS' }, + { value: 'in', label: 'India', hint: 'AS' }, + { value: 'kr', label: 'South Korea', hint: 'AS' }, + { value: 'au', label: 'Australia', hint: 'OC' }, + { value: 'nz', label: 'New Zealand', hint: 'OC' }, + { value: 'za', label: 'South Africa', hint: 'AF' }, + { value: 'eg', label: 'Egypt', hint: 'AF' }, + ]; + + const result = await p.autocomplete({ + message: 'Select a country', + options: countries, + placeholder: 'Type to search countries...', + maxItems: 8, + }); + + if (p.isCancel(result)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + const selected = countries.find((c) => c.value === result); + p.outro(`You selected: ${color.cyan(selected?.label)} (${color.yellow(selected?.hint)})`); +} + +main().catch(console.error); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d4afc66..ca4103f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,5 +9,6 @@ export { default as Prompt } from './prompts/prompt.js'; export { default as SelectPrompt } from './prompts/select.js'; export { default as SelectKeyPrompt } from './prompts/select-key.js'; export { default as TextPrompt } from './prompts/text.js'; +export { default as AutocompletePrompt } from './prompts/autocomplete.js'; export { block, isCancel, getColumns } from './utils/index.js'; export { updateSettings, settings } from './utils/settings.js'; diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts new file mode 100644 index 00000000..77346728 --- /dev/null +++ b/packages/core/src/prompts/autocomplete.ts @@ -0,0 +1,193 @@ +import type { Key } from 'node:readline'; +import color from 'picocolors'; +import Prompt, { type PromptOptions } from './prompt.js'; + +interface OptionLike { + value: unknown; + label?: string; +} + +type FilterFunction = (search: string, opt: T) => boolean; + +function getCursorForValue( + selected: T['value'] | undefined, + items: T[] +): number { + if (selected === undefined) { + return 0; + } + + const currLength = items.length; + + // If filtering changed the available options, update cursor + if (currLength === 0) { + return 0; + } + + // Try to maintain the same selected item + const index = items.findIndex((item) => item.value === selected); + return index !== -1 ? index : 0; +} + +function defaultFilter(input: string, option: T): boolean { + const label = option.label ?? String(option.value); + return label.toLowerCase().includes(input.toLowerCase()); +} + +function normalisedValue(multiple: boolean, values: T[] | undefined): T | T[] | undefined { + if (!values) { + return undefined; + } + if (multiple) { + return values; + } + return values[0]; +} + +export interface AutocompleteOptions + extends PromptOptions> { + options: T[]; + filter?: FilterFunction; + multiple?: boolean; +} + +export default class AutocompletePrompt extends Prompt { + options: T[]; + filteredOptions: T[]; + multiple: boolean; + isNavigating = false; + selectedValues: Array = []; + + focusedValue: T['value'] | undefined; + #cursor = 0; + #lastValue: T['value'] | undefined; + #filterFn: FilterFunction; + + get cursor(): number { + return this.#cursor; + } + + get valueWithCursor() { + if (!this.value) { + return color.inverse(color.hidden('_')); + } + if (this._cursor >= this.value.length) { + return `${this.value}█`; + } + const s1 = this.value.slice(0, this._cursor); + const [s2, ...s3] = this.value.slice(this._cursor); + return `${s1}${color.inverse(s2)}${s3.join('')}`; + } + + constructor(opts: AutocompleteOptions) { + super(opts); + + this.options = opts.options; + this.filteredOptions = [...this.options]; + this.multiple = opts.multiple === true; + this._usePlaceholderAsValue = false; + 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); + } + } + + if (initialValues) { + this.selectedValues = initialValues; + for (const selectedValue of initialValues) { + const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue); + if (selectedIndex !== -1) { + this.toggleSelected(selectedValue); + this.#cursor = selectedIndex; + this.focusedValue = this.options[this.#cursor]?.value; + } + } + } + + this.on('finalize', () => { + if (!this.value) { + this.value = normalisedValue(this.multiple, initialValues); + } + + if (this.state === 'submit') { + this.value = normalisedValue(this.multiple, this.selectedValues); + } + }); + + this.on('key', (char, key) => this.#onKey(char, key)); + this.on('value', (value) => this.#onValueChanged(value)); + } + + protected override _isActionKey(char: string | undefined, key: Key): boolean { + return ( + char === '\t' || + (this.multiple && + this.isNavigating && + key.name === 'space' && + char !== undefined && + char !== '') + ); + } + + #onKey(_char: string | undefined, key: Key): void { + const isUpKey = key.name === 'up'; + const isDownKey = key.name === 'down'; + + // Start navigation mode with up/down arrows + if (isUpKey || isDownKey) { + this.#cursor = Math.max( + 0, + Math.min(this.#cursor + (isUpKey ? -1 : 1), this.filteredOptions.length - 1) + ); + this.focusedValue = this.filteredOptions[this.#cursor]?.value; + if (!this.multiple) { + this.selectedValues = [this.focusedValue]; + } + this.isNavigating = true; + } else { + if ( + this.multiple && + this.focusedValue !== undefined && + (key.name === 'tab' || (this.isNavigating && key.name === 'space')) + ) { + this.toggleSelected(this.focusedValue); + } else { + this.isNavigating = false; + } + } + } + + toggleSelected(value: T['value']) { + if (this.filteredOptions.length === 0) { + return; + } + + if (this.multiple) { + if (this.selectedValues.includes(value)) { + this.selectedValues = this.selectedValues.filter((v) => v !== value); + } else { + this.selectedValues = [...this.selectedValues, value]; + } + } else { + this.selectedValues = [value]; + } + } + + #onValueChanged(value: string | undefined): void { + if (value !== this.#lastValue) { + this.#lastValue = value; + + if (value) { + this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt)); + } else { + this.filteredOptions = [...this.options]; + } + this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions); + this.focusedValue = this.filteredOptions[this.#cursor]?.value; + } + } +} diff --git a/packages/core/src/prompts/password.ts b/packages/core/src/prompts/password.ts index f4076e5c..5d2afc38 100644 --- a/packages/core/src/prompts/password.ts +++ b/packages/core/src/prompts/password.ts @@ -5,29 +5,27 @@ interface PasswordOptions extends PromptOptions { mask?: string; } export default class PasswordPrompt extends Prompt { - valueWithCursor = ''; private _mask = '•'; get cursor() { return this._cursor; } get masked() { - return this.value.replaceAll(/./g, this._mask); + return this.value?.replaceAll(/./g, this._mask) ?? ''; + } + get valueWithCursor() { + if (this.state === 'submit' || this.state === 'cancel') { + return this.masked; + } + const value = this.value ?? ''; + if (this.cursor >= value.length) { + return `${this.masked}${color.inverse(color.hidden('_'))}`; + } + const s1 = this.masked.slice(0, this.cursor); + const s2 = this.masked.slice(this.cursor); + return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; } constructor({ mask, ...opts }: PasswordOptions) { super(opts); this._mask = mask ?? '•'; - - this.on('finalize', () => { - this.valueWithCursor = this.masked; - }); - this.on('value', () => { - if (this.cursor >= this.value.length) { - this.valueWithCursor = `${this.masked}${color.inverse(color.hidden('_'))}`; - } else { - const s1 = this.masked.slice(0, this.cursor); - const s2 = this.masked.slice(this.cursor); - this.valueWithCursor = `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; - } - }); } } diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 089e1dc8..7e58b866 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -1,7 +1,7 @@ import { stdin, stdout } from 'node:process'; import readline, { type Key, type ReadLine } from 'node:readline'; import type { Readable } from 'node:stream'; -import { Writable } from 'node:stream'; +import type { Writable } from 'node:stream'; import { cursor, erase } from 'sisteransi'; import wrap from 'wrap-ansi'; @@ -33,6 +33,7 @@ export default class Prompt { private _prevFrame = ''; private _subscribers = new Map any; once?: boolean }[]>(); protected _cursor = 0; + protected _usePlaceholderAsValue = true; public state: ClackState = 'initial'; public error = ''; @@ -133,29 +134,19 @@ export default class Prompt { ); } - const sink = new Writable(); - sink._write = (chunk, encoding, done) => { - if (this._track) { - this.value = this.rl?.line.replace(/\t/g, ''); - this._cursor = this.rl?.cursor ?? 0; - this.emit('value', this.value); - } - done(); - }; - this.input.pipe(sink); - this.rl = readline.createInterface({ input: this.input, - output: sink, tabSize: 2, prompt: '', escapeCodeTimeout: 50, terminal: true, }); - readline.emitKeypressEvents(this.input, this.rl); this.rl.prompt(); - if (this.opts.initialValue !== undefined && this._track) { - this.rl.write(this.opts.initialValue); + if (this.opts.initialValue !== undefined) { + if (this._track) { + this.rl.write(this.opts.initialValue); + } + this._setValue(this.opts.initialValue); } this.input.on('keypress', this.onKeypress); @@ -179,7 +170,24 @@ export default class Prompt { }); } - private onKeypress(char: string, key?: Key) { + protected _isActionKey(char: string | undefined, _key: Key): boolean { + return char === '\t'; + } + + protected _setValue(value: unknown): void { + this.value = value; + this.emit('value', this.value); + } + + private onKeypress(char: string | undefined, key: Key) { + if (this._track && key.name !== 'return') { + if (key.name && this._isActionKey(char, key)) { + this.rl?.write(null, { ctrl: true, name: 'h' }); + } + this._cursor = this.rl?.cursor ?? 0; + this._setValue(this.rl?.line); + } + if (this.state === 'error') { this.state = 'active'; } @@ -194,20 +202,20 @@ export default class Prompt { if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) { this.emit('confirm', char.toLowerCase() === 'y'); } - if (char === '\t' && this.opts.placeholder) { + if (this._usePlaceholderAsValue && char === '\t' && this.opts.placeholder) { if (!this.value) { this.rl?.write(this.opts.placeholder); - this.emit('value', this.opts.placeholder); + this._setValue(this.opts.placeholder); } } - if (char) { - this.emit('key', char.toLowerCase()); - } + + // Call the key event handler and emit the key event + this.emit('key', char?.toLowerCase(), key); if (key?.name === 'return') { if (!this.value && this.opts.placeholder) { this.rl?.write(this.opts.placeholder); - this.emit('value', this.opts.placeholder); + this._setValue(this.opts.placeholder); } if (this.opts.validate) { @@ -226,6 +234,7 @@ export default class Prompt { if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { this.state = 'cancel'; } + if (this.state === 'submit' || this.state === 'cancel') { this.emit('finalize'); } diff --git a/packages/core/src/prompts/text.ts b/packages/core/src/prompts/text.ts index e45c0408..f8bdf415 100644 --- a/packages/core/src/prompts/text.ts +++ b/packages/core/src/prompts/text.ts @@ -11,11 +11,12 @@ export default class TextPrompt extends Prompt { if (this.state === 'submit') { return this.value; } - if (this.cursor >= this.value.length) { + const value = this.value ?? ''; + if (this.cursor >= value.length) { return `${this.value}█`; } - const s1 = this.value.slice(0, this.cursor); - const [s2, ...s3] = this.value.slice(this.cursor); + const s1 = value.slice(0, this.cursor); + const [s2, ...s3] = value.slice(this.cursor); return `${s1}${color.inverse(s2)}${s3.join('')}`; } get cursor() { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 12eaf917..3250177c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,3 +1,4 @@ +import type { Key } from 'node:readline'; import type { Action } from './utils/settings.js'; /** @@ -15,7 +16,7 @@ export interface ClackEvents { submit: (value?: any) => void; error: (value?: any) => void; cursor: (key?: Action) => void; - key: (key?: string) => void; + key: (key: string | undefined, info: Key) => void; value: (value?: string) => void; confirm: (value?: boolean) => void; finalize: () => void; diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts new file mode 100644 index 00000000..ce5bb836 --- /dev/null +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -0,0 +1,139 @@ +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as AutocompletePrompt } from '../../src/prompts/autocomplete.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe('AutocompletePrompt', () => { + let input: MockReadable; + let output: MockWritable; + const testOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'grape', label: 'Grape' }, + { value: 'orange', label: 'Orange' }, + ]; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders render() result', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + test('initial options match provided options', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + instance.prompt(); + + // Initial state should have all options + expect(instance.filteredOptions.length).to.equal(testOptions.length); + expect(instance.cursor).to.equal(0); + }); + + test('cursor navigation with event emitter', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + instance.prompt(); + + // Initial cursor should be at 0 + expect(instance.cursor).to.equal(0); + + // Directly trigger the cursor event with 'down' + instance.emit('key', '', { name: 'down' }); + + // After down event, cursor should be 1 + expect(instance.cursor).to.equal(1); + + // Trigger cursor event with 'up' + instance.emit('key', '', { name: 'up' }); + + // After up event, cursor should be back to 0 + expect(instance.cursor).to.equal(0); + }); + + test('initialValue selects correct option', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + initialValue: ['cherry'], + }); + + // The cursor should be initialized to the cherry index + const cherryIndex = testOptions.findIndex((opt) => opt.value === 'cherry'); + expect(instance.cursor).to.equal(cherryIndex); + + // The selectedValue should be cherry + expect(instance.selectedValues).to.deep.equal(['cherry']); + }); + + test('filtering through value event', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + instance.prompt(); + + // Initial state should have all options + expect(instance.filteredOptions.length).to.equal(testOptions.length); + + // Simulate typing 'a' by emitting value event + instance.emit('value', 'a'); + + // Check that filtered options are updated to include options with 'a' + expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length); + + // Check that 'apple' is in the filtered options + const hasApple = instance.filteredOptions.some((opt) => opt.value === 'apple'); + expect(hasApple).to.equal(true); + }); + + test('default filter function works correctly', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + instance.emit('value', 'ap'); + + expect(instance.filteredOptions).toEqual([ + { value: 'apple', label: 'Apple' }, + { value: 'grape', label: 'Grape' }, + ]); + + instance.emit('value', 'z'); + + expect(instance.filteredOptions).toEqual([]); + }); +}); diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 41ca2428..5c2a0488 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -38,7 +38,7 @@ describe('Prompt', () => { const resultPromise = instance.prompt(); input.emit('keypress', '', { name: 'return' }); const result = await resultPromise; - expect(result).to.equal(''); + expect(result).to.equal(undefined); expect(isCancel(result)).to.equal(false); expect(instance.state).to.equal('submit'); expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); @@ -181,7 +181,7 @@ describe('Prompt', () => { input.emit('keypress', 'z', { name: 'z' }); - expect(eventSpy).toBeCalledWith('z'); + expect(eventSpy).toBeCalledWith('z', { name: 'z' }); }); test('emits cursor events for movement keys', () => { diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts new file mode 100644 index 00000000..07fd5702 --- /dev/null +++ b/packages/prompts/src/autocomplete.ts @@ -0,0 +1,301 @@ +import { AutocompletePrompt } from '@clack/core'; +import color from 'picocolors'; +import { + type CommonOptions, + S_BAR, + S_BAR_END, + S_CHECKBOX_INACTIVE, + S_CHECKBOX_SELECTED, + S_RADIO_ACTIVE, + S_RADIO_INACTIVE, + symbol, +} from './common.js'; +import { limitOptions } from './limit-options.js'; +import type { Option } from './select.js'; + +function getLabel(option: Option) { + return option.label ?? String(option.value ?? ''); +} + +function getFilteredOption(searchText: string, option: Option): boolean { + if (!searchText) { + return true; + } + const label = (option.label ?? String(option.value ?? '')).toLowerCase(); + const hint = (option.hint ?? '').toLowerCase(); + const value = String(option.value).toLowerCase(); + const term = searchText.toLowerCase(); + + return label.includes(term) || hint.includes(term) || value.includes(term); +} + +function getSelectedOptions(values: T[], options: Option[]): Option[] { + const results: Option[] = []; + + for (const option of options) { + if (values.includes(option.value)) { + results.push(option); + } + } + + return results; +} + +export interface AutocompleteOptions extends CommonOptions { + /** + * The message to display to the user. + */ + message: string; + /** + * Available options for the autocomplete prompt. + */ + options: Option[]; + /** + * The initial selected value. + */ + initialValue?: Value; + /** + * Maximum number of items to display at once. + */ + maxItems?: number; + /** + * Placeholder text to display when no input is provided. + */ + placeholder?: string; +} + +export const autocomplete = (opts: AutocompleteOptions) => { + const prompt = new AutocompletePrompt({ + options: opts.options, + placeholder: opts.placeholder, + initialValue: opts.initialValue ? [opts.initialValue] : undefined, + filter: (search: string, opt: Option) => { + return getFilteredOption(search, opt); + }, + input: opts.input, + output: opts.output, + render() { + // Title and message display + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const valueAsString = String(this.value ?? ''); + + // Handle different states + switch (this.state) { + case 'submit': { + // Show selected value + const selected = getSelectedOptions(this.selectedValues, this.options); + const label = selected.length > 0 ? selected.map(getLabel).join(', ') : ''; + return `${title}${color.gray(S_BAR)} ${color.dim(label)}`; + } + + case 'cancel': { + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.value ?? ''))}`; + } + + default: { + // Display cursor position - show plain text in navigation mode + const searchText = this.isNavigating ? color.dim(valueAsString) : this.valueWithCursor; + + // Show match count if filtered + const matches = + this.filteredOptions.length !== this.options.length + ? color.dim( + ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` + ) + : ''; + + // Render options with selection + const displayOptions = + this.filteredOptions.length === 0 + ? [] + : limitOptions({ + cursor: this.cursor, + options: this.filteredOptions, + style: (option, active) => { + const label = getLabel(option); + const hint = + option.hint && option.value === this.focusedValue + ? color.dim(` (${option.hint})`) + : ''; + + return active + ? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}` + : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`; + }, + maxItems: opts.maxItems, + output: opts.output, + }); + + // Show instructions + const instructions = [ + `${color.dim('↑/↓')} to select`, + `${color.dim('Enter:')} confirm`, + `${color.dim('Type:')} to search`, + ]; + + // No matches message + const noResults = + this.filteredOptions.length === 0 && valueAsString + ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] + : []; + + // Return the formatted prompt + return [ + title, + `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, + ...noResults, + ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), + `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, + `${color.cyan(S_BAR_END)}`, + ].join('\n'); + } + } + }, + }); + + // Return the result or cancel symbol + return prompt.prompt() as Promise; +}; + +// Type definition for the autocompleteMultiselect component +export interface AutocompleteMultiSelectOptions { + /** + * The message to display to the user + */ + message: string; + /** + * The options for the user to choose from + */ + options: Option[]; + /** + * The initial selected values + */ + initialValues?: Value[]; + /** + * The maximum number of items that can be selected + */ + maxItems?: number; + /** + * The placeholder to display in the input + */ + placeholder?: string; + /** + * The stream to read from + */ + input?: NodeJS.ReadStream; + /** + * The stream to write to + */ + output?: NodeJS.WriteStream; +} + +/** + * Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI + */ +export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOptions) => { + const formatOption = ( + option: Option, + active: boolean, + selectedValues: Value[], + focusedValue: Value | undefined + ) => { + const isSelected = selectedValues.includes(option.value); + const label = option.label ?? String(option.value ?? ''); + const hint = + option.hint && focusedValue !== undefined && option.value === focusedValue + ? color.dim(` (${option.hint})`) + : ''; + const checkbox = isSelected + ? color.green(S_CHECKBOX_SELECTED) + : color.dim(S_CHECKBOX_INACTIVE); + + if (active) { + return `${checkbox} ${label}${hint}`; + } + return `${checkbox} ${color.dim(label)}`; + }; + + // Create text prompt which we'll use as foundation + const prompt = new AutocompletePrompt>({ + options: opts.options, + multiple: true, + filter: (search, opt) => { + return getFilteredOption(search, opt); + }, + placeholder: opts.placeholder, + initialValue: opts.initialValues, + input: opts.input, + output: opts.output, + render() { + // Title and symbol + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + + // Selection counter + const counter = + this.selectedValues.length > 0 + ? color.cyan(` (${this.selectedValues.length} selected)`) + : ''; + const value = String(this.value ?? ''); + + // Search input display + const searchText = this.isNavigating + ? color.dim(value) // Just show plain text when in navigation mode + : this.valueWithCursor; + + const matches = + this.filteredOptions.length !== opts.options.length + ? color.dim( + ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` + ) + : ''; + + // Render prompt state + switch (this.state) { + case 'submit': { + return `${title}${color.gray(S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`; + } + case 'cancel': { + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(value))}`; + } + default: { + // Instructions + const instructions = [ + `${color.dim('↑/↓')} to navigate`, + `${color.dim('Space:')} select`, + `${color.dim('Enter:')} confirm`, + `${color.dim('Type:')} to search`, + ]; + + // No results message + const noResults = + this.filteredOptions.length === 0 && value + ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] + : []; + + // Get limited options for display + const displayOptions = limitOptions({ + cursor: this.cursor, + options: this.filteredOptions, + style: (option, active) => + formatOption(option, active, this.selectedValues, this.focusedValue), + maxItems: opts.maxItems, + output: opts.output, + }); + + // Build the prompt display + return [ + title, + `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, + ...noResults, + ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), + `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, + `${color.cyan(S_BAR_END)}`, + ].join('\n'); + } + } + }, + }); + + // Return the result or cancel symbol + return prompt.prompt() as Promise; +}; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 6adc59f9..c77046c7 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -1,5 +1,6 @@ export { isCancel, updateSettings, settings, type ClackSettings } from '@clack/core'; +export * from './autocomplete.js'; export * from './common.js'; export * from './confirm.js'; export * from './group-multi-select.js'; diff --git a/packages/prompts/src/limit-options.ts b/packages/prompts/src/limit-options.ts index 8f3969fa..f1c317a1 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -14,6 +14,7 @@ export const limitOptions = (params: LimitOptionsParams): stri const { cursor, options, style } = params; const output: Writable = params.output ?? process.stdout; const rows = output instanceof WriteStream && output.rows !== undefined ? output.rows : 10; + const overflowFormat = color.dim('...'); const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY; const outputMaxItems = Math.max(rows - 4, 0); @@ -37,7 +38,7 @@ export const limitOptions = (params: LimitOptionsParams): stri const isTopLimit = i === 0 && shouldRenderTopEllipsis; const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; return isTopLimit || isBottomLimit - ? color.dim('...') + ? overflowFormat : style(option, i + slidingWindowLocation === cursor); }); }; diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap new file mode 100644 index 00000000..7d33b655 --- /dev/null +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -0,0 +1,185 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` +[ + "", + "│ +◆ Select an option + +│ Search: _ +│ ● Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ○ Option 3 +│ ○ Option 4 +│ ... +│ ↑/↓ to select • Enter: confirm • Type: to search +└", +] +`; + +exports[`autocomplete > renders initial UI with message and instructions 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", +] +`; + +exports[`autocomplete > shows hint when option has hint and is focused 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: +│ ○ Apple +│ ● Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ○ Banana +│ ● Cherry +│ ○ Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ○ Cherry +│ ● Grape +│ ○ Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ○ Grape +│ ● Orange +│ ○ Kiwi +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ○ Orange +│ ● Kiwi (New Zealand) +│ ↑/↓ to select • Enter: confirm • Type: to search +└", +] +`; + +exports[`autocomplete > shows no matches message when search has no results 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: z█ (0 matches) +│ No matches found +│ ↑/↓ to select • Enter: confirm • Type: to search +└", +] +`; + +exports[`autocomplete > shows selected value in submit state 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: +│ ○ Apple +│ ● Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ Banana", + " +", + "", +] +`; + +exports[`autocomplete > shows strikethrough in cancel state 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "■ Select a fruit +│", + " +", + "", +] +`; diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts new file mode 100644 index 00000000..01ff7c73 --- /dev/null +++ b/packages/prompts/test/autocomplete.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { autocomplete } from '../src/autocomplete.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +describe('autocomplete', () => { + let input: MockReadable; + let output: MockWritable; + const testOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'grape', label: 'Grape' }, + { value: 'orange', label: 'Orange' }, + ]; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders initial UI with message and instructions', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + expect(output.buffer).toMatchSnapshot(); + input.emit('keypress', '', { name: 'return' }); + await result; + }); + + test('limits displayed options when maxItems is set', async () => { + const options = []; + for (let i = 0; i < 10; i++) { + options.push({ value: `option ${i}`, label: `Option ${i}` }); + } + + const result = autocomplete({ + message: 'Select an option', + options, + maxItems: 6, + input, + output, + }); + + expect(output.buffer).toMatchSnapshot(); + input.emit('keypress', '', { name: 'return' }); + await result; + }); + + test('shows no matches message when search has no results', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + // Type something that won't match + input.emit('keypress', 'z', { name: 'z' }); + expect(output.buffer).toMatchSnapshot(); + input.emit('keypress', '', { name: 'return' }); + await result; + }); + + test('shows hint when option has hint and is focused', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: [...testOptions, { value: 'kiwi', label: 'Kiwi', hint: 'New Zealand' }], + input, + output, + }); + + // Navigate to the option with hint + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + expect(output.buffer).toMatchSnapshot(); + input.emit('keypress', '', { name: 'return' }); + await result; + }); + + test('shows selected value in submit state', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + // Select an option and submit + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + expect(value).toBe('banana'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('shows strikethrough in cancel state', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + // Cancel with Ctrl+C + input.emit('keypress', '\x03', { name: 'c' }); + + const value = await result; + expect(typeof value === 'symbol').toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); +});