[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

feat(core + prompts): adds autocomplete (#288)

Co-authored-by: James Garbutt <43081j@users.noreply.github.com>

authored by

Paul Valladares
James Garbutt
and committed by
GitHub
(May 6, 2025, 12:09 PM -0600) f2c2b892 2048eb1c

+1163 -45
+6
.changeset/nasty-parrots-laugh.md
··· 1 + --- 2 + "@clack/prompts": minor 3 + "@clack/core": minor 4 + --- 5 + 6 + Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package.
+100
examples/basic/autocomplete-multiselect.ts
··· 1 + import * as p from '@clack/prompts'; 2 + import color from 'picocolors'; 3 + 4 + /** 5 + * Example demonstrating the integrated autocomplete multiselect component 6 + * Which combines filtering and selection in a single interface 7 + */ 8 + 9 + async function main() { 10 + console.clear(); 11 + 12 + p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`); 13 + 14 + p.note( 15 + ` 16 + ${color.cyan('Filter and select multiple items in a single interface:')} 17 + - ${color.yellow('Type')} to filter the list in real-time 18 + - Use ${color.yellow('up/down arrows')} to navigate with improved stability 19 + - Press ${color.yellow('Space')} to select/deselect the highlighted item ${color.green('(multiple selections allowed)')} 20 + - Use ${color.yellow('Backspace')} to modify your filter text when searching for different options 21 + - Press ${color.yellow('Enter')} when done selecting all items 22 + - Press ${color.yellow('Ctrl+C')} to cancel 23 + `, 24 + 'Instructions' 25 + ); 26 + 27 + // Frameworks in alphabetical order 28 + const frameworks = [ 29 + { value: 'angular', label: 'Angular', hint: 'Frontend/UI' }, 30 + { value: 'django', label: 'Django', hint: 'Python Backend' }, 31 + { value: 'dotnet', label: '.NET Core', hint: 'C# Backend' }, 32 + { value: 'electron', label: 'Electron', hint: 'Desktop' }, 33 + { value: 'express', label: 'Express', hint: 'Node.js Backend' }, 34 + { value: 'flask', label: 'Flask', hint: 'Python Backend' }, 35 + { value: 'flutter', label: 'Flutter', hint: 'Mobile' }, 36 + { value: 'laravel', label: 'Laravel', hint: 'PHP Backend' }, 37 + { value: 'nestjs', label: 'NestJS', hint: 'Node.js Backend' }, 38 + { value: 'nextjs', label: 'Next.js', hint: 'React Framework' }, 39 + { value: 'nuxt', label: 'Nuxt.js', hint: 'Vue Framework' }, 40 + { value: 'rails', label: 'Ruby on Rails', hint: 'Ruby Backend' }, 41 + { value: 'react', label: 'React', hint: 'Frontend/UI' }, 42 + { value: 'reactnative', label: 'React Native', hint: 'Mobile' }, 43 + { value: 'spring', label: 'Spring Boot', hint: 'Java Backend' }, 44 + { value: 'svelte', label: 'Svelte', hint: 'Frontend/UI' }, 45 + { value: 'tauri', label: 'Tauri', hint: 'Desktop' }, 46 + { value: 'vue', label: 'Vue.js', hint: 'Frontend/UI' }, 47 + ]; 48 + 49 + // Use the new integrated autocompleteMultiselect component 50 + const result = await p.autocompleteMultiselect<string>({ 51 + message: 'Select frameworks (type to filter)', 52 + options: frameworks, 53 + placeholder: 'Type to filter...', 54 + maxItems: 8, 55 + }); 56 + 57 + if (p.isCancel(result)) { 58 + p.cancel('Operation cancelled.'); 59 + process.exit(0); 60 + } 61 + 62 + // Type guard: if not a cancel symbol, result must be a string array 63 + function isStringArray(value: unknown): value is string[] { 64 + return Array.isArray(value) && value.every((item) => typeof item === 'string'); 65 + } 66 + 67 + // We can now use the type guard to ensure type safety 68 + if (!isStringArray(result)) { 69 + throw new Error('Unexpected result type'); 70 + } 71 + 72 + const selectedFrameworks = result; 73 + 74 + // If no items selected, show a message 75 + if (selectedFrameworks.length === 0) { 76 + p.note('No frameworks were selected', 'Empty Selection'); 77 + process.exit(0); 78 + } 79 + 80 + // Display selected frameworks with detailed information 81 + p.note( 82 + `You selected ${color.green(selectedFrameworks.length)} frameworks:`, 83 + 'Selection Complete' 84 + ); 85 + 86 + // Show each selected framework with its details 87 + const selectedDetails = selectedFrameworks 88 + .map((value) => { 89 + const framework = frameworks.find((f) => f.value === value); 90 + return framework 91 + ? `${color.cyan(framework.label)} ${color.dim(`- ${framework.hint}`)}` 92 + : value; 93 + }) 94 + .join('\n'); 95 + 96 + p.log.message(selectedDetails); 97 + p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`); 98 + } 99 + 100 + main().catch(console.error);
+59
examples/basic/autocomplete.ts
··· 1 + import * as p from '@clack/prompts'; 2 + import color from 'picocolors'; 3 + 4 + async function main() { 5 + console.clear(); 6 + 7 + p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`); 8 + 9 + p.note( 10 + ` 11 + ${color.cyan('This example demonstrates the type-ahead autocomplete feature:')} 12 + - ${color.yellow('Type')} to filter the list in real-time 13 + - Use ${color.yellow('up/down arrows')} to navigate the filtered results 14 + - Press ${color.yellow('Enter')} to select the highlighted option 15 + - Press ${color.yellow('Ctrl+C')} to cancel 16 + `, 17 + 'Instructions' 18 + ); 19 + 20 + const countries = [ 21 + { value: 'us', label: 'United States', hint: 'NA' }, 22 + { value: 'ca', label: 'Canada', hint: 'NA' }, 23 + { value: 'mx', label: 'Mexico', hint: 'NA' }, 24 + { value: 'br', label: 'Brazil', hint: 'SA' }, 25 + { value: 'ar', label: 'Argentina', hint: 'SA' }, 26 + { value: 'uk', label: 'United Kingdom', hint: 'EU' }, 27 + { value: 'fr', label: 'France', hint: 'EU' }, 28 + { value: 'de', label: 'Germany', hint: 'EU' }, 29 + { value: 'it', label: 'Italy', hint: 'EU' }, 30 + { value: 'es', label: 'Spain', hint: 'EU' }, 31 + { value: 'pt', label: 'Portugal', hint: 'EU' }, 32 + { value: 'ru', label: 'Russia', hint: 'EU/AS' }, 33 + { value: 'cn', label: 'China', hint: 'AS' }, 34 + { value: 'jp', label: 'Japan', hint: 'AS' }, 35 + { value: 'in', label: 'India', hint: 'AS' }, 36 + { value: 'kr', label: 'South Korea', hint: 'AS' }, 37 + { value: 'au', label: 'Australia', hint: 'OC' }, 38 + { value: 'nz', label: 'New Zealand', hint: 'OC' }, 39 + { value: 'za', label: 'South Africa', hint: 'AF' }, 40 + { value: 'eg', label: 'Egypt', hint: 'AF' }, 41 + ]; 42 + 43 + const result = await p.autocomplete({ 44 + message: 'Select a country', 45 + options: countries, 46 + placeholder: 'Type to search countries...', 47 + maxItems: 8, 48 + }); 49 + 50 + if (p.isCancel(result)) { 51 + p.cancel('Operation cancelled.'); 52 + process.exit(0); 53 + } 54 + 55 + const selected = countries.find((c) => c.value === result); 56 + p.outro(`You selected: ${color.cyan(selected?.label)} (${color.yellow(selected?.hint)})`); 57 + } 58 + 59 + main().catch(console.error);
+1
packages/core/src/index.ts
··· 9 9 export { default as SelectPrompt } from './prompts/select.js'; 10 10 export { default as SelectKeyPrompt } from './prompts/select-key.js'; 11 11 export { default as TextPrompt } from './prompts/text.js'; 12 + export { default as AutocompletePrompt } from './prompts/autocomplete.js'; 12 13 export { block, isCancel, getColumns } from './utils/index.js'; 13 14 export { updateSettings, settings } from './utils/settings.js';
+193
packages/core/src/prompts/autocomplete.ts
··· 1 + import type { Key } from 'node:readline'; 2 + import color from 'picocolors'; 3 + import Prompt, { type PromptOptions } from './prompt.js'; 4 + 5 + interface OptionLike { 6 + value: unknown; 7 + label?: string; 8 + } 9 + 10 + type FilterFunction<T extends OptionLike> = (search: string, opt: T) => boolean; 11 + 12 + function getCursorForValue<T extends OptionLike>( 13 + selected: T['value'] | undefined, 14 + items: T[] 15 + ): number { 16 + if (selected === undefined) { 17 + return 0; 18 + } 19 + 20 + const currLength = items.length; 21 + 22 + // If filtering changed the available options, update cursor 23 + if (currLength === 0) { 24 + return 0; 25 + } 26 + 27 + // Try to maintain the same selected item 28 + const index = items.findIndex((item) => item.value === selected); 29 + return index !== -1 ? index : 0; 30 + } 31 + 32 + function defaultFilter<T extends OptionLike>(input: string, option: T): boolean { 33 + const label = option.label ?? String(option.value); 34 + return label.toLowerCase().includes(input.toLowerCase()); 35 + } 36 + 37 + function normalisedValue<T>(multiple: boolean, values: T[] | undefined): T | T[] | undefined { 38 + if (!values) { 39 + return undefined; 40 + } 41 + if (multiple) { 42 + return values; 43 + } 44 + return values[0]; 45 + } 46 + 47 + export interface AutocompleteOptions<T extends OptionLike> 48 + extends PromptOptions<AutocompletePrompt<T>> { 49 + options: T[]; 50 + filter?: FilterFunction<T>; 51 + multiple?: boolean; 52 + } 53 + 54 + export default class AutocompletePrompt<T extends OptionLike> extends Prompt { 55 + options: T[]; 56 + filteredOptions: T[]; 57 + multiple: boolean; 58 + isNavigating = false; 59 + selectedValues: Array<T['value']> = []; 60 + 61 + focusedValue: T['value'] | undefined; 62 + #cursor = 0; 63 + #lastValue: T['value'] | undefined; 64 + #filterFn: FilterFunction<T>; 65 + 66 + get cursor(): number { 67 + return this.#cursor; 68 + } 69 + 70 + get valueWithCursor() { 71 + if (!this.value) { 72 + return color.inverse(color.hidden('_')); 73 + } 74 + if (this._cursor >= this.value.length) { 75 + return `${this.value}█`; 76 + } 77 + const s1 = this.value.slice(0, this._cursor); 78 + const [s2, ...s3] = this.value.slice(this._cursor); 79 + return `${s1}${color.inverse(s2)}${s3.join('')}`; 80 + } 81 + 82 + constructor(opts: AutocompleteOptions<T>) { 83 + super(opts); 84 + 85 + this.options = opts.options; 86 + this.filteredOptions = [...this.options]; 87 + this.multiple = opts.multiple === true; 88 + this._usePlaceholderAsValue = false; 89 + this.#filterFn = opts.filter ?? defaultFilter; 90 + let initialValues: unknown[] | undefined; 91 + if (opts.initialValue && Array.isArray(opts.initialValue)) { 92 + if (this.multiple) { 93 + initialValues = opts.initialValue; 94 + } else { 95 + initialValues = opts.initialValue.slice(0, 1); 96 + } 97 + } 98 + 99 + if (initialValues) { 100 + this.selectedValues = initialValues; 101 + for (const selectedValue of initialValues) { 102 + const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue); 103 + if (selectedIndex !== -1) { 104 + this.toggleSelected(selectedValue); 105 + this.#cursor = selectedIndex; 106 + this.focusedValue = this.options[this.#cursor]?.value; 107 + } 108 + } 109 + } 110 + 111 + this.on('finalize', () => { 112 + if (!this.value) { 113 + this.value = normalisedValue(this.multiple, initialValues); 114 + } 115 + 116 + if (this.state === 'submit') { 117 + this.value = normalisedValue(this.multiple, this.selectedValues); 118 + } 119 + }); 120 + 121 + this.on('key', (char, key) => this.#onKey(char, key)); 122 + this.on('value', (value) => this.#onValueChanged(value)); 123 + } 124 + 125 + protected override _isActionKey(char: string | undefined, key: Key): boolean { 126 + return ( 127 + char === '\t' || 128 + (this.multiple && 129 + this.isNavigating && 130 + key.name === 'space' && 131 + char !== undefined && 132 + char !== '') 133 + ); 134 + } 135 + 136 + #onKey(_char: string | undefined, key: Key): void { 137 + const isUpKey = key.name === 'up'; 138 + const isDownKey = key.name === 'down'; 139 + 140 + // Start navigation mode with up/down arrows 141 + if (isUpKey || isDownKey) { 142 + this.#cursor = Math.max( 143 + 0, 144 + Math.min(this.#cursor + (isUpKey ? -1 : 1), this.filteredOptions.length - 1) 145 + ); 146 + this.focusedValue = this.filteredOptions[this.#cursor]?.value; 147 + if (!this.multiple) { 148 + this.selectedValues = [this.focusedValue]; 149 + } 150 + this.isNavigating = true; 151 + } else { 152 + if ( 153 + this.multiple && 154 + this.focusedValue !== undefined && 155 + (key.name === 'tab' || (this.isNavigating && key.name === 'space')) 156 + ) { 157 + this.toggleSelected(this.focusedValue); 158 + } else { 159 + this.isNavigating = false; 160 + } 161 + } 162 + } 163 + 164 + toggleSelected(value: T['value']) { 165 + if (this.filteredOptions.length === 0) { 166 + return; 167 + } 168 + 169 + if (this.multiple) { 170 + if (this.selectedValues.includes(value)) { 171 + this.selectedValues = this.selectedValues.filter((v) => v !== value); 172 + } else { 173 + this.selectedValues = [...this.selectedValues, value]; 174 + } 175 + } else { 176 + this.selectedValues = [value]; 177 + } 178 + } 179 + 180 + #onValueChanged(value: string | undefined): void { 181 + if (value !== this.#lastValue) { 182 + this.#lastValue = value; 183 + 184 + if (value) { 185 + this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt)); 186 + } else { 187 + this.filteredOptions = [...this.options]; 188 + } 189 + this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions); 190 + this.focusedValue = this.filteredOptions[this.#cursor]?.value; 191 + } 192 + } 193 + }
+13 -15
packages/core/src/prompts/password.ts
··· 5 5 mask?: string; 6 6 } 7 7 export default class PasswordPrompt extends Prompt { 8 - valueWithCursor = ''; 9 8 private _mask = '•'; 10 9 get cursor() { 11 10 return this._cursor; 12 11 } 13 12 get masked() { 14 - return this.value.replaceAll(/./g, this._mask); 13 + return this.value?.replaceAll(/./g, this._mask) ?? ''; 14 + } 15 + get valueWithCursor() { 16 + if (this.state === 'submit' || this.state === 'cancel') { 17 + return this.masked; 18 + } 19 + const value = this.value ?? ''; 20 + if (this.cursor >= value.length) { 21 + return `${this.masked}${color.inverse(color.hidden('_'))}`; 22 + } 23 + const s1 = this.masked.slice(0, this.cursor); 24 + const s2 = this.masked.slice(this.cursor); 25 + return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; 15 26 } 16 27 constructor({ mask, ...opts }: PasswordOptions) { 17 28 super(opts); 18 29 this._mask = mask ?? '•'; 19 - 20 - this.on('finalize', () => { 21 - this.valueWithCursor = this.masked; 22 - }); 23 - this.on('value', () => { 24 - if (this.cursor >= this.value.length) { 25 - this.valueWithCursor = `${this.masked}${color.inverse(color.hidden('_'))}`; 26 - } else { 27 - const s1 = this.masked.slice(0, this.cursor); 28 - const s2 = this.masked.slice(this.cursor); 29 - this.valueWithCursor = `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; 30 - } 31 - }); 32 30 } 33 31 }
+32 -23
packages/core/src/prompts/prompt.ts
··· 1 1 import { stdin, stdout } from 'node:process'; 2 2 import readline, { type Key, type ReadLine } from 'node:readline'; 3 3 import type { Readable } from 'node:stream'; 4 - import { Writable } from 'node:stream'; 4 + import type { Writable } from 'node:stream'; 5 5 import { cursor, erase } from 'sisteransi'; 6 6 import wrap from 'wrap-ansi'; 7 7 ··· 33 33 private _prevFrame = ''; 34 34 private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>(); 35 35 protected _cursor = 0; 36 + protected _usePlaceholderAsValue = true; 36 37 37 38 public state: ClackState = 'initial'; 38 39 public error = ''; ··· 133 134 ); 134 135 } 135 136 136 - const sink = new Writable(); 137 - sink._write = (chunk, encoding, done) => { 138 - if (this._track) { 139 - this.value = this.rl?.line.replace(/\t/g, ''); 140 - this._cursor = this.rl?.cursor ?? 0; 141 - this.emit('value', this.value); 142 - } 143 - done(); 144 - }; 145 - this.input.pipe(sink); 146 - 147 137 this.rl = readline.createInterface({ 148 138 input: this.input, 149 - output: sink, 150 139 tabSize: 2, 151 140 prompt: '', 152 141 escapeCodeTimeout: 50, 153 142 terminal: true, 154 143 }); 155 - readline.emitKeypressEvents(this.input, this.rl); 156 144 this.rl.prompt(); 157 - if (this.opts.initialValue !== undefined && this._track) { 158 - this.rl.write(this.opts.initialValue); 145 + if (this.opts.initialValue !== undefined) { 146 + if (this._track) { 147 + this.rl.write(this.opts.initialValue); 148 + } 149 + this._setValue(this.opts.initialValue); 159 150 } 160 151 161 152 this.input.on('keypress', this.onKeypress); ··· 179 170 }); 180 171 } 181 172 182 - private onKeypress(char: string, key?: Key) { 173 + protected _isActionKey(char: string | undefined, _key: Key): boolean { 174 + return char === '\t'; 175 + } 176 + 177 + protected _setValue(value: unknown): void { 178 + this.value = value; 179 + this.emit('value', this.value); 180 + } 181 + 182 + private onKeypress(char: string | undefined, key: Key) { 183 + if (this._track && key.name !== 'return') { 184 + if (key.name && this._isActionKey(char, key)) { 185 + this.rl?.write(null, { ctrl: true, name: 'h' }); 186 + } 187 + this._cursor = this.rl?.cursor ?? 0; 188 + this._setValue(this.rl?.line); 189 + } 190 + 183 191 if (this.state === 'error') { 184 192 this.state = 'active'; 185 193 } ··· 194 202 if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) { 195 203 this.emit('confirm', char.toLowerCase() === 'y'); 196 204 } 197 - if (char === '\t' && this.opts.placeholder) { 205 + if (this._usePlaceholderAsValue && char === '\t' && this.opts.placeholder) { 198 206 if (!this.value) { 199 207 this.rl?.write(this.opts.placeholder); 200 - this.emit('value', this.opts.placeholder); 208 + this._setValue(this.opts.placeholder); 201 209 } 202 210 } 203 - if (char) { 204 - this.emit('key', char.toLowerCase()); 205 - } 211 + 212 + // Call the key event handler and emit the key event 213 + this.emit('key', char?.toLowerCase(), key); 206 214 207 215 if (key?.name === 'return') { 208 216 if (!this.value && this.opts.placeholder) { 209 217 this.rl?.write(this.opts.placeholder); 210 - this.emit('value', this.opts.placeholder); 218 + this._setValue(this.opts.placeholder); 211 219 } 212 220 213 221 if (this.opts.validate) { ··· 226 234 if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { 227 235 this.state = 'cancel'; 228 236 } 237 + 229 238 if (this.state === 'submit' || this.state === 'cancel') { 230 239 this.emit('finalize'); 231 240 }
+4 -3
packages/core/src/prompts/text.ts
··· 11 11 if (this.state === 'submit') { 12 12 return this.value; 13 13 } 14 - if (this.cursor >= this.value.length) { 14 + const value = this.value ?? ''; 15 + if (this.cursor >= value.length) { 15 16 return `${this.value}█`; 16 17 } 17 - const s1 = this.value.slice(0, this.cursor); 18 - const [s2, ...s3] = this.value.slice(this.cursor); 18 + const s1 = value.slice(0, this.cursor); 19 + const [s2, ...s3] = value.slice(this.cursor); 19 20 return `${s1}${color.inverse(s2)}${s3.join('')}`; 20 21 } 21 22 get cursor() {
+2 -1
packages/core/src/types.ts
··· 1 + import type { Key } from 'node:readline'; 1 2 import type { Action } from './utils/settings.js'; 2 3 3 4 /** ··· 15 16 submit: (value?: any) => void; 16 17 error: (value?: any) => void; 17 18 cursor: (key?: Action) => void; 18 - key: (key?: string) => void; 19 + key: (key: string | undefined, info: Key) => void; 19 20 value: (value?: string) => void; 20 21 confirm: (value?: boolean) => void; 21 22 finalize: () => void;
+139
packages/core/test/prompts/autocomplete.test.ts
··· 1 + import { cursor } from 'sisteransi'; 2 + import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 3 + import { default as AutocompletePrompt } from '../../src/prompts/autocomplete.js'; 4 + import { MockReadable } from '../mock-readable.js'; 5 + import { MockWritable } from '../mock-writable.js'; 6 + 7 + describe('AutocompletePrompt', () => { 8 + let input: MockReadable; 9 + let output: MockWritable; 10 + const testOptions = [ 11 + { value: 'apple', label: 'Apple' }, 12 + { value: 'banana', label: 'Banana' }, 13 + { value: 'cherry', label: 'Cherry' }, 14 + { value: 'grape', label: 'Grape' }, 15 + { value: 'orange', label: 'Orange' }, 16 + ]; 17 + 18 + beforeEach(() => { 19 + input = new MockReadable(); 20 + output = new MockWritable(); 21 + }); 22 + 23 + afterEach(() => { 24 + vi.restoreAllMocks(); 25 + }); 26 + 27 + test('renders render() result', () => { 28 + const instance = new AutocompletePrompt({ 29 + input, 30 + output, 31 + render: () => 'foo', 32 + options: testOptions, 33 + }); 34 + instance.prompt(); 35 + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 36 + }); 37 + 38 + test('initial options match provided options', () => { 39 + const instance = new AutocompletePrompt({ 40 + input, 41 + output, 42 + render: () => 'foo', 43 + options: testOptions, 44 + }); 45 + 46 + instance.prompt(); 47 + 48 + // Initial state should have all options 49 + expect(instance.filteredOptions.length).to.equal(testOptions.length); 50 + expect(instance.cursor).to.equal(0); 51 + }); 52 + 53 + test('cursor navigation with event emitter', () => { 54 + const instance = new AutocompletePrompt({ 55 + input, 56 + output, 57 + render: () => 'foo', 58 + options: testOptions, 59 + }); 60 + 61 + instance.prompt(); 62 + 63 + // Initial cursor should be at 0 64 + expect(instance.cursor).to.equal(0); 65 + 66 + // Directly trigger the cursor event with 'down' 67 + instance.emit('key', '', { name: 'down' }); 68 + 69 + // After down event, cursor should be 1 70 + expect(instance.cursor).to.equal(1); 71 + 72 + // Trigger cursor event with 'up' 73 + instance.emit('key', '', { name: 'up' }); 74 + 75 + // After up event, cursor should be back to 0 76 + expect(instance.cursor).to.equal(0); 77 + }); 78 + 79 + test('initialValue selects correct option', () => { 80 + const instance = new AutocompletePrompt({ 81 + input, 82 + output, 83 + render: () => 'foo', 84 + options: testOptions, 85 + initialValue: ['cherry'], 86 + }); 87 + 88 + // The cursor should be initialized to the cherry index 89 + const cherryIndex = testOptions.findIndex((opt) => opt.value === 'cherry'); 90 + expect(instance.cursor).to.equal(cherryIndex); 91 + 92 + // The selectedValue should be cherry 93 + expect(instance.selectedValues).to.deep.equal(['cherry']); 94 + }); 95 + 96 + test('filtering through value event', () => { 97 + const instance = new AutocompletePrompt({ 98 + input, 99 + output, 100 + render: () => 'foo', 101 + options: testOptions, 102 + }); 103 + 104 + instance.prompt(); 105 + 106 + // Initial state should have all options 107 + expect(instance.filteredOptions.length).to.equal(testOptions.length); 108 + 109 + // Simulate typing 'a' by emitting value event 110 + instance.emit('value', 'a'); 111 + 112 + // Check that filtered options are updated to include options with 'a' 113 + expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length); 114 + 115 + // Check that 'apple' is in the filtered options 116 + const hasApple = instance.filteredOptions.some((opt) => opt.value === 'apple'); 117 + expect(hasApple).to.equal(true); 118 + }); 119 + 120 + test('default filter function works correctly', () => { 121 + const instance = new AutocompletePrompt({ 122 + input, 123 + output, 124 + render: () => 'foo', 125 + options: testOptions, 126 + }); 127 + 128 + instance.emit('value', 'ap'); 129 + 130 + expect(instance.filteredOptions).toEqual([ 131 + { value: 'apple', label: 'Apple' }, 132 + { value: 'grape', label: 'Grape' }, 133 + ]); 134 + 135 + instance.emit('value', 'z'); 136 + 137 + expect(instance.filteredOptions).toEqual([]); 138 + }); 139 + });
+2 -2
packages/core/test/prompts/prompt.test.ts
··· 38 38 const resultPromise = instance.prompt(); 39 39 input.emit('keypress', '', { name: 'return' }); 40 40 const result = await resultPromise; 41 - expect(result).to.equal(''); 41 + expect(result).to.equal(undefined); 42 42 expect(isCancel(result)).to.equal(false); 43 43 expect(instance.state).to.equal('submit'); 44 44 expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); ··· 181 181 182 182 input.emit('keypress', 'z', { name: 'z' }); 183 183 184 - expect(eventSpy).toBeCalledWith('z'); 184 + expect(eventSpy).toBeCalledWith('z', { name: 'z' }); 185 185 }); 186 186 187 187 test('emits cursor events for movement keys', () => {
+301
packages/prompts/src/autocomplete.ts
··· 1 + import { AutocompletePrompt } from '@clack/core'; 2 + import color from 'picocolors'; 3 + import { 4 + type CommonOptions, 5 + S_BAR, 6 + S_BAR_END, 7 + S_CHECKBOX_INACTIVE, 8 + S_CHECKBOX_SELECTED, 9 + S_RADIO_ACTIVE, 10 + S_RADIO_INACTIVE, 11 + symbol, 12 + } from './common.js'; 13 + import { limitOptions } from './limit-options.js'; 14 + import type { Option } from './select.js'; 15 + 16 + function getLabel<T>(option: Option<T>) { 17 + return option.label ?? String(option.value ?? ''); 18 + } 19 + 20 + function getFilteredOption<T>(searchText: string, option: Option<T>): boolean { 21 + if (!searchText) { 22 + return true; 23 + } 24 + const label = (option.label ?? String(option.value ?? '')).toLowerCase(); 25 + const hint = (option.hint ?? '').toLowerCase(); 26 + const value = String(option.value).toLowerCase(); 27 + const term = searchText.toLowerCase(); 28 + 29 + return label.includes(term) || hint.includes(term) || value.includes(term); 30 + } 31 + 32 + function getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] { 33 + const results: Option<T>[] = []; 34 + 35 + for (const option of options) { 36 + if (values.includes(option.value)) { 37 + results.push(option); 38 + } 39 + } 40 + 41 + return results; 42 + } 43 + 44 + export interface AutocompleteOptions<Value> extends CommonOptions { 45 + /** 46 + * The message to display to the user. 47 + */ 48 + message: string; 49 + /** 50 + * Available options for the autocomplete prompt. 51 + */ 52 + options: Option<Value>[]; 53 + /** 54 + * The initial selected value. 55 + */ 56 + initialValue?: Value; 57 + /** 58 + * Maximum number of items to display at once. 59 + */ 60 + maxItems?: number; 61 + /** 62 + * Placeholder text to display when no input is provided. 63 + */ 64 + placeholder?: string; 65 + } 66 + 67 + export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => { 68 + const prompt = new AutocompletePrompt({ 69 + options: opts.options, 70 + placeholder: opts.placeholder, 71 + initialValue: opts.initialValue ? [opts.initialValue] : undefined, 72 + filter: (search: string, opt: Option<Value>) => { 73 + return getFilteredOption(search, opt); 74 + }, 75 + input: opts.input, 76 + output: opts.output, 77 + render() { 78 + // Title and message display 79 + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 80 + const valueAsString = String(this.value ?? ''); 81 + 82 + // Handle different states 83 + switch (this.state) { 84 + case 'submit': { 85 + // Show selected value 86 + const selected = getSelectedOptions(this.selectedValues, this.options); 87 + const label = selected.length > 0 ? selected.map(getLabel).join(', ') : ''; 88 + return `${title}${color.gray(S_BAR)} ${color.dim(label)}`; 89 + } 90 + 91 + case 'cancel': { 92 + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.value ?? ''))}`; 93 + } 94 + 95 + default: { 96 + // Display cursor position - show plain text in navigation mode 97 + const searchText = this.isNavigating ? color.dim(valueAsString) : this.valueWithCursor; 98 + 99 + // Show match count if filtered 100 + const matches = 101 + this.filteredOptions.length !== this.options.length 102 + ? color.dim( 103 + ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` 104 + ) 105 + : ''; 106 + 107 + // Render options with selection 108 + const displayOptions = 109 + this.filteredOptions.length === 0 110 + ? [] 111 + : limitOptions({ 112 + cursor: this.cursor, 113 + options: this.filteredOptions, 114 + style: (option, active) => { 115 + const label = getLabel(option); 116 + const hint = 117 + option.hint && option.value === this.focusedValue 118 + ? color.dim(` (${option.hint})`) 119 + : ''; 120 + 121 + return active 122 + ? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}` 123 + : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`; 124 + }, 125 + maxItems: opts.maxItems, 126 + output: opts.output, 127 + }); 128 + 129 + // Show instructions 130 + const instructions = [ 131 + `${color.dim('↑/↓')} to select`, 132 + `${color.dim('Enter:')} confirm`, 133 + `${color.dim('Type:')} to search`, 134 + ]; 135 + 136 + // No matches message 137 + const noResults = 138 + this.filteredOptions.length === 0 && valueAsString 139 + ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] 140 + : []; 141 + 142 + // Return the formatted prompt 143 + return [ 144 + title, 145 + `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, 146 + ...noResults, 147 + ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), 148 + `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, 149 + `${color.cyan(S_BAR_END)}`, 150 + ].join('\n'); 151 + } 152 + } 153 + }, 154 + }); 155 + 156 + // Return the result or cancel symbol 157 + return prompt.prompt() as Promise<Value | symbol>; 158 + }; 159 + 160 + // Type definition for the autocompleteMultiselect component 161 + export interface AutocompleteMultiSelectOptions<Value> { 162 + /** 163 + * The message to display to the user 164 + */ 165 + message: string; 166 + /** 167 + * The options for the user to choose from 168 + */ 169 + options: Option<Value>[]; 170 + /** 171 + * The initial selected values 172 + */ 173 + initialValues?: Value[]; 174 + /** 175 + * The maximum number of items that can be selected 176 + */ 177 + maxItems?: number; 178 + /** 179 + * The placeholder to display in the input 180 + */ 181 + placeholder?: string; 182 + /** 183 + * The stream to read from 184 + */ 185 + input?: NodeJS.ReadStream; 186 + /** 187 + * The stream to write to 188 + */ 189 + output?: NodeJS.WriteStream; 190 + } 191 + 192 + /** 193 + * Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI 194 + */ 195 + export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOptions<Value>) => { 196 + const formatOption = ( 197 + option: Option<Value>, 198 + active: boolean, 199 + selectedValues: Value[], 200 + focusedValue: Value | undefined 201 + ) => { 202 + const isSelected = selectedValues.includes(option.value); 203 + const label = option.label ?? String(option.value ?? ''); 204 + const hint = 205 + option.hint && focusedValue !== undefined && option.value === focusedValue 206 + ? color.dim(` (${option.hint})`) 207 + : ''; 208 + const checkbox = isSelected 209 + ? color.green(S_CHECKBOX_SELECTED) 210 + : color.dim(S_CHECKBOX_INACTIVE); 211 + 212 + if (active) { 213 + return `${checkbox} ${label}${hint}`; 214 + } 215 + return `${checkbox} ${color.dim(label)}`; 216 + }; 217 + 218 + // Create text prompt which we'll use as foundation 219 + const prompt = new AutocompletePrompt<Option<Value>>({ 220 + options: opts.options, 221 + multiple: true, 222 + filter: (search, opt) => { 223 + return getFilteredOption(search, opt); 224 + }, 225 + placeholder: opts.placeholder, 226 + initialValue: opts.initialValues, 227 + input: opts.input, 228 + output: opts.output, 229 + render() { 230 + // Title and symbol 231 + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 232 + 233 + // Selection counter 234 + const counter = 235 + this.selectedValues.length > 0 236 + ? color.cyan(` (${this.selectedValues.length} selected)`) 237 + : ''; 238 + const value = String(this.value ?? ''); 239 + 240 + // Search input display 241 + const searchText = this.isNavigating 242 + ? color.dim(value) // Just show plain text when in navigation mode 243 + : this.valueWithCursor; 244 + 245 + const matches = 246 + this.filteredOptions.length !== opts.options.length 247 + ? color.dim( 248 + ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` 249 + ) 250 + : ''; 251 + 252 + // Render prompt state 253 + switch (this.state) { 254 + case 'submit': { 255 + return `${title}${color.gray(S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`; 256 + } 257 + case 'cancel': { 258 + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(value))}`; 259 + } 260 + default: { 261 + // Instructions 262 + const instructions = [ 263 + `${color.dim('↑/↓')} to navigate`, 264 + `${color.dim('Space:')} select`, 265 + `${color.dim('Enter:')} confirm`, 266 + `${color.dim('Type:')} to search`, 267 + ]; 268 + 269 + // No results message 270 + const noResults = 271 + this.filteredOptions.length === 0 && value 272 + ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] 273 + : []; 274 + 275 + // Get limited options for display 276 + const displayOptions = limitOptions({ 277 + cursor: this.cursor, 278 + options: this.filteredOptions, 279 + style: (option, active) => 280 + formatOption(option, active, this.selectedValues, this.focusedValue), 281 + maxItems: opts.maxItems, 282 + output: opts.output, 283 + }); 284 + 285 + // Build the prompt display 286 + return [ 287 + title, 288 + `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, 289 + ...noResults, 290 + ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), 291 + `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, 292 + `${color.cyan(S_BAR_END)}`, 293 + ].join('\n'); 294 + } 295 + } 296 + }, 297 + }); 298 + 299 + // Return the result or cancel symbol 300 + return prompt.prompt() as Promise<Value | symbol>; 301 + };
+1
packages/prompts/src/index.ts
··· 1 1 export { isCancel, updateSettings, settings, type ClackSettings } from '@clack/core'; 2 2 3 + export * from './autocomplete.js'; 3 4 export * from './common.js'; 4 5 export * from './confirm.js'; 5 6 export * from './group-multi-select.js';
+2 -1
packages/prompts/src/limit-options.ts
··· 14 14 const { cursor, options, style } = params; 15 15 const output: Writable = params.output ?? process.stdout; 16 16 const rows = output instanceof WriteStream && output.rows !== undefined ? output.rows : 10; 17 + const overflowFormat = color.dim('...'); 17 18 18 19 const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY; 19 20 const outputMaxItems = Math.max(rows - 4, 0); ··· 37 38 const isTopLimit = i === 0 && shouldRenderTopEllipsis; 38 39 const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; 39 40 return isTopLimit || isBottomLimit 40 - ? color.dim('...') 41 + ? overflowFormat 41 42 : style(option, i + slidingWindowLocation === cursor); 42 43 }); 43 44 };
+185
packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
··· 1 + // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 + 3 + exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ Select an option 8 + 9 + │ Search: _ 10 + │ ● Option 0 11 + │ ○ Option 1 12 + │ ○ Option 2 13 + │ ○ Option 3 14 + │ ○ Option 4 15 + │ ... 16 + │ ↑/↓ to select • Enter: confirm • Type: to search 17 + └", 18 + ] 19 + `; 20 + 21 + exports[`autocomplete > renders initial UI with message and instructions 1`] = ` 22 + [ 23 + "<cursor.hide>", 24 + "│ 25 + ◆ Select a fruit 26 + 27 + │ Search: _ 28 + │ ● Apple 29 + │ ○ Banana 30 + │ ○ Cherry 31 + │ ○ Grape 32 + │ ○ Orange 33 + │ ↑/↓ to select • Enter: confirm • Type: to search 34 + └", 35 + ] 36 + `; 37 + 38 + exports[`autocomplete > shows hint when option has hint and is focused 1`] = ` 39 + [ 40 + "<cursor.hide>", 41 + "│ 42 + ◆ Select a fruit 43 + 44 + │ Search: _ 45 + │ ● Apple 46 + │ ○ Banana 47 + │ ○ Cherry 48 + │ ○ Grape 49 + │ ○ Orange 50 + │ ○ Kiwi 51 + │ ↑/↓ to select • Enter: confirm • Type: to search 52 + └", 53 + "<cursor.backward count=999><cursor.up count=11>", 54 + "<cursor.down count=3>", 55 + "<erase.down>", 56 + "│ Search: 57 + │ ○ Apple 58 + │ ● Banana 59 + │ ○ Cherry 60 + │ ○ Grape 61 + │ ○ Orange 62 + │ ○ Kiwi 63 + │ ↑/↓ to select • Enter: confirm • Type: to search 64 + └", 65 + "<cursor.backward count=999><cursor.up count=11>", 66 + "<cursor.down count=5>", 67 + "<erase.down>", 68 + "│ ○ Banana 69 + │ ● Cherry 70 + │ ○ Grape 71 + │ ○ Orange 72 + │ ○ Kiwi 73 + │ ↑/↓ to select • Enter: confirm • Type: to search 74 + └", 75 + "<cursor.backward count=999><cursor.up count=11>", 76 + "<cursor.down count=6>", 77 + "<erase.down>", 78 + "│ ○ Cherry 79 + │ ● Grape 80 + │ ○ Orange 81 + │ ○ Kiwi 82 + │ ↑/↓ to select • Enter: confirm • Type: to search 83 + └", 84 + "<cursor.backward count=999><cursor.up count=11>", 85 + "<cursor.down count=7>", 86 + "<erase.down>", 87 + "│ ○ Grape 88 + │ ● Orange 89 + │ ○ Kiwi 90 + │ ↑/↓ to select • Enter: confirm • Type: to search 91 + └", 92 + "<cursor.backward count=999><cursor.up count=11>", 93 + "<cursor.down count=8>", 94 + "<erase.down>", 95 + "│ ○ Orange 96 + │ ● Kiwi (New Zealand) 97 + │ ↑/↓ to select • Enter: confirm • Type: to search 98 + └", 99 + ] 100 + `; 101 + 102 + exports[`autocomplete > shows no matches message when search has no results 1`] = ` 103 + [ 104 + "<cursor.hide>", 105 + "│ 106 + ◆ Select a fruit 107 + 108 + │ Search: _ 109 + │ ● Apple 110 + │ ○ Banana 111 + │ ○ Cherry 112 + │ ○ Grape 113 + │ ○ Orange 114 + │ ↑/↓ to select • Enter: confirm • Type: to search 115 + └", 116 + "<cursor.backward count=999><cursor.up count=10>", 117 + "<cursor.down count=3>", 118 + "<erase.down>", 119 + "│ Search: z█ (0 matches) 120 + │ No matches found 121 + │ ↑/↓ to select • Enter: confirm • Type: to search 122 + └", 123 + ] 124 + `; 125 + 126 + exports[`autocomplete > shows selected value in submit state 1`] = ` 127 + [ 128 + "<cursor.hide>", 129 + "│ 130 + ◆ Select a fruit 131 + 132 + │ Search: _ 133 + │ ● Apple 134 + │ ○ Banana 135 + │ ○ Cherry 136 + │ ○ Grape 137 + │ ○ Orange 138 + │ ↑/↓ to select • Enter: confirm • Type: to search 139 + └", 140 + "<cursor.backward count=999><cursor.up count=10>", 141 + "<cursor.down count=3>", 142 + "<erase.down>", 143 + "│ Search: 144 + │ ○ Apple 145 + │ ● Banana 146 + │ ○ Cherry 147 + │ ○ Grape 148 + │ ○ Orange 149 + │ ↑/↓ to select • Enter: confirm • Type: to search 150 + └", 151 + "<cursor.backward count=999><cursor.up count=10>", 152 + "<cursor.down count=1>", 153 + "<erase.down>", 154 + "◇ Select a fruit 155 + │ Banana", 156 + " 157 + ", 158 + "<cursor.show>", 159 + ] 160 + `; 161 + 162 + exports[`autocomplete > shows strikethrough in cancel state 1`] = ` 163 + [ 164 + "<cursor.hide>", 165 + "│ 166 + ◆ Select a fruit 167 + 168 + │ Search: _ 169 + │ ● Apple 170 + │ ○ Banana 171 + │ ○ Cherry 172 + │ ○ Grape 173 + │ ○ Orange 174 + │ ↑/↓ to select • Enter: confirm • Type: to search 175 + └", 176 + "<cursor.backward count=999><cursor.up count=10>", 177 + "<cursor.down count=1>", 178 + "<erase.down>", 179 + "■ Select a fruit 180 + │", 181 + " 182 + ", 183 + "<cursor.show>", 184 + ] 185 + `;
+123
packages/prompts/test/autocomplete.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 + import { autocomplete } from '../src/autocomplete.js'; 3 + import { MockReadable, MockWritable } from './test-utils.js'; 4 + 5 + describe('autocomplete', () => { 6 + let input: MockReadable; 7 + let output: MockWritable; 8 + const testOptions = [ 9 + { value: 'apple', label: 'Apple' }, 10 + { value: 'banana', label: 'Banana' }, 11 + { value: 'cherry', label: 'Cherry' }, 12 + { value: 'grape', label: 'Grape' }, 13 + { value: 'orange', label: 'Orange' }, 14 + ]; 15 + 16 + beforeEach(() => { 17 + input = new MockReadable(); 18 + output = new MockWritable(); 19 + }); 20 + 21 + afterEach(() => { 22 + vi.restoreAllMocks(); 23 + }); 24 + 25 + test('renders initial UI with message and instructions', async () => { 26 + const result = autocomplete({ 27 + message: 'Select a fruit', 28 + options: testOptions, 29 + input, 30 + output, 31 + }); 32 + 33 + expect(output.buffer).toMatchSnapshot(); 34 + input.emit('keypress', '', { name: 'return' }); 35 + await result; 36 + }); 37 + 38 + test('limits displayed options when maxItems is set', async () => { 39 + const options = []; 40 + for (let i = 0; i < 10; i++) { 41 + options.push({ value: `option ${i}`, label: `Option ${i}` }); 42 + } 43 + 44 + const result = autocomplete({ 45 + message: 'Select an option', 46 + options, 47 + maxItems: 6, 48 + input, 49 + output, 50 + }); 51 + 52 + expect(output.buffer).toMatchSnapshot(); 53 + input.emit('keypress', '', { name: 'return' }); 54 + await result; 55 + }); 56 + 57 + test('shows no matches message when search has no results', async () => { 58 + const result = autocomplete({ 59 + message: 'Select a fruit', 60 + options: testOptions, 61 + input, 62 + output, 63 + }); 64 + 65 + // Type something that won't match 66 + input.emit('keypress', 'z', { name: 'z' }); 67 + expect(output.buffer).toMatchSnapshot(); 68 + input.emit('keypress', '', { name: 'return' }); 69 + await result; 70 + }); 71 + 72 + test('shows hint when option has hint and is focused', async () => { 73 + const result = autocomplete({ 74 + message: 'Select a fruit', 75 + options: [...testOptions, { value: 'kiwi', label: 'Kiwi', hint: 'New Zealand' }], 76 + input, 77 + output, 78 + }); 79 + 80 + // Navigate to the option with hint 81 + input.emit('keypress', '', { name: 'down' }); 82 + input.emit('keypress', '', { name: 'down' }); 83 + input.emit('keypress', '', { name: 'down' }); 84 + input.emit('keypress', '', { name: 'down' }); 85 + input.emit('keypress', '', { name: 'down' }); 86 + expect(output.buffer).toMatchSnapshot(); 87 + input.emit('keypress', '', { name: 'return' }); 88 + await result; 89 + }); 90 + 91 + test('shows selected value in submit state', async () => { 92 + const result = autocomplete({ 93 + message: 'Select a fruit', 94 + options: testOptions, 95 + input, 96 + output, 97 + }); 98 + 99 + // Select an option and submit 100 + input.emit('keypress', '', { name: 'down' }); 101 + input.emit('keypress', '', { name: 'return' }); 102 + 103 + const value = await result; 104 + expect(value).toBe('banana'); 105 + expect(output.buffer).toMatchSnapshot(); 106 + }); 107 + 108 + test('shows strikethrough in cancel state', async () => { 109 + const result = autocomplete({ 110 + message: 'Select a fruit', 111 + options: testOptions, 112 + input, 113 + output, 114 + }); 115 + 116 + // Cancel with Ctrl+C 117 + input.emit('keypress', '\x03', { name: 'c' }); 118 + 119 + const value = await result; 120 + expect(typeof value === 'symbol').toBe(true); 121 + expect(output.buffer).toMatchSnapshot(); 122 + }); 123 + });