import type { Key, ReadLine } from "node:readline"; import type { Readable, Writable } from "node:stream"; import { styleText } from "node:util"; import { AutocompletePrompt } from "@clack/core"; import { S_BAR, S_BAR_END, S_CHECKBOX_INACTIVE, S_CHECKBOX_SELECTED, limitOptions, settings, symbol, } from "@clack/prompts"; type Option = { value: Value; label?: string; hint?: string; disabled?: boolean; }; type Filter = (search: string, option: Option) => boolean; type AutocompleteMultiselectOptions = { message: string; options: | Option[] | ((this: AutocompletePrompt>) => Option[]); maxItems?: number; validate?: (value: Value[] | undefined) => string | Error | undefined; filter?: Filter; initialValues?: Value[]; required?: boolean; input?: Readable; output?: Writable; signal?: AbortSignal; withGuide?: boolean; }; 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 is_ctrl_a(char: string | undefined, key: Key): boolean { return char === "\u0001" || (key.ctrl === true && key.name === "a"); } /** * Generated by 🤖. Mostly copying and extending from Clack AutocompletePrompt. * * This is to support ctrl+a to select all visible */ class AutocompleteMultiselectPrompt extends AutocompletePrompt< Option > { protected override _isActionKey(char: string | undefined, key: Key): boolean { return ( super._isActionKey(char, key) || (this.multiple && key.name === "space" && char !== undefined && char !== "") ); } constructor( private readonly promptOptions: AutocompleteMultiselectOptions, ) { super({ options: promptOptions.options, multiple: true, filter: promptOptions.filter ?? ((search, opt) => getFilteredOption(search, opt)), validate: (value) => { if ( promptOptions.required && (!Array.isArray(value) || value.length === 0) ) { return "Please select at least one item"; } return promptOptions.validate?.(value as Value[] | undefined); }, initialValue: promptOptions.initialValues, signal: promptOptions.signal, input: promptOptions.input, output: promptOptions.output, render() { const hasGuide = promptOptions.withGuide ?? settings.withGuide; const title = `${hasGuide ? `${styleText("gray", S_BAR)}\n` : ""}${symbol(this.state)} ${promptOptions.message}\n`; const userInput = this.userInput; const searchText = this.userInputWithCursor; const options = this.options; const matches = this.filteredOptions.length !== options.length ? styleText( "dim", ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? "" : "es"})`, ) : ""; switch (this.state) { case "submit": { return `${title}${hasGuide ? `${styleText("gray", S_BAR)} ` : ""}${styleText("dim", `${this.selectedValues.length} items selected`)}`; } case "cancel": { return `${title}${hasGuide ? `${styleText("gray", S_BAR)} ` : ""}${styleText(["strikethrough", "dim"], userInput)}`; } default: { const barStyle = this.state === "error" ? "yellow" : "cyan"; const guidePrefix = hasGuide ? `${styleText(barStyle, S_BAR)} ` : ""; const guidePrefixEnd = hasGuide ? styleText(barStyle, S_BAR_END) : ""; const instructions = [ `${styleText("dim", "↑/↓")} to navigate`, `${styleText("dim", "Space/Tab:")} select`, `${styleText("dim", "Ctrl+a:")} select visible`, `${styleText("dim", "Enter:")} confirm`, `${styleText("dim", "Type:")} to search`, ]; const noResults = this.filteredOptions.length === 0 && userInput ? [`${guidePrefix}${styleText("yellow", "No matches found")}`] : []; const errorMessage = this.state === "error" ? [`${guidePrefix}${styleText("yellow", this.error)}`] : []; const headerLines = [ ...`${title}${hasGuide ? styleText(barStyle, S_BAR) : ""}`.split( "\n", ), `${guidePrefix}${styleText("dim", "Search:")} ${searchText}${matches}`, ...noResults, ...errorMessage, ]; const footerLines = [ `${guidePrefix}${instructions.join(" • ")}`, guidePrefixEnd, ]; const displayOptions = limitOptions({ cursor: this.cursor, options: this.filteredOptions, style: (option, active) => { const isSelected = this.selectedValues.includes(option.value); const label = option.label ?? String(option.value ?? ""); const hint = option.hint && this.focusedValue !== undefined && option.value === this.focusedValue ? styleText("dim", ` (${option.hint})`) : ""; const checkbox = isSelected ? styleText("green", S_CHECKBOX_SELECTED) : styleText("dim", S_CHECKBOX_INACTIVE); if (option.disabled) { return `${styleText("gray", S_CHECKBOX_INACTIVE)} ${styleText(["strikethrough", "gray"], label)}`; } if (active) { return `${checkbox} ${label}${hint}`; } return `${checkbox} ${styleText("dim", label)}`; }, maxItems: promptOptions.maxItems, output: promptOptions.output, rowPadding: headerLines.length + footerLines.length, }); return [ ...headerLines, ...displayOptions.map((option) => `${guidePrefix}${option}`), ...footerLines, ].join("\n"); } } }, }); this.on("key", (char, key) => { if ( key.name === "space" && !this.isNavigating && this.focusedValue !== undefined ) { this.toggleSelected(this.focusedValue); this.#restore_cursor_to_end(); return; } if (!is_ctrl_a(char, key)) return; this.#toggle_all_visible(); this.isNavigating = true; this.#restore_cursor_to_end(); }); } #toggle_all_visible(): void { const visible_values = this.filteredOptions .filter((option) => !option.disabled) .map((option) => option.value); if (!visible_values.length) return; const every_visible_selected = visible_values.every((value) => this.selectedValues.includes(value), ); this.selectedValues = every_visible_selected ? this.selectedValues.filter((value) => !visible_values.includes(value)) : [ ...this.selectedValues, ...visible_values.filter( (value) => !this.selectedValues.includes(value), ), ]; } #restore_cursor_to_end(): void { const rl = (this as unknown as { rl?: ReadLine }).rl; rl?.write("", { ctrl: true, name: "e" }); this._cursor = rl?.cursor ?? this.userInput.length; } } export function autocompleteMultiselect( opts: AutocompleteMultiselectOptions, ): Promise { return new AutocompleteMultiselectPrompt(opts).prompt() as Promise< Value[] | symbol >; }