@fission-ai/openspec
Version:
AI-native system for spec-driven development
159 lines • 7.04 kB
JavaScript
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