···11+---
22+"@clack/prompts": minor
33+"@clack/core": minor
44+---
55+66+Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package.
+100
examples/basic/autocomplete-multiselect.ts
···11+import * as p from '@clack/prompts';
22+import color from 'picocolors';
33+44+/**
55+ * Example demonstrating the integrated autocomplete multiselect component
66+ * Which combines filtering and selection in a single interface
77+ */
88+99+async function main() {
1010+ console.clear();
1111+1212+ p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`);
1313+1414+ p.note(
1515+ `
1616+${color.cyan('Filter and select multiple items in a single interface:')}
1717+- ${color.yellow('Type')} to filter the list in real-time
1818+- Use ${color.yellow('up/down arrows')} to navigate with improved stability
1919+- Press ${color.yellow('Space')} to select/deselect the highlighted item ${color.green('(multiple selections allowed)')}
2020+- Use ${color.yellow('Backspace')} to modify your filter text when searching for different options
2121+- Press ${color.yellow('Enter')} when done selecting all items
2222+- Press ${color.yellow('Ctrl+C')} to cancel
2323+ `,
2424+ 'Instructions'
2525+ );
2626+2727+ // Frameworks in alphabetical order
2828+ const frameworks = [
2929+ { value: 'angular', label: 'Angular', hint: 'Frontend/UI' },
3030+ { value: 'django', label: 'Django', hint: 'Python Backend' },
3131+ { value: 'dotnet', label: '.NET Core', hint: 'C# Backend' },
3232+ { value: 'electron', label: 'Electron', hint: 'Desktop' },
3333+ { value: 'express', label: 'Express', hint: 'Node.js Backend' },
3434+ { value: 'flask', label: 'Flask', hint: 'Python Backend' },
3535+ { value: 'flutter', label: 'Flutter', hint: 'Mobile' },
3636+ { value: 'laravel', label: 'Laravel', hint: 'PHP Backend' },
3737+ { value: 'nestjs', label: 'NestJS', hint: 'Node.js Backend' },
3838+ { value: 'nextjs', label: 'Next.js', hint: 'React Framework' },
3939+ { value: 'nuxt', label: 'Nuxt.js', hint: 'Vue Framework' },
4040+ { value: 'rails', label: 'Ruby on Rails', hint: 'Ruby Backend' },
4141+ { value: 'react', label: 'React', hint: 'Frontend/UI' },
4242+ { value: 'reactnative', label: 'React Native', hint: 'Mobile' },
4343+ { value: 'spring', label: 'Spring Boot', hint: 'Java Backend' },
4444+ { value: 'svelte', label: 'Svelte', hint: 'Frontend/UI' },
4545+ { value: 'tauri', label: 'Tauri', hint: 'Desktop' },
4646+ { value: 'vue', label: 'Vue.js', hint: 'Frontend/UI' },
4747+ ];
4848+4949+ // Use the new integrated autocompleteMultiselect component
5050+ const result = await p.autocompleteMultiselect<string>({
5151+ message: 'Select frameworks (type to filter)',
5252+ options: frameworks,
5353+ placeholder: 'Type to filter...',
5454+ maxItems: 8,
5555+ });
5656+5757+ if (p.isCancel(result)) {
5858+ p.cancel('Operation cancelled.');
5959+ process.exit(0);
6060+ }
6161+6262+ // Type guard: if not a cancel symbol, result must be a string array
6363+ function isStringArray(value: unknown): value is string[] {
6464+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
6565+ }
6666+6767+ // We can now use the type guard to ensure type safety
6868+ if (!isStringArray(result)) {
6969+ throw new Error('Unexpected result type');
7070+ }
7171+7272+ const selectedFrameworks = result;
7373+7474+ // If no items selected, show a message
7575+ if (selectedFrameworks.length === 0) {
7676+ p.note('No frameworks were selected', 'Empty Selection');
7777+ process.exit(0);
7878+ }
7979+8080+ // Display selected frameworks with detailed information
8181+ p.note(
8282+ `You selected ${color.green(selectedFrameworks.length)} frameworks:`,
8383+ 'Selection Complete'
8484+ );
8585+8686+ // Show each selected framework with its details
8787+ const selectedDetails = selectedFrameworks
8888+ .map((value) => {
8989+ const framework = frameworks.find((f) => f.value === value);
9090+ return framework
9191+ ? `${color.cyan(framework.label)} ${color.dim(`- ${framework.hint}`)}`
9292+ : value;
9393+ })
9494+ .join('\n');
9595+9696+ p.log.message(selectedDetails);
9797+ p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`);
9898+}
9999+100100+main().catch(console.error);
···99export { default as SelectPrompt } from './prompts/select.js';
1010export { default as SelectKeyPrompt } from './prompts/select-key.js';
1111export { default as TextPrompt } from './prompts/text.js';
1212+export { default as AutocompletePrompt } from './prompts/autocomplete.js';
1213export { block, isCancel, getColumns } from './utils/index.js';
1314export { updateSettings, settings } from './utils/settings.js';
···11+import { AutocompletePrompt } from '@clack/core';
22+import color from 'picocolors';
33+import {
44+ type CommonOptions,
55+ S_BAR,
66+ S_BAR_END,
77+ S_CHECKBOX_INACTIVE,
88+ S_CHECKBOX_SELECTED,
99+ S_RADIO_ACTIVE,
1010+ S_RADIO_INACTIVE,
1111+ symbol,
1212+} from './common.js';
1313+import { limitOptions } from './limit-options.js';
1414+import type { Option } from './select.js';
1515+1616+function getLabel<T>(option: Option<T>) {
1717+ return option.label ?? String(option.value ?? '');
1818+}
1919+2020+function getFilteredOption<T>(searchText: string, option: Option<T>): boolean {
2121+ if (!searchText) {
2222+ return true;
2323+ }
2424+ const label = (option.label ?? String(option.value ?? '')).toLowerCase();
2525+ const hint = (option.hint ?? '').toLowerCase();
2626+ const value = String(option.value).toLowerCase();
2727+ const term = searchText.toLowerCase();
2828+2929+ return label.includes(term) || hint.includes(term) || value.includes(term);
3030+}
3131+3232+function getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] {
3333+ const results: Option<T>[] = [];
3434+3535+ for (const option of options) {
3636+ if (values.includes(option.value)) {
3737+ results.push(option);
3838+ }
3939+ }
4040+4141+ return results;
4242+}
4343+4444+export interface AutocompleteOptions<Value> extends CommonOptions {
4545+ /**
4646+ * The message to display to the user.
4747+ */
4848+ message: string;
4949+ /**
5050+ * Available options for the autocomplete prompt.
5151+ */
5252+ options: Option<Value>[];
5353+ /**
5454+ * The initial selected value.
5555+ */
5656+ initialValue?: Value;
5757+ /**
5858+ * Maximum number of items to display at once.
5959+ */
6060+ maxItems?: number;
6161+ /**
6262+ * Placeholder text to display when no input is provided.
6363+ */
6464+ placeholder?: string;
6565+}
6666+6767+export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
6868+ const prompt = new AutocompletePrompt({
6969+ options: opts.options,
7070+ placeholder: opts.placeholder,
7171+ initialValue: opts.initialValue ? [opts.initialValue] : undefined,
7272+ filter: (search: string, opt: Option<Value>) => {
7373+ return getFilteredOption(search, opt);
7474+ },
7575+ input: opts.input,
7676+ output: opts.output,
7777+ render() {
7878+ // Title and message display
7979+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
8080+ const valueAsString = String(this.value ?? '');
8181+8282+ // Handle different states
8383+ switch (this.state) {
8484+ case 'submit': {
8585+ // Show selected value
8686+ const selected = getSelectedOptions(this.selectedValues, this.options);
8787+ const label = selected.length > 0 ? selected.map(getLabel).join(', ') : '';
8888+ return `${title}${color.gray(S_BAR)} ${color.dim(label)}`;
8989+ }
9090+9191+ case 'cancel': {
9292+ return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.value ?? ''))}`;
9393+ }
9494+9595+ default: {
9696+ // Display cursor position - show plain text in navigation mode
9797+ const searchText = this.isNavigating ? color.dim(valueAsString) : this.valueWithCursor;
9898+9999+ // Show match count if filtered
100100+ const matches =
101101+ this.filteredOptions.length !== this.options.length
102102+ ? color.dim(
103103+ ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
104104+ )
105105+ : '';
106106+107107+ // Render options with selection
108108+ const displayOptions =
109109+ this.filteredOptions.length === 0
110110+ ? []
111111+ : limitOptions({
112112+ cursor: this.cursor,
113113+ options: this.filteredOptions,
114114+ style: (option, active) => {
115115+ const label = getLabel(option);
116116+ const hint =
117117+ option.hint && option.value === this.focusedValue
118118+ ? color.dim(` (${option.hint})`)
119119+ : '';
120120+121121+ return active
122122+ ? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`
123123+ : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`;
124124+ },
125125+ maxItems: opts.maxItems,
126126+ output: opts.output,
127127+ });
128128+129129+ // Show instructions
130130+ const instructions = [
131131+ `${color.dim('↑/↓')} to select`,
132132+ `${color.dim('Enter:')} confirm`,
133133+ `${color.dim('Type:')} to search`,
134134+ ];
135135+136136+ // No matches message
137137+ const noResults =
138138+ this.filteredOptions.length === 0 && valueAsString
139139+ ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
140140+ : [];
141141+142142+ // Return the formatted prompt
143143+ return [
144144+ title,
145145+ `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
146146+ ...noResults,
147147+ ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
148148+ `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
149149+ `${color.cyan(S_BAR_END)}`,
150150+ ].join('\n');
151151+ }
152152+ }
153153+ },
154154+ });
155155+156156+ // Return the result or cancel symbol
157157+ return prompt.prompt() as Promise<Value | symbol>;
158158+};
159159+160160+// Type definition for the autocompleteMultiselect component
161161+export interface AutocompleteMultiSelectOptions<Value> {
162162+ /**
163163+ * The message to display to the user
164164+ */
165165+ message: string;
166166+ /**
167167+ * The options for the user to choose from
168168+ */
169169+ options: Option<Value>[];
170170+ /**
171171+ * The initial selected values
172172+ */
173173+ initialValues?: Value[];
174174+ /**
175175+ * The maximum number of items that can be selected
176176+ */
177177+ maxItems?: number;
178178+ /**
179179+ * The placeholder to display in the input
180180+ */
181181+ placeholder?: string;
182182+ /**
183183+ * The stream to read from
184184+ */
185185+ input?: NodeJS.ReadStream;
186186+ /**
187187+ * The stream to write to
188188+ */
189189+ output?: NodeJS.WriteStream;
190190+}
191191+192192+/**
193193+ * Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI
194194+ */
195195+export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOptions<Value>) => {
196196+ const formatOption = (
197197+ option: Option<Value>,
198198+ active: boolean,
199199+ selectedValues: Value[],
200200+ focusedValue: Value | undefined
201201+ ) => {
202202+ const isSelected = selectedValues.includes(option.value);
203203+ const label = option.label ?? String(option.value ?? '');
204204+ const hint =
205205+ option.hint && focusedValue !== undefined && option.value === focusedValue
206206+ ? color.dim(` (${option.hint})`)
207207+ : '';
208208+ const checkbox = isSelected
209209+ ? color.green(S_CHECKBOX_SELECTED)
210210+ : color.dim(S_CHECKBOX_INACTIVE);
211211+212212+ if (active) {
213213+ return `${checkbox} ${label}${hint}`;
214214+ }
215215+ return `${checkbox} ${color.dim(label)}`;
216216+ };
217217+218218+ // Create text prompt which we'll use as foundation
219219+ const prompt = new AutocompletePrompt<Option<Value>>({
220220+ options: opts.options,
221221+ multiple: true,
222222+ filter: (search, opt) => {
223223+ return getFilteredOption(search, opt);
224224+ },
225225+ placeholder: opts.placeholder,
226226+ initialValue: opts.initialValues,
227227+ input: opts.input,
228228+ output: opts.output,
229229+ render() {
230230+ // Title and symbol
231231+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
232232+233233+ // Selection counter
234234+ const counter =
235235+ this.selectedValues.length > 0
236236+ ? color.cyan(` (${this.selectedValues.length} selected)`)
237237+ : '';
238238+ const value = String(this.value ?? '');
239239+240240+ // Search input display
241241+ const searchText = this.isNavigating
242242+ ? color.dim(value) // Just show plain text when in navigation mode
243243+ : this.valueWithCursor;
244244+245245+ const matches =
246246+ this.filteredOptions.length !== opts.options.length
247247+ ? color.dim(
248248+ ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
249249+ )
250250+ : '';
251251+252252+ // Render prompt state
253253+ switch (this.state) {
254254+ case 'submit': {
255255+ return `${title}${color.gray(S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`;
256256+ }
257257+ case 'cancel': {
258258+ return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(value))}`;
259259+ }
260260+ default: {
261261+ // Instructions
262262+ const instructions = [
263263+ `${color.dim('↑/↓')} to navigate`,
264264+ `${color.dim('Space:')} select`,
265265+ `${color.dim('Enter:')} confirm`,
266266+ `${color.dim('Type:')} to search`,
267267+ ];
268268+269269+ // No results message
270270+ const noResults =
271271+ this.filteredOptions.length === 0 && value
272272+ ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
273273+ : [];
274274+275275+ // Get limited options for display
276276+ const displayOptions = limitOptions({
277277+ cursor: this.cursor,
278278+ options: this.filteredOptions,
279279+ style: (option, active) =>
280280+ formatOption(option, active, this.selectedValues, this.focusedValue),
281281+ maxItems: opts.maxItems,
282282+ output: opts.output,
283283+ });
284284+285285+ // Build the prompt display
286286+ return [
287287+ title,
288288+ `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
289289+ ...noResults,
290290+ ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
291291+ `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
292292+ `${color.cyan(S_BAR_END)}`,
293293+ ].join('\n');
294294+ }
295295+ }
296296+ },
297297+ });
298298+299299+ // Return the result or cancel symbol
300300+ return prompt.prompt() as Promise<Value | symbol>;
301301+};
+1
packages/prompts/src/index.ts
···11export { isCancel, updateSettings, settings, type ClackSettings } from '@clack/core';
2233+export * from './autocomplete.js';
34export * from './common.js';
45export * from './confirm.js';
56export * from './group-multi-select.js';