From 5b2f38a996cd1e2168e6af9a7dda017b3ddef16f Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:10:23 -0600 Subject: [PATCH 01/14] feat: adds autocomplete --- .changeset/nasty-parrots-laugh.md | 6 + examples/basic/autocomplete-multiselect.ts | 91 ++++ examples/basic/autocomplete.ts | 57 +++ packages/core/src/index.ts | 1 + packages/core/src/prompts/autocomplete.ts | 96 ++++ .../core/test/prompts/autocomplete.test.ts | 174 +++++++ packages/prompts/src/index.ts | 479 ++++++++++++++++++ 7 files changed, 904 insertions(+) create mode 100644 .changeset/nasty-parrots-laugh.md create mode 100644 examples/basic/autocomplete-multiselect.ts create mode 100644 examples/basic/autocomplete.ts create mode 100644 packages/core/src/prompts/autocomplete.ts create mode 100644 packages/core/test/prompts/autocomplete.test.ts 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..e06d14dd --- /dev/null +++ b/examples/basic/autocomplete-multiselect.ts @@ -0,0 +1,91 @@ +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)')} +- 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); \ No newline at end of file diff --git a/examples/basic/autocomplete.ts b/examples/basic/autocomplete.ts new file mode 100644 index 00000000..45e5e9e5 --- /dev/null +++ b/examples/basic/autocomplete.ts @@ -0,0 +1,57 @@ +import { setTimeout } from 'node:timers/promises'; +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); \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 67e48162..64a17fee 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 } 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..3496ff6d --- /dev/null +++ b/packages/core/src/prompts/autocomplete.ts @@ -0,0 +1,96 @@ +import Prompt, { type PromptOptions } from './prompt.js'; + +export interface AutocompleteOptions extends PromptOptions> { + options: T[]; + initialValue?: T['value']; + maxItems?: number; + filterFn?: (input: string, option: T) => boolean; +} + +export default class AutocompletePrompt extends Prompt { + options: T[]; + filteredOptions: T[]; + cursor = 0; + maxItems: number; + filterFn: (input: string, option: T) => boolean; + + private get _value() { + return this.filteredOptions[this.cursor]; + } + + 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; + } + } + } + + private changeValue() { + if (this.filteredOptions.length > 0) { + // Set the selected option's value + this.selectedValue = this._value.value; + } + } + + // Store both the search input and the selected value + public selectedValue: any; + + constructor(opts: AutocompleteOptions) { + super(opts, true); + + 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.selectedValue = this.options[initialIndex].value; + } + } + + // Handle cursor movement + this.on('cursor', (key) => { + switch (key) { + case 'up': + this.cursor = this.cursor === 0 ? this.filteredOptions.length - 1 : this.cursor - 1; + break; + case 'down': + this.cursor = this.cursor === this.filteredOptions.length - 1 ? 0 : this.cursor + 1; + break; + } + this.changeValue(); + }); + + // Update filtered options when input changes + this.on('value', (value) => { + this.value = value; + this.filterOptions(); + }); + } + + // Default filtering function + private defaultFilterFn(input: string, option: T): boolean { + const label = option.label ?? String(option.value); + return label.toLowerCase().includes(input.toLowerCase()); + } +} \ No newline at end of file diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts new file mode 100644 index 00000000..5c265e15 --- /dev/null +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -0,0 +1,174 @@ +import color from 'picocolors'; +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import Prompt from '../../src/prompts/prompt.js'; +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('cursor', 'down'); + + // After down event, cursor should be 1 + expect(instance.cursor).to.equal(1); + + // Trigger cursor event with 'up' + instance.emit('cursor', '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.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); + }); + + 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 + }); + + // 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); + }; + + // Call the test filter with an input + const sampleOption = testOptions[0]; // 'apple' + const result = testFilter('ap', sampleOption); + + // The filter should match 'apple' with 'ap' + expect(result).to.equal(true); + + // Should not match with a non-existing substring + const noMatch = testFilter('z', sampleOption); + expect(noMatch).to.equal(false); + }); +}); \ No newline at end of file diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index d49c735b..859ff6ff 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -1014,3 +1014,482 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => { s.stop(result || task.title); } }; + +function getLabel(option: Option) { + return option.label ?? String(option.value ?? ''); +} + +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) => { + // 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({ + placeholder: opts.placeholder, + initialValue: '', + 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`; + + // 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) : ''; + return `${title}${color.gray(S_BAR)} ${color.dim(label)}`; + } + + case 'cancel': { + 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('_'))); + + // Show match count if filtered + const matches = filtered.length !== opts.options.length + ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + : ''; + + // Render options with selection + const displayOptions = filtered.length === 0 + ? [] + : limitOptions({ + cursor: cursor, + options: filtered, + style: (option, active) => { + const label = getLabel(option); + const hint = option.hint ? 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 = isNavigating + ? [`${color.dim('↑/↓')} to select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} to search`] + : [`${color.dim('Type')} to filter, ${color.dim('↑/↓')} to navigate`]; + + // No matches message + const noResults = filtered.length === 0 && input + ? [`${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}`, + ...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) => { + // 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 isSelected = selectedValues.includes(option.value); + const label = option.label ?? String(option.value ?? ''); + const hint = option.hint ? color.dim(` (${option.hint})`) : ''; + const checkbox = isSelected + ? color.green(S_CHECKBOX_SELECTED) + : color.dim(S_CHECKBOX_INACTIVE); + + if (active) { + return `${color.green('›')} ${checkbox} ${label}${hint}`; + } + 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({ + placeholder: opts.placeholder, + initialValue: '', + 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]') + : ''; + + // 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 matches = filtered.length !== opts.options.length + ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + : ''; + + // Render prompt state + switch (this.state) { + case 'submit': { + return `${title}${color.gray(S_BAR)} ${color.dim(`${selectedValues.length} items selected`)}`; + } + case 'cancel': { + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(input))}`; + } + 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`]; + + // No results message + const noResults = filtered.length === 0 && input + ? [`${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), + maxItems: opts.maxItems, + output: opts.output + }); + + // Build the prompt display + return [ + title, + `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}${modeIndicator}`, + ...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; +}; From 0f4aa044724c9248348612b50271b517f5fc1ac9 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:18:54 -0600 Subject: [PATCH 02/14] Apply suggestions from @MacFJA and @natemoo.re reviews --- examples/basic/autocomplete-multiselect.ts | 1 + packages/core/src/prompts/autocomplete.ts | 38 ++++++ packages/core/src/prompts/prompt.ts | 23 +++- .../src/__snapshots__/index.test.ts.snap | 84 ++++++------ packages/prompts/src/index.ts | 121 ++++++++++++++---- 5 files changed, 201 insertions(+), 66 deletions(-) diff --git a/examples/basic/autocomplete-multiselect.ts b/examples/basic/autocomplete-multiselect.ts index e06d14dd..54362617 100644 --- a/examples/basic/autocomplete-multiselect.ts +++ b/examples/basic/autocomplete-multiselect.ts @@ -16,6 +16,7 @@ ${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'); diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 3496ff6d..571c0b44 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -13,6 +13,8 @@ export default class AutocompletePrompt boolean; + isNavigationMode = false; // Track if we're in navigation mode + ignoreNextSpace = false; // Track if we should ignore the next space private get _value() { return this.filteredOptions[this.cursor]; @@ -68,13 +70,34 @@ export default class AutocompletePrompt { + // Enter navigation mode with arrow keys + if (key === 'up' || key === 'down') { + this.isNavigationMode = true; + } + + // Space key in navigation mode should be ignored for input + if (key === ' ' && this.isNavigationMode) { + this.ignoreNextSpace = true; + return false; // Prevent propagation + } + + // Exit navigation mode with non-navigation keys + if (key !== 'up' && key !== 'down' && key !== 'return') { + this.isNavigationMode = false; + } + }); + // 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; } @@ -83,6 +106,21 @@ export default class AutocompletePrompt { + // 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; + } + + // In navigation mode, strip out any spaces + if (this.isNavigationMode && value?.includes(' ')) { + this.value = value.replace(/\s+/g, ''); + return; + } + + // Normal filtering when not in navigation mode this.value = value; this.filterOptions(); }); diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 089e1dc8..eab6fdcd 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -180,6 +180,17 @@ 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; + } + if (this.state === 'error') { this.state = 'active'; } @@ -200,8 +211,16 @@ export default class Prompt { this.emit('value', 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; + } } if (key?.name === 'return') { @@ -223,9 +242,11 @@ export default class Prompt { } } - if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { + // Only process as cancel if the key wasn't already handled + if (!keyHandled && isActionKey([char, key?.name, key?.sequence], 'cancel')) { this.state = 'cancel'; } + if (this.state === 'submit' || this.state === 'cancel') { this.emit('finalize'); } diff --git a/packages/prompts/src/__snapshots__/index.test.ts.snap b/packages/prompts/src/__snapshots__/index.test.ts.snap index 6437aefe..4ea0402e 100644 --- a/packages/prompts/src/__snapshots__/index.test.ts.snap +++ b/packages/prompts/src/__snapshots__/index.test.ts.snap @@ -815,7 +815,7 @@ exports[`prompts (isCI = false) > multiselect > maxItems renders a sliding windo │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -826,7 +826,7 @@ exports[`prompts (isCI = false) > multiselect > maxItems renders a sliding windo │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -836,7 +836,7 @@ exports[`prompts (isCI = false) > multiselect > maxItems renders a sliding windo │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -845,18 +845,18 @@ exports[`prompts (isCI = false) > multiselect > maxItems renders a sliding windo "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -866,7 +866,7 @@ exports[`prompts (isCI = false) > multiselect > maxItems renders a sliding windo │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -876,7 +876,7 @@ exports[`prompts (isCI = false) > multiselect > maxItems renders a sliding windo │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -1089,7 +1089,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1100,7 +1100,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1110,7 +1110,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1119,18 +1119,18 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -1140,7 +1140,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -1150,7 +1150,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -1160,7 +1160,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt6 │ ◻ opt7 │ ◻ opt8 -│ ... +│  ... └ ", "", @@ -1170,7 +1170,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 -│ ... +│  ... └ ", "", @@ -1206,7 +1206,7 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops downwards 1 │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1235,13 +1235,13 @@ exports[`prompts (isCI = false) > multiselect > sliding window loops upwards 1`] │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 @@ -2807,7 +2807,7 @@ exports[`prompts (isCI = true) > multiselect > maxItems renders a sliding window │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -2818,7 +2818,7 @@ exports[`prompts (isCI = true) > multiselect > maxItems renders a sliding window │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -2828,7 +2828,7 @@ exports[`prompts (isCI = true) > multiselect > maxItems renders a sliding window │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -2837,18 +2837,18 @@ exports[`prompts (isCI = true) > multiselect > maxItems renders a sliding window "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -2858,7 +2858,7 @@ exports[`prompts (isCI = true) > multiselect > maxItems renders a sliding window │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -2868,7 +2868,7 @@ exports[`prompts (isCI = true) > multiselect > maxItems renders a sliding window │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -3081,7 +3081,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -3092,7 +3092,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -3102,7 +3102,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -3111,18 +3111,18 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -3132,7 +3132,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -3142,7 +3142,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -3152,7 +3152,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt6 │ ◻ opt7 │ ◻ opt8 -│ ... +│  ... └ ", "", @@ -3162,7 +3162,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 -│ ... +│  ... └ ", "", @@ -3198,7 +3198,7 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops downwards 1` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -3227,13 +3227,13 @@ exports[`prompts (isCI = true) > multiselect > sliding window loops upwards 1`] │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 859ff6ff..3d2d15c0 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -68,12 +68,18 @@ interface LimitOptionsParams extends CommonOptions { maxItems: number | undefined; cursor: number; style: (option: TOption, active: boolean) => string; + /** + * Custom format for overflow indicators. + * Defaults to ' ...' if not provided. + */ + overflowFormat?: string; } const limitOptions = (params: LimitOptionsParams): string[] => { 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 = params.overflowFormat ?? color.dim(' ...'); const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY; const outputMaxItems = Math.max(rows - 4, 0); @@ -97,7 +103,7 @@ const limitOptions = (params: LimitOptionsParams): string[] => const isTopLimit = i === 0 && shouldRenderTopEllipsis; const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; return isTopLimit || isBottomLimit - ? color.dim('...') + ? overflowFormat : style(option, i + slidingWindowLocation === cursor); }); }; @@ -309,6 +315,7 @@ export const select = (opts: SelectOptions) => { cursor: this.cursor, options: this.options, maxItems: opts.maxItems, + overflowFormat: color.dim(' ...'), style: (item, active) => opt(item, active ? 'active' : 'inactive'), }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } @@ -475,6 +482,7 @@ export const multiselect = (opts: MultiSelectOptions) => { options: this.options, cursor: this.cursor, maxItems: opts.maxItems, + overflowFormat: color.dim(' ...'), style: styleOption, }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } @@ -1109,6 +1117,26 @@ export const autocomplete = (opts: AutocompleteOptions) => { isNavigating = true; } + // Handle space in navigation mode + if (key === ' ' && isNavigating) { + // Space is often used for selection in navigation mode + // Important: completely prevent space from being added to input + (this as any)._ignoreNextSpace = true; + // Directly modify the input value to prevent space addition + if (this.value?.endsWith(' ')) { + this.value = this.value.slice(0, -1); + } + return false; // Prevent the space from being added + } + + // Exit navigation mode when pressing escape + if (key === 'escape' && isNavigating) { + isNavigating = false; + // Set flag on prompt instance to prevent global cancel + (this as any)._keyHandled = true; + return false; // Prevent propagation of the escape key + } + // Allow typing again when user presses any other key if (key !== 'up' && key !== 'down' && key !== 'return') { isNavigating = false; @@ -1136,13 +1164,28 @@ export const autocomplete = (opts: AutocompleteOptions) => { // 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); + // Handle spaces in navigation mode + if (isNavigating) { + // Remove any spaces when in navigation mode + if (this.value?.includes(' ')) { + this.value = this.value.replace(/\s+/g, ''); } + return; + } + + // Handle ignoring spaces from key handler + if ((this as any)._ignoreNextSpace && this.value?.endsWith(' ')) { + // Remove the space and reset the flag + this.value = this.value.replace(/\s+$/, ''); + (this as any)._ignoreNextSpace = false; + return; + } + + // Normal filtering mode + const newInput = this.value || ''; + if (newInput !== input) { + input = newInput; + filterOptions(input); } }); @@ -1215,13 +1258,14 @@ export const autocomplete = (opts: AutocompleteOptions) => { : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`; }, maxItems: opts.maxItems, - output: opts.output + output: opts.output, + overflowFormat: color.dim(' ...') }); - // Show instructions + // 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`]; + ? [`${color.white('↑/↓:')} navigate, ${color.dim('Space:')} select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} filter`] + : [`${color.dim('↑/↓:')} navigate, ${color.white('Type')} to filter, ${color.dim('Enter:')} confirm`]; // No matches message const noResults = filtered.length === 0 && input @@ -1294,7 +1338,8 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const formatOption = (option: Option, active: boolean) => { const isSelected = selectedValues.includes(option.value); const label = option.label ?? String(option.value ?? ''); - const hint = option.hint ? color.dim(` (${option.hint})`) : ''; + // Only show hint when the item is active + const hint = active && option.hint ? color.dim(` (${option.hint})`) : ''; const checkbox = isSelected ? color.green(S_CHECKBOX_SELECTED) : color.dim(S_CHECKBOX_INACTIVE); @@ -1302,7 +1347,10 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti if (active) { return `${color.green('›')} ${checkbox} ${label}${hint}`; } - return `${color.dim(' ')} ${checkbox} ${color.dim(label)}${hint}`; + if (isSelected) { + return `${color.dim(' ')} ${checkbox} ${color.dim(label)}`; + } + return `${color.dim(' ')} ${checkbox} ${color.dim(label)}`; }; // Filter options based on search input @@ -1374,9 +1422,20 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti if (key === ' ' && isNavigating && filtered.length > 0) { toggleSelected(); // Important: prevent the space from being added to the input + // Set a flag to ignore any space input for filtering + (this as any)._ignoreNextSpace = true; + // Directly modify the input value to prevent space addition + if (this.value?.endsWith(' ')) { + this.value = this.value.slice(0, -1); + } return false; } + // Also toggle when Enter is pressed in navigation mode + if (key === 'return' && isNavigating && filtered.length > 0) { + toggleSelected(); + } + // Allow typing again when user presses any other key if (key !== 'up' && key !== 'down' && key !== ' ' && key !== 'return') { isNavigating = false; @@ -1402,13 +1461,28 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // 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); + // Handle spaces in navigation mode + if (isNavigating) { + // Remove any spaces when in navigation mode + if (this.value?.includes(' ')) { + this.value = this.value.replace(/\s+/g, ''); } + return; + } + + // Handle ignoring spaces from key handler + if ((this as any)._ignoreNextSpace && this.value?.endsWith(' ')) { + // Remove the space and reset the flag + this.value = this.value.replace(/\s+$/, ''); + (this as any)._ignoreNextSpace = false; + return; + } + + // Normal filtering mode + const newInput = this.value || ''; + if (newInput !== input) { + input = newInput; + filterOptions(input); } }); @@ -1459,8 +1533,8 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti 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`]; + ? [`${color.white('↑/↓:')} navigate, ${color.dim('Space:')} select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} filter`] + : [`${color.dim('↑/↓:')} navigate, ${color.white('Type')} to filter, ${color.dim('Enter:')} confirm`]; // No results message const noResults = filtered.length === 0 && input @@ -1473,12 +1547,13 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti options: filtered, style: (option, active) => formatOption(option, active), maxItems: opts.maxItems, - output: opts.output + output: opts.output, + overflowFormat: color.dim(' ...') }); // Build the prompt display return [ - title, + title.replace(/\n$/, ''), `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}${modeIndicator}`, ...noResults, ...displayOptions.map(option => `${color.cyan(S_BAR)} ${option}`), From 6ae902c45a4d6ac01b77aabd187136df8a06856f Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 18 Apr 2025 19:47:40 -0600 Subject: [PATCH 03/14] add: missing changes after merge --- examples/basic/autocomplete.ts | 1 - packages/prompts/src/autocomplete.ts | 488 ++++++++++++++++++ packages/prompts/src/index.ts | 1 + packages/prompts/src/limit-options.ts | 8 +- packages/prompts/src/multi-select.ts | 1 + packages/prompts/src/select.ts | 1 + .../__snapshots__/multi-select.test.ts.snap | 84 +-- packages/prompts/test/autocomplete.test.ts | 172 ++++++ 8 files changed, 712 insertions(+), 44 deletions(-) create mode 100644 packages/prompts/src/autocomplete.ts create mode 100644 packages/prompts/test/autocomplete.test.ts diff --git a/examples/basic/autocomplete.ts b/examples/basic/autocomplete.ts index 45e5e9e5..8364a444 100644 --- a/examples/basic/autocomplete.ts +++ b/examples/basic/autocomplete.ts @@ -1,4 +1,3 @@ -import { setTimeout } from 'node:timers/promises'; import * as p from '@clack/prompts'; import color from 'picocolors'; diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts new file mode 100644 index 00000000..3c334ade --- /dev/null +++ b/packages/prompts/src/autocomplete.ts @@ -0,0 +1,488 @@ +import color from 'picocolors'; +import { type CommonOptions, S_BAR, symbol, S_RADIO_ACTIVE, S_RADIO_INACTIVE, S_CHECKBOX_SELECTED, S_CHECKBOX_INACTIVE, S_BAR_END } from './common.js'; +import { limitOptions } from './limit-options.js'; +import type { Option } from './select.js'; +import { TextPrompt } from '@clack/core'; + + +function getLabel(option: Option) { + return option.label ?? String(option.value ?? ''); +} + +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) => { + // 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({ + placeholder: opts.placeholder, + initialValue: '', + 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`; + + // 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) : ''; + return `${title}${color.gray(S_BAR)} ${color.dim(label)}`; + } + + case 'cancel': { + 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('_'))); + + // Show match count if filtered + const matches = filtered.length !== opts.options.length + ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + : ''; + + // Render options with selection + const displayOptions = filtered.length === 0 + ? [] + : limitOptions({ + cursor: cursor, + options: filtered, + style: (option, active) => { + const label = getLabel(option); + const hint = option.hint ? 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 = isNavigating + ? [`${color.dim('↑/↓')} to select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} to search`] + : [`${color.dim('Type')} to filter, ${color.dim('↑/↓')} to navigate`]; + + // No matches message + const noResults = filtered.length === 0 && input + ? [`${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}`, + ...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) => { + // 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 isSelected = selectedValues.includes(option.value); + const label = option.label ?? String(option.value ?? ''); + const hint = option.hint ? color.dim(` (${option.hint})`) : ''; + const checkbox = isSelected + ? color.green(S_CHECKBOX_SELECTED) + : color.dim(S_CHECKBOX_INACTIVE); + + if (active) { + return `${color.green('›')} ${checkbox} ${label}${hint}`; + } + 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({ + placeholder: opts.placeholder, + initialValue: '', + 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]') + : ''; + + // 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 matches = filtered.length !== opts.options.length + ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + : ''; + + // Render prompt state + switch (this.state) { + case 'submit': { + return `${title}${color.gray(S_BAR)} ${color.dim(`${selectedValues.length} items selected`)}`; + } + case 'cancel': { + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(input))}`; + } + 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`]; + + // No results message + const noResults = filtered.length === 0 && input + ? [`${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), + maxItems: opts.maxItems, + output: opts.output + }); + + // Build the prompt display + return [ + title, + `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}${modeIndicator}`, + ...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; +}; \ No newline at end of file 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..9a3a3b09 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -8,12 +8,18 @@ export interface LimitOptionsParams extends CommonOptions { maxItems: number | undefined; cursor: number; style: (option: TOption, active: boolean) => string; + /** + * Custom format for overflow indicators. + * Defaults to ' ...' if not provided. + */ + overflowFormat?: string; } export const limitOptions = (params: LimitOptionsParams): string[] => { 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 = params.overflowFormat ?? color.dim(' ...'); const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY; const outputMaxItems = Math.max(rows - 4, 0); @@ -37,7 +43,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/src/multi-select.ts b/packages/prompts/src/multi-select.ts index a93420f8..7c9640e9 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -120,6 +120,7 @@ export const multiselect = (opts: MultiSelectOptions) => { options: this.options, cursor: this.cursor, maxItems: opts.maxItems, + overflowFormat: color.dim(' ...'), style: styleOption, }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index c66ca0fc..2ca3deb0 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -96,6 +96,7 @@ export const select = (opts: SelectOptions) => { cursor: this.cursor, options: this.options, maxItems: opts.maxItems, + overflowFormat: color.dim(' ...'), style: (item, active) => opt(item, active ? 'active' : 'inactive'), }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap index 01268cf2..acec91e1 100644 --- a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap @@ -145,7 +145,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -156,7 +156,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -166,7 +166,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -175,18 +175,18 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -196,7 +196,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -206,7 +206,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -419,7 +419,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -430,7 +430,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -440,7 +440,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -449,18 +449,18 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -470,7 +470,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -480,7 +480,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -490,7 +490,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt6 │ ◻ opt7 │ ◻ opt8 -│ ... +│  ... └ ", "", @@ -500,7 +500,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 -│ ... +│  ... └ ", "", @@ -536,7 +536,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -565,13 +565,13 @@ exports[`multiselect (isCI = false) > sliding window loops upwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 @@ -740,7 +740,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -751,7 +751,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -761,7 +761,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -770,18 +770,18 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -791,7 +791,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -801,7 +801,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -1014,7 +1014,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1025,7 +1025,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1035,7 +1035,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1044,18 +1044,18 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│ ... +│  ... └ ", "", @@ -1065,7 +1065,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│ ... +│  ... └ ", "", @@ -1075,7 +1075,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│ ... +│  ... └ ", "", @@ -1085,7 +1085,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt6 │ ◻ opt7 │ ◻ opt8 -│ ... +│  ... └ ", "", @@ -1095,7 +1095,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 -│ ... +│  ... └ ", "", @@ -1131,7 +1131,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", @@ -1160,13 +1160,13 @@ exports[`multiselect (isCI = true) > sliding window loops upwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│ ... +│  ... └ ", "", "", "", - "│ ... + "│  ... │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts new file mode 100644 index 00000000..6c293636 --- /dev/null +++ b/packages/prompts/test/autocomplete.test.ts @@ -0,0 +1,172 @@ +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { AutocompletePrompt } from '@clack/core'; +import { MockReadable } from './test-utils.js'; +import { MockWritable } from './test-utils.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('cursor', 'down'); + + // After down event, cursor should be 1 + expect(instance.cursor).to.equal(1); + + // Trigger cursor event with 'up' + instance.emit('cursor', '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.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); + }); + + 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 + }); + + // 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); + }; + + // Call the test filter with an input + const sampleOption = testOptions[0]; // 'apple' + const result = testFilter('ap', sampleOption); + + // The filter should match 'apple' with 'ap' + expect(result).to.equal(true); + + // Should not match with a non-existing substring + const noMatch = testFilter('z', sampleOption); + expect(noMatch).to.equal(false); + }); +}); \ No newline at end of file From d7523e306e2614b05ed92dbb9d18f829fd2ff862 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 21 Apr 2025 18:08:55 +0100 Subject: [PATCH 04/14] chore: use selectedValue getter --- packages/core/src/prompts/autocomplete.ts | 27 +++++++---------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 571c0b44..c58ff4c8 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -16,23 +16,19 @@ export default class AutocompletePrompt 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); @@ -43,15 +39,10 @@ export default class AutocompletePrompt 0) { - // Set the selected option's value - this.selectedValue = this._value.value; - } - } - // Store both the search input and the selected value - public selectedValue: any; + public get selectedValue(): T['value'] | undefined { + return this.filteredOptions[this.cursor]?.value; + } constructor(opts: AutocompleteOptions) { super(opts, true); @@ -60,13 +51,12 @@ export default class AutocompletePrompt value === opts.initialValue); if (initialIndex !== -1) { this.cursor = initialIndex; - this.selectedValue = this.options[initialIndex].value; } } @@ -101,7 +91,6 @@ export default class AutocompletePrompt Date: Mon, 28 Apr 2025 10:16:21 +0100 Subject: [PATCH 05/14] chore: run format --- examples/basic/autocomplete-multiselect.ts | 134 +++---- examples/basic/autocomplete.ts | 89 ++--- packages/core/src/prompts/autocomplete.ts | 229 ++++++------ packages/core/src/prompts/prompt.ts | 4 +- .../core/test/prompts/autocomplete.test.ts | 332 +++++++++--------- packages/prompts/src/autocomplete.ts | 168 +++++---- packages/prompts/test/autocomplete.test.ts | 332 +++++++++--------- 7 files changed, 656 insertions(+), 632 deletions(-) diff --git a/examples/basic/autocomplete-multiselect.ts b/examples/basic/autocomplete-multiselect.ts index 54362617..c34420c6 100644 --- a/examples/basic/autocomplete-multiselect.ts +++ b/examples/basic/autocomplete-multiselect.ts @@ -7,11 +7,12 @@ import color from 'picocolors'; */ async function main() { - console.clear(); + console.clear(); - p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`); + p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`); - p.note(` + 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 @@ -19,74 +20,81 @@ ${color.cyan('Filter and select multiple items in a single interface:')} - 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'); + `, + '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' }, - ]; + // 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 - }); + // 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); - } + 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'); - } + // 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'); - } + // We can now use the type guard to ensure type safety + if (!isStringArray(result)) { + throw new Error('Unexpected result type'); + } - const selectedFrameworks = result; + 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); - } + // 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'); + // Display selected frameworks with detailed information + p.note( + `You selected ${color.green(selectedFrameworks.length)} frameworks:`, + 'Selection Complete' + ); - p.log.message(selectedDetails); - p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`); + // 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); \ No newline at end of file +main().catch(console.error); diff --git a/examples/basic/autocomplete.ts b/examples/basic/autocomplete.ts index 8364a444..ea94ece1 100644 --- a/examples/basic/autocomplete.ts +++ b/examples/basic/autocomplete.ts @@ -2,55 +2,58 @@ import * as p from '@clack/prompts'; import color from 'picocolors'; async function main() { - console.clear(); + console.clear(); - p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`); + p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`); - p.note(` + 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)})`); + `, + '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); \ No newline at end of file +main().catch(console.error); diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index c58ff4c8..459ad93c 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -1,123 +1,124 @@ import Prompt, { type PromptOptions } from './prompt.js'; -export interface AutocompleteOptions extends PromptOptions> { - options: T[]; - initialValue?: T['value']; - maxItems?: number; - filterFn?: (input: string, option: T) => boolean; +export interface AutocompleteOptions + extends PromptOptions> { + options: T[]; + initialValue?: T['value']; + maxItems?: number; + filterFn?: (input: string, option: T) => boolean; } 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; - } - } - } - - // Store both the search input and the selected value - public get selectedValue(): T['value'] | undefined { + 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; + } + } + } + + // Store both the search input and the selected value + public get selectedValue(): T['value'] | undefined { return this.filteredOptions[this.cursor]?.value; } - constructor(opts: AutocompleteOptions) { - super(opts, true); - - 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; - } - } - - // Handle keyboard key presses - this.on('key', (key) => { - // Enter navigation mode with arrow keys - if (key === 'up' || key === 'down') { - this.isNavigationMode = true; - } - - // Space key in navigation mode should be ignored for input - if (key === ' ' && this.isNavigationMode) { - this.ignoreNextSpace = true; - return false; // Prevent propagation - } - - // Exit navigation mode with non-navigation keys - if (key !== 'up' && key !== 'down' && key !== 'return') { - this.isNavigationMode = false; - } - }); - - // 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; - } - }); - - // 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; - } - - // In navigation mode, strip out any spaces - if (this.isNavigationMode && value?.includes(' ')) { - this.value = value.replace(/\s+/g, ''); - return; - } - - // Normal filtering when not in navigation mode - this.value = value; - this.filterOptions(); - }); - } - - // Default filtering function - private defaultFilterFn(input: string, option: T): boolean { - const label = option.label ?? String(option.value); - return label.toLowerCase().includes(input.toLowerCase()); - } + constructor(opts: AutocompleteOptions) { + super(opts, true); + + 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; + } + } + + // Handle keyboard key presses + this.on('key', (key) => { + // Enter navigation mode with arrow keys + if (key === 'up' || key === 'down') { + this.isNavigationMode = true; + } + + // Space key in navigation mode should be ignored for input + if (key === ' ' && this.isNavigationMode) { + this.ignoreNextSpace = true; + return false; // Prevent propagation + } + + // Exit navigation mode with non-navigation keys + if (key !== 'up' && key !== 'down' && key !== 'return') { + this.isNavigationMode = false; + } + }); + + // 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; + } + }); + + // 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; + } + + // In navigation mode, strip out any spaces + if (this.isNavigationMode && value?.includes(' ')) { + this.value = value.replace(/\s+/g, ''); + return; + } + + // Normal filtering when not in navigation mode + this.value = value; + this.filterOptions(); + }); + } + + // Default filtering function + private defaultFilterFn(input: string, option: T): boolean { + const label = option.label ?? String(option.value); + return label.toLowerCase().includes(input.toLowerCase()); + } } diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index eab6fdcd..098465bf 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -211,7 +211,7 @@ export default class Prompt { this.emit('value', this.opts.placeholder); } } - + // Call the key event handler and emit the key event if (char) { this.emit('key', char.toLowerCase()); @@ -246,7 +246,7 @@ export default class Prompt { if (!keyHandled && 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/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts index 5c265e15..34383858 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -1,174 +1,174 @@ import color from 'picocolors'; import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import Prompt from '../../src/prompts/prompt.js'; 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'; 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('cursor', 'down'); - - // After down event, cursor should be 1 - expect(instance.cursor).to.equal(1); - - // Trigger cursor event with 'up' - instance.emit('cursor', '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.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); - }); - - 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 - }); - - // 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); - }; - - // Call the test filter with an input - const sampleOption = testOptions[0]; // 'apple' - const result = testFilter('ap', sampleOption); - - // The filter should match 'apple' with 'ap' - expect(result).to.equal(true); - - // Should not match with a non-existing substring - const noMatch = testFilter('z', sampleOption); - expect(noMatch).to.equal(false); - }); -}); \ No newline at end of file + 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('cursor', 'down'); + + // After down event, cursor should be 1 + expect(instance.cursor).to.equal(1); + + // Trigger cursor event with 'up' + instance.emit('cursor', '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.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); + }); + + 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, + }); + + // 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); + }; + + // Call the test filter with an input + const sampleOption = testOptions[0]; // 'apple' + const result = testFilter('ap', sampleOption); + + // The filter should match 'apple' with 'ap' + expect(result).to.equal(true); + + // Should not match with a non-existing substring + const noMatch = testFilter('z', sampleOption); + expect(noMatch).to.equal(false); + }); +}); diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 3c334ade..cfbe6968 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -1,9 +1,17 @@ +import { TextPrompt } from '@clack/core'; import color from 'picocolors'; -import { type CommonOptions, S_BAR, symbol, S_RADIO_ACTIVE, S_RADIO_INACTIVE, S_CHECKBOX_SELECTED, S_CHECKBOX_INACTIVE, S_BAR_END } from './common.js'; +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'; -import { TextPrompt } from '@clack/core'; - function getLabel(option: Option) { return option.label ?? String(option.value ?? ''); @@ -47,7 +55,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { const prevSelected = filtered[cursor]?.value; if (searchText) { - filtered = opts.options.filter(option => { + filtered = opts.options.filter((option) => { const label = (option.label ?? String(option.value ?? '')).toLowerCase(); const hint = (option.hint ?? '').toLowerCase(); const value = String(option.value).toLowerCase(); @@ -59,13 +67,13 @@ export const autocomplete = (opts: AutocompleteOptions) => { filtered = [...opts.options]; } - // If filtering changed the available options, update cursor + // 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); + const index = filtered.findIndex((o) => o.value === prevSelected); cursor = index !== -1 ? index : 0; } else { cursor = 0; @@ -127,7 +135,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { // Register input change handler to update filtering this.on('value', () => { // Only update input when not in navigation mode - if (!isNavigating) { + if (!isNavigating) { const newInput = this.value || ''; if (newInput !== input) { input = newInput; @@ -146,7 +154,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { // Set initial selection if provided if (opts.initialValue !== undefined && !selectedValue) { - const initialIndex = opts.options.findIndex(o => o.value === opts.initialValue); + const initialIndex = opts.options.findIndex((o) => o.value === opts.initialValue); if (initialIndex !== -1) { cursor = initialIndex; selectedValue = opts.options[initialIndex].value; @@ -166,7 +174,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { switch (this.state) { case 'submit': { // Show selected value - const selected = opts.options.find(o => o.value === selectedValue); + const selected = opts.options.find((o) => o.value === selectedValue); const label = selected ? getLabel(selected) : ''; return `${title}${color.gray(S_BAR)} ${color.dim(label)}`; } @@ -177,61 +185,65 @@ export const autocomplete = (opts: AutocompleteOptions) => { default: { // Mode indicator (for debugging) - const modeIndicator = isNavigating - ? color.yellow(' [navigation]') - : ''; + 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('_'))); - - // Show match count if filtered - const matches = filtered.length !== opts.options.length - ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) - : ''; - - // Render options with selection - const displayOptions = filtered.length === 0 - ? [] - : limitOptions({ - cursor: cursor, - options: filtered, - style: (option, active) => { - const label = getLabel(option); - const hint = option.hint ? 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 - }); + const searchText = isNavigating + ? color.dim(input) + : this.value + ? this.valueWithCursor + : color.inverse(color.hidden('_')); + + // Show match count if filtered + const matches = + filtered.length !== opts.options.length + ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + : ''; + + // Render options with selection + const displayOptions = + filtered.length === 0 + ? [] + : limitOptions({ + cursor: cursor, + options: filtered, + style: (option, active) => { + const label = getLabel(option); + const hint = option.hint ? 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 = isNavigating - ? [`${color.dim('↑/↓')} to select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} to search`] + ? [ + `${color.dim('↑/↓')} to select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} to search`, + ] : [`${color.dim('Type')} to filter, ${color.dim('↑/↓')} to navigate`]; // No matches message - const noResults = filtered.length === 0 && input - ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] - : []; + const noResults = + filtered.length === 0 && input + ? [`${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}`, ...noResults, - ...displayOptions.map(option => `${color.cyan(S_BAR)} ${option}`), + ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, - `${color.cyan(S_BAR_END)}` + `${color.cyan(S_BAR_END)}`, ].join('\n'); - - } + } } - } + }, }); // Return the result or cancel symbol @@ -287,16 +299,13 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const isSelected = selectedValues.includes(option.value); const label = option.label ?? String(option.value ?? ''); const hint = option.hint ? color.dim(` (${option.hint})`) : ''; - const checkbox = isSelected - ? color.green(S_CHECKBOX_SELECTED) - : color.dim(S_CHECKBOX_INACTIVE); + const checkbox = isSelected ? color.green(S_CHECKBOX_SELECTED) : color.dim(S_CHECKBOX_INACTIVE); if (active) { return `${color.green('›')} ${checkbox} ${label}${hint}`; - } + } return `${color.dim(' ')} ${checkbox} ${color.dim(label)}${hint}`; - - }; + }; // Filter options based on search input const filterOptions = (searchText: string) => { @@ -304,7 +313,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const prevSelected = filtered[cursor]?.value; if (searchText) { - filtered = opts.options.filter(option => { + filtered = opts.options.filter((option) => { const label = (option.label ?? String(option.value ?? '')).toLowerCase(); const hint = (option.hint ?? '').toLowerCase(); const value = String(option.value).toLowerCase(); @@ -316,13 +325,13 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti filtered = [...opts.options]; } - // If filtering changed the available options, update cursor + // 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); + const index = filtered.findIndex((o) => o.value === prevSelected); cursor = index !== -1 ? index : 0; } else { cursor = 0; @@ -341,7 +350,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const value = filtered[cursor].value; if (selectedValues.includes(value)) { - selectedValues = selectedValues.filter(v => v !== value); + selectedValues = selectedValues.filter((v) => v !== value); } else { selectedValues = [...selectedValues, value]; } @@ -423,23 +432,23 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti 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)`) - : ''; + const counter = + selectedValues.length > 0 ? color.cyan(` (${selectedValues.length} selected)`) : ''; // Mode indicator - const modeIndicator = isNavigating - ? color.yellow(' [navigation mode]') - : ''; + const modeIndicator = isNavigating ? color.yellow(' [navigation mode]') : ''; // Search input display - const searchText = isNavigating + const searchText = isNavigating ? color.dim(input) // Just show plain text when in navigation mode - : (this.value ? this.valueWithCursor : color.inverse(color.hidden('_'))); + : this.value + ? this.valueWithCursor + : color.inverse(color.hidden('_')); - const matches = filtered.length !== opts.options.length - ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) - : ''; + const matches = + filtered.length !== opts.options.length + ? color.dim(` (${filtered.length} match${filtered.length === 1 ? '' : 'es'})`) + : ''; // Render prompt state switch (this.state) { @@ -452,21 +461,24 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti default: { // Instructions const instructions = isNavigating - ? [`${color.dim('Space:')} select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} exit navigation`] + ? [ + `${color.dim('Space:')} select, ${color.dim('Enter:')} confirm, ${color.dim('Type:')} exit navigation`, + ] : [`${color.dim('Type')} to filter, ${color.dim('↑/↓')} to navigate`]; // No results message - const noResults = filtered.length === 0 && input - ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] - : []; + const noResults = + filtered.length === 0 && input + ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] + : []; // Get limited options for display const displayOptions = limitOptions({ - cursor, + cursor, options: filtered, style: (option, active) => formatOption(option, active), maxItems: opts.maxItems, - output: opts.output + output: opts.output, }); // Build the prompt display @@ -474,15 +486,15 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti title, `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}${modeIndicator}`, ...noResults, - ...displayOptions.map(option => `${color.cyan(S_BAR)} ${option}`), + ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, - `${color.cyan(S_BAR_END)}` + `${color.cyan(S_BAR_END)}`, ].join('\n'); } } - } + }, }); // Return the result or cancel symbol return prompt.prompt() as Promise; -}; \ No newline at end of file +}; diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index 6c293636..656d10b7 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -1,172 +1,172 @@ +import { AutocompletePrompt } from '@clack/core'; import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { AutocompletePrompt } from '@clack/core'; import { MockReadable } from './test-utils.js'; import { MockWritable } from './test-utils.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('cursor', 'down'); - - // After down event, cursor should be 1 - expect(instance.cursor).to.equal(1); - - // Trigger cursor event with 'up' - instance.emit('cursor', '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.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); - }); - - 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 - }); - - // 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); - }; - - // Call the test filter with an input - const sampleOption = testOptions[0]; // 'apple' - const result = testFilter('ap', sampleOption); - - // The filter should match 'apple' with 'ap' - expect(result).to.equal(true); - - // Should not match with a non-existing substring - const noMatch = testFilter('z', sampleOption); - expect(noMatch).to.equal(false); - }); -}); \ No newline at end of file + 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('cursor', 'down'); + + // After down event, cursor should be 1 + expect(instance.cursor).to.equal(1); + + // Trigger cursor event with 'up' + instance.emit('cursor', '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.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); + }); + + 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, + }); + + // 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); + }; + + // Call the test filter with an input + const sampleOption = testOptions[0]; // 'apple' + const result = testFilter('ap', sampleOption); + + // The filter should match 'apple' with 'ap' + expect(result).to.equal(true); + + // Should not match with a non-existing substring + const noMatch = testFilter('z', sampleOption); + expect(noMatch).to.equal(false); + }); +}); From 9b1ebc5153022c3e57f50bb0c848268ca1085be3 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:24:49 +0100 Subject: [PATCH 06/14] core rework/simplification (#308) --- packages/core/src/prompts/autocomplete.ts | 249 +++++++----- packages/core/src/prompts/password.ts | 28 +- packages/core/src/prompts/prompt.ts | 67 ++-- packages/core/src/types.ts | 3 +- .../core/test/prompts/autocomplete.test.ts | 57 +-- packages/core/test/prompts/prompt.test.ts | 4 +- packages/prompts/src/autocomplete.ts | 377 ++++-------------- packages/prompts/test/autocomplete.test.ts | 55 +-- 8 files changed, 308 insertions(+), 532 deletions(-) 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([]); }); }); From e3ef952c646fa8b758fbc6078366d44265ff8bc2 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Mon, 28 Apr 2025 19:31:28 -0600 Subject: [PATCH 07/14] Apply suggestions from @43081j & @MacFJA's review --- packages/core/src/prompts/autocomplete.ts | 16 ++++---- packages/prompts/src/autocomplete.ts | 45 ++++++++++++++--------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 031d5733..77346728 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -58,7 +58,7 @@ export default class AutocompletePrompt extends Prompt { isNavigating = false; selectedValues: Array = []; - #focusedValue: T['value'] | undefined; + focusedValue: T['value'] | undefined; #cursor = 0; #lastValue: T['value'] | undefined; #filterFn: FilterFunction; @@ -103,7 +103,7 @@ export default class AutocompletePrompt extends Prompt { if (selectedIndex !== -1) { this.toggleSelected(selectedValue); this.#cursor = selectedIndex; - this.#focusedValue = this.options[this.#cursor]?.value; + this.focusedValue = this.options[this.#cursor]?.value; } } } @@ -143,18 +143,18 @@ export default class AutocompletePrompt extends Prompt { 0, Math.min(this.#cursor + (isUpKey ? -1 : 1), this.filteredOptions.length - 1) ); - this.#focusedValue = this.filteredOptions[this.#cursor]?.value; + this.focusedValue = this.filteredOptions[this.#cursor]?.value; if (!this.multiple) { - this.selectedValues = [this.#focusedValue]; + this.selectedValues = [this.focusedValue]; } this.isNavigating = true; } else { if ( this.multiple && - this.#focusedValue !== undefined && + this.focusedValue !== undefined && (key.name === 'tab' || (this.isNavigating && key.name === 'space')) ) { - this.toggleSelected(this.#focusedValue); + this.toggleSelected(this.focusedValue); } else { this.isNavigating = false; } @@ -186,8 +186,8 @@ export default class AutocompletePrompt extends Prompt { } else { this.filteredOptions = [...this.options]; } - this.#cursor = getCursorForValue(this.#focusedValue, this.filteredOptions); - this.#focusedValue = this.filteredOptions[this.#cursor]?.value; + this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions); + this.focusedValue = this.filteredOptions[this.#cursor]?.value; } } } diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index c95c9c2c..7e61462e 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -65,11 +65,11 @@ export interface AutocompleteOptions extends CommonOptions { } export const autocomplete = (opts: AutocompleteOptions) => { - const prompt = new AutocompletePrompt({ + const prompt = new AutocompletePrompt>({ options: opts.options, placeholder: opts.placeholder, - initialValue: opts.initialValue ? [opts.initialValue] : undefined, - filter: (search, opt) => { + initialValue: opts.initialValue ? [{ value: opts.initialValue }] : undefined, + filter: (search: string, opt: Option) => { return getFilteredOption(search, opt); }, input: opts.input, @@ -83,7 +83,10 @@ export const autocomplete = (opts: AutocompleteOptions) => { switch (this.state) { case 'submit': { // Show selected value - const selected = getSelectedOptions(this.selectedValues, this.options); + const selected = getSelectedOptions( + this.selectedValues.map((v) => v), + this.options + ); const label = selected.length > 0 ? selected.map(getLabel).join(', ') : ''; return `${title}${color.gray(S_BAR)} ${color.dim(label)}`; } @@ -113,7 +116,10 @@ export const autocomplete = (opts: AutocompleteOptions) => { options: this.filteredOptions, style: (option, active) => { const label = getLabel(option); - const hint = option.hint ? color.dim(` (${option.hint})`) : ''; + const hint = + option.hint && option.value === this.focusedValue + ? color.dim(` (${option.hint})`) + : ''; return active ? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}` @@ -190,18 +196,6 @@ export interface AutocompleteMultiSelectOptions { * 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[]) => { - const isSelected = selectedValues.includes(option.value); - const label = option.label ?? String(option.value ?? ''); - const hint = option.hint ? color.dim(` (${option.hint})`) : ''; - const checkbox = isSelected ? color.green(S_CHECKBOX_SELECTED) : color.dim(S_CHECKBOX_INACTIVE); - - if (active) { - return `${color.green('›')} ${checkbox} ${label}${hint}`; - } - return `${color.dim(' ')} ${checkbox} ${color.dim(label)}${hint}`; - }; - // Create text prompt which we'll use as foundation const prompt = new AutocompletePrompt>({ options: opts.options, @@ -214,6 +208,23 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti input: opts.input, output: opts.output, render() { + 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 && this.focusedValue !== undefined && option.value === this.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)}`; + }; + // Title and symbol const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; From 4eebe3cef7eade2d10a2a1db12d8630e72c03f7d Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 29 Apr 2025 11:25:13 -0600 Subject: [PATCH 08/14] Apply suggestions from @43081j's review --- packages/prompts/src/autocomplete.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 7e61462e..1ae56d8d 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -65,10 +65,10 @@ export interface AutocompleteOptions extends CommonOptions { } export const autocomplete = (opts: AutocompleteOptions) => { - const prompt = new AutocompletePrompt>({ + const prompt = new AutocompletePrompt({ options: opts.options, placeholder: opts.placeholder, - initialValue: opts.initialValue ? [{ value: opts.initialValue }] : undefined, + initialValue: opts.initialValue ? [opts.initialValue] : undefined, filter: (search: string, opt: Option) => { return getFilteredOption(search, opt); }, @@ -83,10 +83,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { switch (this.state) { case 'submit': { // Show selected value - const selected = getSelectedOptions( - this.selectedValues.map((v) => v), - this.options - ); + 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)}`; } @@ -208,11 +205,11 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti input: opts.input, output: opts.output, render() { - const formatOption = (option: Option, active: boolean, selectedValues: Value[]) => { + 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 && this.focusedValue !== undefined && option.value === this.focusedValue + option.hint && focusedValue !== undefined && option.value === focusedValue ? color.dim(` (${option.hint})`) : ''; const checkbox = isSelected @@ -274,7 +271,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const displayOptions = limitOptions({ cursor: this.cursor, options: this.filteredOptions, - style: (option, active) => formatOption(option, active, this.selectedValues), + style: (option, active) => formatOption(option, active, this.selectedValues, this.focusedValue), maxItems: opts.maxItems, output: opts.output, }); From 3a16bcbbfe61185cd9c302e7a42f9dad9d9a76e2 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:29:20 -0600 Subject: [PATCH 09/14] tests: refactor autocomplete prompt tests to avoid core functionality duplication --- .../__snapshots__/autocomplete.test.ts.snap | 201 ++++++++++++++++++ packages/prompts/test/autocomplete.test.ts | 142 ++++++------- 2 files changed, 266 insertions(+), 77 deletions(-) create mode 100644 packages/prompts/test/__snapshots__/autocomplete.test.ts.snap 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..b7864360 --- /dev/null +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -0,0 +1,201 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ 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 placeholder when provided 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ● Apple +│ ○ Banana +│ ○ Cherry +│ ○ Grape +│ ○ Orange +│ ↑/↓ 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 index 8a0ca42c..497d5ac5 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -1,10 +1,8 @@ -import { AutocompletePrompt } from '@clack/core'; -import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { MockReadable } from './test-utils.js'; -import { MockWritable } from './test-utils.js'; +import { MockReadable, MockWritable } from './test-utils.js'; +import { autocomplete } from '../src/autocomplete.js'; -describe('AutocompletePrompt', () => { +describe('autocomplete', () => { let input: MockReadable; let output: MockWritable; const testOptions = [ @@ -24,116 +22,106 @@ describe('AutocompletePrompt', () => { vi.restoreAllMocks(); }); - test('renders render() result', () => { - const instance = new AutocompletePrompt({ + test('renders initial UI with message and instructions', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, input, output, - render: () => 'foo', - options: testOptions, }); - instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + + expect(output.buffer).toMatchSnapshot(); }); - test('initial options match provided options', () => { - const instance = new AutocompletePrompt({ + test('shows placeholder when provided', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, + placeholder: 'Type to search...', 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); + expect(output.buffer).toMatchSnapshot(); }); - test('cursor navigation with event emitter', () => { - const instance = new AutocompletePrompt({ + test('limits displayed options when maxItems is set', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, + maxItems: 2, 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); + expect(output.buffer).toMatchSnapshot(); }); - test('initialValue selects correct option', () => { - const instance = new AutocompletePrompt({ + test('shows no matches message when search has no results', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, 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); + // Type something that won't match + input.emit('keypress', 'z', { name: 'z' }); - // The selectedValue should be cherry - expect(instance.selectedValues).to.deep.equal(['cherry']); + expect(output.buffer).toMatchSnapshot(); }); - test('filtering through value event', () => { - const instance = new AutocompletePrompt({ + 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, - render: () => 'foo', - options: testOptions, }); - instance.prompt(); + // 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' }); - // Initial state should have all options - expect(instance.filteredOptions.length).to.equal(testOptions.length); + expect(output.buffer).toMatchSnapshot(); + }); - // Simulate typing 'a' by emitting value event - instance.emit('value', 'a'); + test('shows selected value in submit state', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); - // Check that filtered options are updated to include options with 'a' - expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length); + // Select an option and submit + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'return' }); - // Check that 'apple' is in the filtered options - const hasApple = instance.filteredOptions.some((opt) => opt.value === 'apple'); - expect(hasApple).to.equal(true); + const value = await result; + expect(value).toBe('banana'); + expect(output.buffer).toMatchSnapshot(); }); - test('default filter function works correctly', () => { - const instance = new AutocompletePrompt({ + test('shows strikethrough in cancel state', async () => { + const result = autocomplete({ + message: 'Select a fruit', + options: testOptions, 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'); + // Cancel with Ctrl+C + input.emit('keypress', '\x03', { name: 'c' }); - expect(instance.filteredOptions).toEqual([]); + const value = await result; + expect(typeof value === 'symbol').toBe(true); + expect(output.buffer).toMatchSnapshot(); }); }); From 6f11483df7ad7fb99c8041c5926d85e0ae69ec73 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:30:52 -0600 Subject: [PATCH 10/14] format --- packages/prompts/src/autocomplete.ts | 10 ++++++++-- packages/prompts/test/autocomplete.test.ts | 7 ++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 1ae56d8d..67e6fe78 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -205,7 +205,12 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti input: opts.input, output: opts.output, render() { - const formatOption = (option: Option, active: boolean, selectedValues: Value[], focusedValue: Value | undefined) => { + 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 = @@ -271,7 +276,8 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const displayOptions = limitOptions({ cursor: this.cursor, options: this.filteredOptions, - style: (option, active) => formatOption(option, active, this.selectedValues, this.focusedValue), + style: (option, active) => + formatOption(option, active, this.selectedValues, this.focusedValue), maxItems: opts.maxItems, output: opts.output, }); diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index 497d5ac5..0096940d 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { MockReadable, MockWritable } from './test-utils.js'; import { autocomplete } from '../src/autocomplete.js'; +import { MockReadable, MockWritable } from './test-utils.js'; describe('autocomplete', () => { let input: MockReadable; @@ -74,10 +74,7 @@ describe('autocomplete', () => { 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' }, - ], + options: [...testOptions, { value: 'kiwi', label: 'Kiwi', hint: 'New Zealand' }], input, output, }); From a65db407171b5d7e8d830a671e0270dd15aba62e Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 1 May 2025 11:23:48 +0100 Subject: [PATCH 11/14] fix: handle undefined values in text prompts --- packages/core/src/prompts/text.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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() { From 0c034ff02d5167c2af545b35012c5e93bc55c3f7 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 1 May 2025 09:24:08 -0600 Subject: [PATCH 12/14] Apply suggestions from @43081j's review --- packages/core/src/prompts/prompt.ts | 1 - packages/prompts/src/autocomplete.ts | 44 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index bd85f3ef..7e58b866 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -231,7 +231,6 @@ export default class Prompt { } } - // Only process as cancel if the key wasn't already handled if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { this.state = 'cancel'; } diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 67e6fe78..07fd5702 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -193,6 +193,28 @@ export interface AutocompleteMultiSelectOptions { * 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, @@ -205,28 +227,6 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti input: opts.input, output: opts.output, render() { - 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)}`; - }; - // Title and symbol const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; From 2dfbdf83d49b90eb5c87c0c355e2fb021676b665 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 2 May 2025 17:54:06 -0600 Subject: [PATCH 13/14] Apply suggestions from @43081j's review --- .../__snapshots__/autocomplete.test.ts.snap | 30 ++++------------- packages/prompts/test/autocomplete.test.ts | 33 +++++++++---------- 2 files changed, 23 insertions(+), 40 deletions(-) diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index b7864360..2de58577 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -4,14 +4,15 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` [ "", "│ -◆ Select a fruit +◆ Select an option │ Search: _ -│ ● Apple -│ ○ Banana -│ ○ Cherry -│ ○ Grape -│ ○ Orange +│ ● Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ○ Option 3 +│ ○ Option 4 +│  ... │ ↑/↓ to select • Enter: confirm • Type: to search └", ] @@ -122,23 +123,6 @@ exports[`autocomplete > shows no matches message when search has no results 1`] ] `; -exports[`autocomplete > shows placeholder when provided 1`] = ` -[ - "", - "│ -◆ Select a fruit - -│ Search: _ -│ ● Apple -│ ○ Banana -│ ○ Cherry -│ ○ Grape -│ ○ Orange -│ ↑/↓ to select • Enter: confirm • Type: to search -└", -] -`; - exports[`autocomplete > shows selected value in submit state 1`] = ` [ "", diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index 0096940d..01ff7c73 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -31,30 +31,27 @@ describe('autocomplete', () => { }); expect(output.buffer).toMatchSnapshot(); - }); - - test('shows placeholder when provided', async () => { - const result = autocomplete({ - message: 'Select a fruit', - options: testOptions, - placeholder: 'Type to search...', - 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 a fruit', - options: testOptions, - maxItems: 2, + 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 () => { @@ -67,8 +64,9 @@ describe('autocomplete', () => { // 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 () => { @@ -85,8 +83,9 @@ describe('autocomplete', () => { 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 () => { From 984a92d23f841c9b00cacc57b07935f8798cd4bc Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 6 May 2025 10:52:44 -0600 Subject: [PATCH 14/14] remove: overflowFormat param from LimitOptionsParams interface --- packages/prompts/src/limit-options.ts | 7 +- packages/prompts/src/multi-select.ts | 1 - packages/prompts/src/select.ts | 1 - .../__snapshots__/autocomplete.test.ts.snap | 2 +- .../__snapshots__/multi-select.test.ts.snap | 84 +++++++++---------- 5 files changed, 44 insertions(+), 51 deletions(-) diff --git a/packages/prompts/src/limit-options.ts b/packages/prompts/src/limit-options.ts index 9a3a3b09..f1c317a1 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -8,18 +8,13 @@ export interface LimitOptionsParams extends CommonOptions { maxItems: number | undefined; cursor: number; style: (option: TOption, active: boolean) => string; - /** - * Custom format for overflow indicators. - * Defaults to ' ...' if not provided. - */ - overflowFormat?: string; } export const limitOptions = (params: LimitOptionsParams): string[] => { 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 = params.overflowFormat ?? color.dim(' ...'); + const overflowFormat = color.dim('...'); const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY; const outputMaxItems = Math.max(rows - 4, 0); diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 7c9640e9..a93420f8 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -120,7 +120,6 @@ export const multiselect = (opts: MultiSelectOptions) => { options: this.options, cursor: this.cursor, maxItems: opts.maxItems, - overflowFormat: color.dim(' ...'), style: styleOption, }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index 2ca3deb0..c66ca0fc 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -96,7 +96,6 @@ export const select = (opts: SelectOptions) => { cursor: this.cursor, options: this.options, maxItems: opts.maxItems, - overflowFormat: color.dim(' ...'), style: (item, active) => opt(item, active ? 'active' : 'inactive'), }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 2de58577..7d33b655 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -12,7 +12,7 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` │ ○ Option 2 │ ○ Option 3 │ ○ Option 4 -│  ... +│ ... │ ↑/↓ to select • Enter: confirm • Type: to search └", ] diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap index acec91e1..01268cf2 100644 --- a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap @@ -145,7 +145,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -156,7 +156,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -166,7 +166,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -175,18 +175,18 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", "", "", - "│  ... + "│ ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│  ... +│ ... └ ", "", @@ -196,7 +196,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│  ... +│ ... └ ", "", @@ -206,7 +206,7 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│  ... +│ ... └ ", "", @@ -419,7 +419,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -430,7 +430,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -440,7 +440,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -449,18 +449,18 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", "", "", - "│  ... + "│ ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│  ... +│ ... └ ", "", @@ -470,7 +470,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│  ... +│ ... └ ", "", @@ -480,7 +480,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│  ... +│ ... └ ", "", @@ -490,7 +490,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt6 │ ◻ opt7 │ ◻ opt8 -│  ... +│ ... └ ", "", @@ -500,7 +500,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 -│  ... +│ ... └ ", "", @@ -536,7 +536,7 @@ exports[`multiselect (isCI = false) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -565,13 +565,13 @@ exports[`multiselect (isCI = false) > sliding window loops upwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", "", "", - "│  ... + "│ ... │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 @@ -740,7 +740,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -751,7 +751,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -761,7 +761,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -770,18 +770,18 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", "", "", - "│  ... + "│ ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│  ... +│ ... └ ", "", @@ -791,7 +791,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│  ... +│ ... └ ", "", @@ -801,7 +801,7 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│  ... +│ ... └ ", "", @@ -1014,7 +1014,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -1025,7 +1025,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -1035,7 +1035,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -1044,18 +1044,18 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` "│ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", "", "", - "│  ... + "│ ... │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 │ ◻ opt5 -│  ... +│ ... └ ", "", @@ -1065,7 +1065,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt4 │ ◻ opt5 │ ◻ opt6 -│  ... +│ ... └ ", "", @@ -1075,7 +1075,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt5 │ ◻ opt6 │ ◻ opt7 -│  ... +│ ... └ ", "", @@ -1085,7 +1085,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt6 │ ◻ opt7 │ ◻ opt8 -│  ... +│ ... └ ", "", @@ -1095,7 +1095,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt7 │ ◻ opt8 │ ◻ opt9 -│  ... +│ ... └ ", "", @@ -1131,7 +1131,7 @@ exports[`multiselect (isCI = true) > sliding window loops downwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", @@ -1160,13 +1160,13 @@ exports[`multiselect (isCI = true) > sliding window loops upwards 1`] = ` │ ◻ opt2 │ ◻ opt3 │ ◻ opt4 -│  ... +│ ... └ ", "", "", "", - "│  ... + "│ ... │ ◻ opt7 │ ◻ opt8 │ ◻ opt9