diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 459ad93c..031d5733 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -1,124 +1,193 @@ +import type { Key } from 'node:readline'; +import color from 'picocolors'; import Prompt, { type PromptOptions } from './prompt.js'; -export interface AutocompleteOptions +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[]; - initialValue?: T['value']; - maxItems?: number; - filterFn?: (input: string, option: T) => boolean; + filter?: FilterFunction; + multiple?: boolean; } -export default class AutocompletePrompt extends Prompt { +export default class AutocompletePrompt extends Prompt { options: T[]; filteredOptions: T[]; - cursor = 0; - maxItems: number; - filterFn: (input: string, option: T) => boolean; - isNavigationMode = false; // Track if we're in navigation mode - ignoreNextSpace = false; // Track if we should ignore the next space - - private filterOptions() { - const input = this.value?.toLowerCase() ?? ''; - // Remember the currently selected value before filtering - const previousSelectedValue = this.filteredOptions[this.cursor]?.value; - - // Filter options based on the current input - this.filteredOptions = input - ? this.options.filter((option) => this.filterFn(input, option)) - : this.options; - - // Reset cursor to 0 by default when filtering changes - this.cursor = 0; - - // Try to maintain the previously selected item if it still exists in filtered results - if (previousSelectedValue !== undefined && this.filteredOptions.length > 0) { - const newIndex = this.filteredOptions.findIndex((opt) => opt.value === previousSelectedValue); - if (newIndex !== -1) { - // Found the same item in new filtered results, keep it selected - this.cursor = newIndex; - } - } + multiple: boolean; + isNavigating = false; + selectedValues: Array = []; + + #focusedValue: T['value'] | undefined; + #cursor = 0; + #lastValue: T['value'] | undefined; + #filterFn: FilterFunction; + + get cursor(): number { + return this.#cursor; } - // Store both the search input and the selected value - public get selectedValue(): T['value'] | undefined { - return this.filteredOptions[this.cursor]?.value; + 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, true); + super(opts); this.options = opts.options; this.filteredOptions = [...this.options]; - this.maxItems = opts.maxItems ?? 10; - this.filterFn = opts.filterFn ?? this.defaultFilterFn; - - // Set initial value if provided - if (opts.initialValue !== undefined) { - const initialIndex = this.options.findIndex(({ value }) => value === opts.initialValue); - if (initialIndex !== -1) { - this.cursor = initialIndex; + 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); } } - // Handle keyboard key presses - this.on('key', (key) => { - // Enter navigation mode with arrow keys - if (key === 'up' || key === 'down') { - this.isNavigationMode = true; + 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; + } } + } - // Space key in navigation mode should be ignored for input - if (key === ' ' && this.isNavigationMode) { - this.ignoreNextSpace = true; - return false; // Prevent propagation + this.on('finalize', () => { + if (!this.value) { + this.value = normalisedValue(this.multiple, initialValues); } - // Exit navigation mode with non-navigation keys - if (key !== 'up' && key !== 'down' && key !== 'return') { - this.isNavigationMode = false; + if (this.state === 'submit') { + this.value = normalisedValue(this.multiple, this.selectedValues); } }); - // Handle cursor movement - this.on('cursor', (key) => { - switch (key) { - case 'up': - this.isNavigationMode = true; - this.cursor = this.cursor === 0 ? this.filteredOptions.length - 1 : this.cursor - 1; - break; - case 'down': - this.isNavigationMode = true; - this.cursor = this.cursor === this.filteredOptions.length - 1 ? 0 : this.cursor + 1; - break; - } - }); + this.on('key', (char, key) => this.#onKey(char, key)); + this.on('value', (value) => this.#onValueChanged(value)); + } - // Update filtered options when input changes - this.on('value', (value) => { - // Check if we need to ignore a space - if (this.ignoreNextSpace && value?.endsWith(' ')) { - // Remove the space and reset the flag - this.value = value.replace(/\s+$/, ''); - this.ignoreNextSpace = false; - return; - } + protected override _isActionKey(char: string | undefined, key: Key): boolean { + return ( + char === '\t' || + (this.multiple && + this.isNavigating && + key.name === 'space' && + char !== undefined && + char !== '') + ); + } - // In navigation mode, strip out any spaces - if (this.isNavigationMode && value?.includes(' ')) { - this.value = value.replace(/\s+/g, ''); - return; + #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; + } + } + } - // Normal filtering when not in navigation mode - this.value = value; - this.filterOptions(); - }); + 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]; + } } - // Default filtering function - private defaultFilterFn(input: string, option: T): boolean { - const label = option.label ?? String(option.value); - return label.toLowerCase().includes(input.toLowerCase()); + #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 098465bf..bd85f3ef 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,16 +170,22 @@ export default class Prompt { }); } - private onKeypress(char: string, key?: Key) { - // First check for ESC key - // Only relevant for ESC in navigation mode scenarios - let keyHandled = false; - if (char === '\x1b' || key?.name === 'escape') { - // We won't do any special handling for ESC in navigation mode for now - // Just let it propagate to the cancel handler below - keyHandled = false; - // Reset any existing flag - (this as any)._keyHandled = false; + 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') { @@ -205,28 +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); } } // Call the key event handler and emit the key event - if (char) { - this.emit('key', char.toLowerCase()); - // Check if the key handler set the prevented flag - if ((this as any)._keyHandled) { - keyHandled = true; - // Reset the flag - (this as any)._keyHandled = false; - } - } + 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) { @@ -243,7 +232,7 @@ export default class Prompt { } // Only process as cancel if the key wasn't already handled - if (!keyHandled && isActionKey([char, key?.name, key?.sequence], 'cancel')) { + if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { this.state = 'cancel'; } 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 index 34383858..ce5bb836 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -1,8 +1,6 @@ -import color from 'picocolors'; import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { default as AutocompletePrompt } from '../../src/prompts/autocomplete.js'; -import Prompt from '../../src/prompts/prompt.js'; import { MockReadable } from '../mock-readable.js'; import { MockWritable } from '../mock-writable.js'; @@ -66,13 +64,13 @@ describe('AutocompletePrompt', () => { expect(instance.cursor).to.equal(0); // Directly trigger the cursor event with 'down' - instance.emit('cursor', '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('cursor', 'up'); + instance.emit('key', '', { name: 'up' }); // After up event, cursor should be back to 0 expect(instance.cursor).to.equal(0); @@ -84,7 +82,7 @@ describe('AutocompletePrompt', () => { output, render: () => 'foo', options: testOptions, - initialValue: 'cherry', + initialValue: ['cherry'], }); // The cursor should be initialized to the cherry index @@ -92,34 +90,7 @@ describe('AutocompletePrompt', () => { expect(instance.cursor).to.equal(cherryIndex); // The selectedValue should be cherry - expect(instance.selectedValue).to.equal('cherry'); - }); - - test('maxItems limits the number of options displayed', () => { - // Create more test options - const manyOptions = [ - ...testOptions, - { value: 'kiwi', label: 'Kiwi' }, - { value: 'lemon', label: 'Lemon' }, - { value: 'mango', label: 'Mango' }, - { value: 'peach', label: 'Peach' }, - ]; - - const instance = new AutocompletePrompt({ - input, - output, - render: () => 'foo', - options: manyOptions, - maxItems: 3, - }); - - instance.prompt(); - - // There should still be all options in the filteredOptions array - expect(instance.filteredOptions.length).to.equal(manyOptions.length); - - // The maxItems property should be set correctly - expect(instance.maxItems).to.equal(3); + expect(instance.selectedValues).to.deep.equal(['cherry']); }); test('filtering through value event', () => { @@ -154,21 +125,15 @@ describe('AutocompletePrompt', () => { options: testOptions, }); - // Create a test function that uses the private method - const testFilter = (input: string, option: any) => { - // @ts-ignore - Access private method for testing - return instance.defaultFilterFn(input, option); - }; + instance.emit('value', 'ap'); - // Call the test filter with an input - const sampleOption = testOptions[0]; // 'apple' - const result = testFilter('ap', sampleOption); + expect(instance.filteredOptions).toEqual([ + { value: 'apple', label: 'Apple' }, + { value: 'grape', label: 'Grape' }, + ]); - // The filter should match 'apple' with 'ap' - expect(result).to.equal(true); + instance.emit('value', 'z'); - // Should not match with a non-existing substring - const noMatch = testFilter('z', sampleOption); - expect(noMatch).to.equal(false); + 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 index cfbe6968..c95c9c2c 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -1,4 +1,4 @@ -import { TextPrompt } from '@clack/core'; +import { AutocompletePrompt } from '@clack/core'; import color from 'picocolors'; import { type CommonOptions, @@ -17,6 +17,30 @@ 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. @@ -41,172 +65,52 @@ export interface AutocompleteOptions extends CommonOptions { } export const autocomplete = (opts: AutocompleteOptions) => { - // Track input, cursor position, and filtering - similar to multiselect - let input = ''; - let cursor = 0; - let filtered = [...opts.options]; - let selectedValue = opts.initialValue; - let isEventsRegistered = false; - let isNavigating = false; - - // Filter options based on search input - const filterOptions = (searchText: string) => { - const prevLength = filtered.length; - const prevSelected = filtered[cursor]?.value; - - if (searchText) { - filtered = opts.options.filter((option) => { - 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); - }); - } else { - filtered = [...opts.options]; - } - - // If filtering changed the available options, update cursor - if (prevLength !== filtered.length || !filtered.length) { - if (filtered.length === 0) { - cursor = 0; - } else if (prevSelected !== undefined) { - // Try to maintain the same selected item - const index = filtered.findIndex((o) => o.value === prevSelected); - cursor = index !== -1 ? index : 0; - } else { - cursor = 0; - } - } - - // Ensure cursor is within bounds - if (cursor >= filtered.length && filtered.length > 0) { - cursor = filtered.length - 1; - } - - // Update selected value based on cursor - if (filtered.length > 0) { - selectedValue = filtered[cursor].value; - } - }; - - // Create text prompt - const prompt = new TextPrompt({ + const prompt = new AutocompletePrompt({ + options: opts.options, placeholder: opts.placeholder, - initialValue: '', + initialValue: opts.initialValue ? [opts.initialValue] : undefined, + filter: (search, opt) => { + return getFilteredOption(search, opt); + }, input: opts.input, output: opts.output, render() { - // Register event handlers only once - if (!isEventsRegistered) { - // Handle keyboard navigation - this.on('key', (key) => { - // Start navigation mode with up/down arrows - if (key === 'up' || key === 'down') { - isNavigating = true; - } - - // Allow typing again when user presses any other key - if (key !== 'up' && key !== 'down' && key !== 'return') { - isNavigating = false; - } - }); - - // Handle cursor movement - this.on('cursor', (key) => { - if (filtered.length === 0) return; - - // Enter navigation mode - isNavigating = true; - - if (key === 'up') { - cursor = cursor > 0 ? cursor - 1 : filtered.length - 1; - } else if (key === 'down') { - cursor = cursor < filtered.length - 1 ? cursor + 1 : 0; - } - - // Update selected value - if (filtered.length > 0) { - selectedValue = filtered[cursor].value; - } - }); - - // Register input change handler to update filtering - this.on('value', () => { - // Only update input when not in navigation mode - if (!isNavigating) { - const newInput = this.value || ''; - if (newInput !== input) { - input = newInput; - filterOptions(input); - } - } - }); - - isEventsRegistered = true; - } - - // Handle initial state - if (this.state === 'initial') { - input = this.value || ''; - filterOptions(input); - - // Set initial selection if provided - if (opts.initialValue !== undefined && !selectedValue) { - const initialIndex = opts.options.findIndex((o) => o.value === opts.initialValue); - if (initialIndex !== -1) { - cursor = initialIndex; - selectedValue = opts.options[initialIndex].value; - } - } - } - - // Set selection on submit - if (this.state === 'submit') { - this.value = selectedValue as any; - } - // 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 = opts.options.find((o) => o.value === selectedValue); - const label = selected ? getLabel(selected) : ''; + 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 || ''))}`; + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.value ?? ''))}`; } default: { - // Mode indicator (for debugging) - const modeIndicator = isNavigating ? color.yellow(' [navigation]') : ''; - // Display cursor position - show plain text in navigation mode - const searchText = isNavigating - ? color.dim(input) - : this.value - ? this.valueWithCursor - : color.inverse(color.hidden('_')); + const searchText = this.isNavigating ? color.dim(valueAsString) : this.valueWithCursor; // Show match count if filtered const matches = - filtered.length !== opts.options.length - ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + this.filteredOptions.length !== this.options.length + ? color.dim( + ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` + ) : ''; // Render options with selection const displayOptions = - filtered.length === 0 + this.filteredOptions.length === 0 ? [] : limitOptions({ - cursor: cursor, - options: filtered, + cursor: this.cursor, + options: this.filteredOptions, style: (option, active) => { const label = getLabel(option); const hint = option.hint ? color.dim(` (${option.hint})`) : ''; @@ -220,22 +124,22 @@ export const autocomplete = (opts: AutocompleteOptions) => { }); // Show instructions - const instructions = isNavigating - ? [ - `${color.dim('↑/↓')} to select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} to search`, - ] - : [`${color.dim('Type')} to filter, ${color.dim('↑/↓')} to navigate`]; + const instructions = [ + `${color.dim('↑/↓')} to select`, + `${color.dim('Enter:')} confirm`, + `${color.dim('Type:')} to search`, + ]; // No matches message const noResults = - filtered.length === 0 && input + 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}${modeIndicator}`, + `${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(' • '))}`, @@ -286,16 +190,7 @@ export interface AutocompleteMultiSelectOptions { * Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI */ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOptions) => { - // Track input, filtering, selection, and cursor state - let input = ''; - let cursor = 0; - let filtered = [...opts.options]; - let selectedValues: Value[] = [...(opts.initialValues ?? [])]; - let isEventsRegistered = false; - let isNavigating = false; // Track if we're in navigation mode - - // Format a single option - const formatOption = (option: Option, active: boolean) => { + const formatOption = (option: Option, active: boolean, selectedValues: Value[]) => { const isSelected = selectedValues.includes(option.value); const label = option.label ?? String(option.value ?? ''); const hint = option.hint ? color.dim(` (${option.hint})`) : ''; @@ -307,176 +202,68 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti return `${color.dim(' ')} ${checkbox} ${color.dim(label)}${hint}`; }; - // Filter options based on search input - const filterOptions = (searchText: string) => { - const prevLength = filtered.length; - const prevSelected = filtered[cursor]?.value; - - if (searchText) { - filtered = opts.options.filter((option) => { - 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); - }); - } else { - filtered = [...opts.options]; - } - - // If filtering changed the available options, update cursor - if (prevLength !== filtered.length || !filtered.length) { - if (filtered.length === 0) { - cursor = 0; - } else if (prevSelected !== undefined) { - // Try to maintain the same selected item - const index = filtered.findIndex((o) => o.value === prevSelected); - cursor = index !== -1 ? index : 0; - } else { - cursor = 0; - } - } - - // Ensure cursor is within bounds in any case - if (cursor >= filtered.length && filtered.length > 0) { - cursor = filtered.length - 1; - } - }; - - // Toggle selection of current item - const toggleSelected = () => { - if (filtered.length === 0) return; - - const value = filtered[cursor].value; - if (selectedValues.includes(value)) { - selectedValues = selectedValues.filter((v) => v !== value); - } else { - selectedValues = [...selectedValues, value]; - } - }; - // Create text prompt which we'll use as foundation - const prompt = new TextPrompt({ + const prompt = new AutocompletePrompt>({ + options: opts.options, + multiple: true, + filter: (search, opt) => { + return getFilteredOption(search, opt); + }, placeholder: opts.placeholder, - initialValue: '', + initialValue: opts.initialValues, input: opts.input, output: opts.output, render() { - // Register event handlers only once - if (!isEventsRegistered) { - // Handle keyboard input and selection - this.on('key', (key) => { - // Start navigation mode with up/down arrows - if (key === 'up' || key === 'down') { - isNavigating = true; - } - - // Toggle selection with space but only in navigation mode - if (key === ' ' && isNavigating && filtered.length > 0) { - toggleSelected(); - // Important: prevent the space from being added to the input - return false; - } - - // Allow typing again when user presses any other key - if (key !== 'up' && key !== 'down' && key !== ' ' && key !== 'return') { - isNavigating = false; - } - - // Don't block other key events - return; - }); - - // Handle cursor movement - this.on('cursor', (key) => { - if (filtered.length === 0) return; - - // Enter navigation mode - isNavigating = true; - - if (key === 'up') { - cursor = cursor > 0 ? cursor - 1 : filtered.length - 1; - } else if (key === 'down') { - cursor = cursor < filtered.length - 1 ? cursor + 1 : 0; - } - }); - - // Register input change handler to update filtering - this.on('value', () => { - // Only update input when not in navigation mode - if (!isNavigating) { - const newInput = this.value || ''; - if (newInput !== input) { - input = newInput; - filterOptions(input); - } - } - }); - - isEventsRegistered = true; - } - - // Handle initial filtering - if (this.state === 'initial') { - input = this.value || ''; - filterOptions(input); - } - - // Handle submit state - if (this.state === 'submit') { - this.value = selectedValues as any; - } - // Title and symbol const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; // Selection counter const counter = - selectedValues.length > 0 ? color.cyan(` (${selectedValues.length} selected)`) : ''; - - // Mode indicator - const modeIndicator = isNavigating ? color.yellow(' [navigation mode]') : ''; + this.selectedValues.length > 0 + ? color.cyan(` (${this.selectedValues.length} selected)`) + : ''; + const value = String(this.value ?? ''); // Search input display - const searchText = isNavigating - ? color.dim(input) // Just show plain text when in navigation mode - : this.value - ? this.valueWithCursor - : color.inverse(color.hidden('_')); + const searchText = this.isNavigating + ? color.dim(value) // Just show plain text when in navigation mode + : this.valueWithCursor; const matches = - filtered.length !== opts.options.length - ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + 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(`${selectedValues.length} items selected`)}`; + 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(input))}`; + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(value))}`; } default: { // Instructions - const instructions = isNavigating - ? [ - `${color.dim('Space:')} select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} exit navigation`, - ] - : [`${color.dim('Type')} to filter, ${color.dim('↑/↓')} to navigate`]; + const instructions = [ + `${color.dim('↑/↓')} to navigate`, + `${color.dim('Space:')} select`, + `${color.dim('Enter:')} confirm`, + `${color.dim('Type:')} to search`, + ]; // No results message const noResults = - filtered.length === 0 && input + this.filteredOptions.length === 0 && value ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] : []; // Get limited options for display const displayOptions = limitOptions({ - cursor, - options: filtered, - style: (option, active) => formatOption(option, active), + cursor: this.cursor, + options: this.filteredOptions, + style: (option, active) => formatOption(option, active, this.selectedValues), maxItems: opts.maxItems, output: opts.output, }); @@ -484,7 +271,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // Build the prompt display return [ title, - `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}${modeIndicator}`, + `${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(' • '))}`, diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index 656d10b7..8a0ca42c 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -64,13 +64,13 @@ describe('AutocompletePrompt', () => { expect(instance.cursor).to.equal(0); // Directly trigger the cursor event with 'down' - instance.emit('cursor', '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('cursor', 'up'); + instance.emit('key', '', { name: 'up' }); // After up event, cursor should be back to 0 expect(instance.cursor).to.equal(0); @@ -82,7 +82,7 @@ describe('AutocompletePrompt', () => { output, render: () => 'foo', options: testOptions, - initialValue: 'cherry', + initialValue: ['cherry'], }); // The cursor should be initialized to the cherry index @@ -90,34 +90,7 @@ describe('AutocompletePrompt', () => { expect(instance.cursor).to.equal(cherryIndex); // The selectedValue should be cherry - expect(instance.selectedValue).to.equal('cherry'); - }); - - test('maxItems limits the number of options displayed', () => { - // Create more test options - const manyOptions = [ - ...testOptions, - { value: 'kiwi', label: 'Kiwi' }, - { value: 'lemon', label: 'Lemon' }, - { value: 'mango', label: 'Mango' }, - { value: 'peach', label: 'Peach' }, - ]; - - const instance = new AutocompletePrompt({ - input, - output, - render: () => 'foo', - options: manyOptions, - maxItems: 3, - }); - - instance.prompt(); - - // There should still be all options in the filteredOptions array - expect(instance.filteredOptions.length).to.equal(manyOptions.length); - - // The maxItems property should be set correctly - expect(instance.maxItems).to.equal(3); + expect(instance.selectedValues).to.deep.equal(['cherry']); }); test('filtering through value event', () => { @@ -152,21 +125,15 @@ describe('AutocompletePrompt', () => { options: testOptions, }); - // Create a test function that uses the private method - const testFilter = (input: string, option: any) => { - // @ts-ignore - Access private method for testing - return instance.defaultFilterFn(input, option); - }; + instance.emit('value', 'ap'); - // Call the test filter with an input - const sampleOption = testOptions[0]; // 'apple' - const result = testFilter('ap', sampleOption); + expect(instance.filteredOptions).toEqual([ + { value: 'apple', label: 'Apple' }, + { value: 'grape', label: 'Grape' }, + ]); - // The filter should match 'apple' with 'ap' - expect(result).to.equal(true); + instance.emit('value', 'z'); - // Should not match with a non-existing substring - const noMatch = testFilter('z', sampleOption); - expect(noMatch).to.equal(false); + expect(instance.filteredOptions).toEqual([]); }); });