UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

159 lines 7.04 kB
import chalk from 'chalk'; /** * Create the searchable multi-select prompt. * Uses dynamic import to prevent pre-commit hook hangs (see #367). */ async function createSearchableMultiSelect() { const { createPrompt, useState, useKeypress, useMemo, usePrefix, isEnterKey, isBackspaceKey, isUpKey, isDownKey, } = await import('@inquirer/core'); return createPrompt((config, done) => { const { message, choices, pageSize = 15, validate } = config; const [searchText, setSearchText] = useState(''); const [selectedValues, setSelectedValues] = useState(() => choices.filter(c => c.preSelected).map(c => c.value)); const [cursor, setCursor] = useState(0); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); const prefix = usePrefix({ status }); // Filter choices by search const filteredChoices = useMemo(() => { if (!searchText.trim()) return choices; const term = searchText.toLowerCase(); return choices.filter((c) => c.name.toLowerCase().includes(term) || c.value.toLowerCase().includes(term)); }, [searchText, choices]); const selectedSet = useMemo(() => new Set(selectedValues), [selectedValues]); const choiceMap = useMemo(() => new Map(choices.map((c) => [c.value, c])), [choices]); useKeypress((key) => { if (status === 'done') return; // Enter to confirm/submit if (isEnterKey(key)) { if (validate) { const result = validate(selectedValues); if (result !== true) { setError(typeof result === 'string' ? result : 'Invalid'); return; } } setStatus('done'); done(selectedValues); return; } // Space to toggle selection if (key.name === 'space') { const choice = filteredChoices[cursor]; if (choice) { if (selectedSet.has(choice.value)) { setSelectedValues(selectedValues.filter(v => v !== choice.value)); } else { setSelectedValues([...selectedValues, choice.value]); } } return; } // Backspace to remove or delete search char if (isBackspaceKey(key)) { if (searchText === '' && selectedValues.length > 0) { setSelectedValues(selectedValues.slice(0, -1)); } else { setSearchText(searchText.slice(0, -1)); setCursor(0); } return; } // Navigation if (isUpKey(key)) { setCursor(Math.max(0, cursor - 1)); return; } if (isDownKey(key)) { setCursor(Math.min(filteredChoices.length - 1, cursor + 1)); return; } // Character input - handle printable characters if (key.name && key.name.length === 1 && !key.ctrl) { setSearchText(searchText + key.name); setCursor(0); } }); // Render done state if (status === 'done') { const names = selectedValues .map((v) => choiceMap.get(v)?.name ?? v) .join(', '); return `${prefix} ${chalk.bold(message)} ${chalk.cyan(names || '(none)')}`; } // Render active state const lines = []; lines.push(`${prefix} ${chalk.bold(message)}`); // Selected chips const chips = selectedValues.length > 0 ? selectedValues .map((v) => chalk.bgCyan.black(` ${choiceMap.get(v)?.name} `)) .join(' ') : chalk.dim('(none selected)'); lines.push(` Selected: ${chips}`); // Search box lines.push(` Search: ${chalk.yellow('[')}${searchText || chalk.dim('type to filter')}${chalk.yellow(']')}`); // Instructions lines.push(` ${chalk.cyan('↑↓')} navigate • ${chalk.cyan('Space')} toggle • ${chalk.cyan('Backspace')} remove • ${chalk.cyan('Enter')} confirm`); // List if (filteredChoices.length === 0) { lines.push(chalk.yellow(' No matches')); } else { // Calculate pagination const startIndex = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), filteredChoices.length - pageSize)); const endIndex = Math.min(startIndex + pageSize, filteredChoices.length); const visibleChoices = filteredChoices.slice(startIndex, endIndex); for (let i = 0; i < visibleChoices.length; i++) { const item = visibleChoices[i]; const actualIndex = startIndex + i; const isActive = actualIndex === cursor; const selected = selectedSet.has(item.value); const icon = selected ? chalk.green('◉') : chalk.dim('○'); const arrow = isActive ? chalk.cyan('›') : ' '; const name = isActive ? chalk.cyan(item.name) : item.name; const isRefresh = selected && item.configured; const statusLabel = !selected ? item.configured ? ' (configured)' : item.detected ? ' (detected)' : '' : ''; const suffix = selected ? chalk.dim(isRefresh ? ' (refresh)' : ' (selected)') : chalk.dim(statusLabel); lines.push(` ${arrow} ${icon} ${name}${suffix}`); } // Show pagination indicator if needed if (filteredChoices.length > pageSize) { const currentPage = Math.floor(cursor / pageSize) + 1; const totalPages = Math.ceil(filteredChoices.length / pageSize); lines.push(chalk.dim(` (${currentPage}/${totalPages})`)); } } if (error) lines.push(chalk.red(` ${error}`)); return lines.join('\n'); }); } /** * A searchable multi-select prompt with visible search box, * selected items display, and intuitive keyboard navigation. * * - Type to filter choices * - ↑↓ to navigate * - Space to toggle highlighted item selection * - Backspace to remove last selected item (or delete search char) * - Enter to confirm selections */ export async function searchableMultiSelect(config) { const prompt = await createSearchableMultiSelect(); return prompt(config); } export default searchableMultiSelect; //# sourceMappingURL=searchable-multi-select.js.map