feat: Update @clack/prompts to v1.0.0 and Add autocompleteMultiselect prompt

This commit is contained in:
Davor Racić 2026-02-02 22:18:15 +01:00
parent 323cd75efd
commit 25ad95327c
5 changed files with 240 additions and 95 deletions

18
package-lock.json generated
View File

@ -9,7 +9,8 @@
"version": "6.0.0-Beta.5",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^0.11.0",
"@clack/core": "^1.0.0",
"@clack/prompts": "^1.0.0",
"@kayvan/markdown-tree-parser": "^1.6.1",
"boxen": "^5.1.2",
"chalk": "^4.1.2",
@ -22,6 +23,7 @@
"ignore": "^7.0.5",
"js-yaml": "^4.1.0",
"ora": "^5.4.1",
"picocolors": "^1.1.1",
"semver": "^7.6.3",
"wrap-ansi": "^7.0.0",
"xml2js": "^0.6.2",
@ -756,9 +758,9 @@
}
},
"node_modules/@clack/core": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz",
"integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
@ -766,12 +768,12 @@
}
},
"node_modules/@clack/prompts": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz",
"integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
"license": "MIT",
"dependencies": {
"@clack/core": "0.5.0",
"@clack/core": "1.0.0",
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
}

View File

@ -68,7 +68,8 @@
]
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"@clack/core": "^1.0.0",
"@clack/prompts": "^1.0.0",
"@kayvan/markdown-tree-parser": "^1.6.1",
"boxen": "^5.1.2",
"chalk": "^4.1.2",
@ -81,6 +82,7 @@
"ignore": "^7.0.5",
"js-yaml": "^4.1.0",
"ora": "^5.4.1",
"picocolors": "^1.1.1",
"semver": "^7.6.3",
"wrap-ansi": "^7.0.0",
"xml2js": "^0.6.2",

View File

@ -586,7 +586,11 @@ class ConfigCollector {
console.log();
console.log(chalk.cyan('?') + ' ' + chalk.magenta(moduleDisplayName));
let customize = true;
if (moduleName !== 'core') {
if (moduleName === 'core') {
// Core module: no confirm prompt, so add spacing manually to match visual style
console.log(chalk.gray('│'));
} else {
// Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing)
const customizeAnswer = await prompts.prompt([
{
type: 'confirm',

View File

@ -8,6 +8,8 @@
*/
let _clack = null;
let _clackCore = null;
let _picocolors = null;
/**
* Lazy-load @clack/prompts (ESM module)
@ -20,6 +22,28 @@ async function getClack() {
return _clack;
}
/**
* Lazy-load @clack/core (ESM module)
* @returns {Promise<Object>} The clack core module
*/
async function getClackCore() {
if (!_clackCore) {
_clackCore = await import('@clack/core');
}
return _clackCore;
}
/**
* Lazy-load picocolors
* @returns {Promise<Object>} The picocolors module
*/
async function getPicocolors() {
if (!_picocolors) {
_picocolors = (await import('picocolors')).default;
}
return _picocolors;
}
/**
* Handle user cancellation gracefully
* @param {any} value - The value to check
@ -191,6 +215,35 @@ async function groupMultiselect(options) {
return result;
}
/**
* Autocomplete multi-select prompt with type-ahead filtering
* @param {Object} options - Prompt options
* @param {string} options.message - The question to ask
* @param {Array} options.options - Array of choices [{label, value, hint?}]
* @param {string} [options.placeholder] - Placeholder text for search input
* @param {Array} [options.initialValues] - Array of initially selected values
* @param {boolean} [options.required=false] - Whether at least one must be selected
* @param {number} [options.maxItems=5] - Maximum visible items in scrollable list
* @param {Function} [options.filter] - Custom filter function (search, option) => boolean
* @returns {Promise<Array>} Array of selected values
*/
async function autocompleteMultiselect(options) {
const clack = await getClack();
const result = await clack.autocompleteMultiselect({
message: options.message,
options: options.options,
placeholder: options.placeholder || 'Type to search...',
initialValues: options.initialValues,
required: options.required || false,
maxItems: options.maxItems || 5,
filter: options.filter,
});
await handleCancel(result);
return result;
}
/**
* Confirm prompt (replaces Inquirer 'confirm' type)
* @param {Object} options - Prompt options
@ -211,7 +264,12 @@ async function confirm(options) {
}
/**
* Text input prompt (replaces Inquirer 'input' type)
* Text input prompt with Tab-to-fill-placeholder support (replaces Inquirer 'input' type)
*
* This custom implementation restores the Tab-to-fill-placeholder behavior that was
* intentionally removed in @clack/prompts v1.0.0 (placeholder became purely visual).
* Uses @clack/core's TextPrompt primitive with custom key handling.
*
* @param {Object} options - Prompt options
* @param {string} options.message - The question to ask
* @param {string} [options.default] - Default value
@ -220,20 +278,64 @@ async function confirm(options) {
* @returns {Promise<string>} User's input
*/
async function text(options) {
const clack = await getClack();
const core = await getClackCore();
const color = await getPicocolors();
// Use default as placeholder if placeholder not explicitly provided
// This shows the default value as grayed-out hint text
const placeholder = options.placeholder === undefined ? options.default : options.placeholder;
const defaultValue = options.default;
const result = await clack.text({
message: options.message,
defaultValue: options.default,
placeholder: typeof placeholder === 'string' ? placeholder : undefined,
const prompt = new core.TextPrompt({
defaultValue,
validate: options.validate,
render() {
const title = `${color.gray('◆')} ${options.message}`;
// Show placeholder as dim text when input is empty
let valueDisplay;
if (this.state === 'error') {
valueDisplay = color.yellow(this.userInputWithCursor);
} else if (this.userInput) {
valueDisplay = this.userInputWithCursor;
} else if (placeholder) {
// Show placeholder with cursor indicator when empty
valueDisplay = `${color.inverse(color.hidden('_'))}${color.dim(placeholder)}`;
} else {
valueDisplay = color.inverse(color.hidden('_'));
}
const bar = color.gray('│');
// Handle different states
if (this.state === 'submit') {
return `${color.gray('◇')} ${options.message}\n${bar} ${color.dim(this.value || defaultValue || '')}`;
}
if (this.state === 'cancel') {
return `${color.gray('◇')} ${options.message}\n${bar} ${color.strikethrough(color.dim(this.userInput || ''))}`;
}
if (this.state === 'error') {
return `${color.yellow('▲')} ${options.message}\n${bar} ${valueDisplay}\n${color.yellow('│')} ${color.yellow(this.error)}`;
}
return `${title}\n${bar} ${valueDisplay}\n${bar}`;
},
});
// Add Tab key handler to fill placeholder into input
prompt.on('key', (char) => {
if (char === '\t' && placeholder && !prompt.userInput) {
// Use _setUserInput with write=true to populate the readline and update internal state
prompt._setUserInput(placeholder, true);
}
});
const result = await prompt.prompt();
await handleCancel(result);
// TextPrompt's finalize handler already applies defaultValue for empty input
return result;
}
@ -423,6 +525,7 @@ module.exports = {
select,
multiselect,
groupMultiselect,
autocompleteMultiselect,
confirm,
text,
password,

View File

@ -344,6 +344,9 @@ class UI {
/**
* Prompt for tool/IDE selection (called after module configuration)
* Uses a split prompt approach:
* 1. Recommended tools - standard multiselect for 3 preferred tools
* 2. Additional tools - autocompleteMultiselect with search capability
* @param {string} projectDir - Project directory to check for existing IDEs
* @returns {Object} Tool configuration
*/
@ -366,95 +369,126 @@ class UI {
const preferredIdes = ideManager.getPreferredIdes();
const otherIdes = ideManager.getOtherIdes();
// Build grouped options object for groupMultiselect
const groupedOptions = {};
const processedIdes = new Set();
const initialValues = [];
// Determine which configured IDEs are in "preferred" vs "other" categories
const configuredPreferred = configuredIdes.filter((id) => preferredIdes.some((ide) => ide.value === id));
const configuredOther = configuredIdes.filter((id) => otherIdes.some((ide) => ide.value === id));
// First, add previously configured IDEs, marked with ✅
if (configuredIdes.length > 0) {
const configuredGroup = [];
for (const ideValue of configuredIdes) {
// Skip empty or invalid IDE values
if (!ideValue || typeof ideValue !== 'string') {
continue;
// Warn about previously configured tools that are no longer available
const allKnownValues = new Set([...preferredIdes, ...otherIdes].map((ide) => ide.value));
const unknownTools = configuredIdes.filter((id) => id && typeof id === 'string' && !allKnownValues.has(id));
if (unknownTools.length > 0) {
console.log(chalk.yellow(`⚠️ Previously configured tools are no longer available: ${unknownTools.join(', ')}`));
}
// Find the IDE in either preferred or other lists
const preferredIde = preferredIdes.find((ide) => ide.value === ideValue);
const otherIde = otherIdes.find((ide) => ide.value === ideValue);
const ide = preferredIde || otherIde;
if (ide) {
configuredGroup.push({
label: `${ide.name}`,
value: ide.value,
});
processedIdes.add(ide.value);
initialValues.push(ide.value); // Pre-select configured IDEs
} else {
// Warn about unrecognized IDE (but don't fail)
console.log(chalk.yellow(`⚠️ Previously configured IDE '${ideValue}' is no longer available`));
}
}
if (configuredGroup.length > 0) {
groupedOptions['Previously Configured'] = configuredGroup;
}
}
// Add preferred tools (excluding already processed)
const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value));
if (remainingPreferred.length > 0) {
groupedOptions['Recommended Tools'] = remainingPreferred.map((ide) => {
processedIdes.add(ide.value);
// ─────────────────────────────────────────────────────────────────────────────
// STEP 1: Recommended Tools (multiselect)
// ─────────────────────────────────────────────────────────────────────────────
const recommendedOptions = preferredIdes.map((ide) => {
const isConfigured = configuredPreferred.includes(ide.value);
return {
label: `${ide.name}`,
label: isConfigured ? `${ide.name} ⭐ ✅` : `${ide.name}`,
value: ide.value,
};
});
}
// Add other tools (excluding already processed)
const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value));
if (remainingOther.length > 0) {
groupedOptions['Additional Tools'] = remainingOther.map((ide) => ({
label: ide.name,
value: ide.value,
}));
}
// Add standalone "None" option at the end
groupedOptions[' '] = [
{
// Add "__NONE__" option at the end
recommendedOptions.push({
label: '⚠ None - I am not installing any tools',
value: '__NONE__',
},
];
let selectedIdes = [];
selectedIdes = await prompts.groupMultiselect({
message: `Select tools to configure ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`,
options: groupedOptions,
initialValues: initialValues.length > 0 ? initialValues : undefined,
required: true,
selectableGroups: false,
});
// If user selected both "__NONE__" and other tools, honor the "None" choice
if (selectedIdes && selectedIdes.includes('__NONE__') && selectedIdes.length > 1) {
// Pre-select previously configured preferred tools
const recommendedInitialValues = configuredPreferred.length > 0 ? configuredPreferred : undefined;
const recommendedSelected = await prompts.multiselect({
message: `Select recommended tools ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`,
options: recommendedOptions,
initialValues: recommendedInitialValues,
required: true,
});
// Handle "__NONE__" selection
if (recommendedSelected && recommendedSelected.includes('__NONE__')) {
if (recommendedSelected.length > 1) {
console.log();
console.log(chalk.yellow('⚠️ "None - I am not installing any tools" was selected, so no tools will be configured.'));
console.log();
selectedIdes = [];
} else if (selectedIdes && selectedIdes.includes('__NONE__')) {
// Only "__NONE__" was selected
selectedIdes = [];
}
return {
ides: [],
skipIde: true,
};
}
// Filter out any special values from recommended selection
const selectedRecommended = (recommendedSelected || []).filter((v) => v !== '__NONE__');
// ─────────────────────────────────────────────────────────────────────────────
// STEP 2: "Add more tools?" confirmation
// ─────────────────────────────────────────────────────────────────────────────
// Auto-show additional tools prompt if user has configured "other" tools
// Otherwise, ask if they want to add more
let showAdditionalPrompt = configuredOther.length > 0;
if (!showAdditionalPrompt && otherIdes.length > 0) {
console.log('');
showAdditionalPrompt = await prompts.confirm({
message: 'Add more tools from the extended list?',
default: false,
});
}
let selectedAdditional = [];
// ─────────────────────────────────────────────────────────────────────────────
// STEP 3: Additional Tools (autocompleteMultiselect with search)
// ─────────────────────────────────────────────────────────────────────────────
if (showAdditionalPrompt && otherIdes.length > 0) {
// Build options for additional tools, excluding any already selected in recommended
const additionalOptions = otherIdes
.filter((ide) => !selectedRecommended.includes(ide.value))
.map((ide) => {
const isConfigured = configuredOther.includes(ide.value);
return {
ides: selectedIdes || [],
skipIde: !selectedIdes || selectedIdes.length === 0,
label: isConfigured ? `${ide.name}` : ide.name,
value: ide.value,
};
});
// Add "__SKIP__" option at the end
additionalOptions.push({
label: '⚠ Skip - Keep recommended selections only',
value: '__SKIP__',
});
// Pre-select previously configured other tools
const additionalInitialValues = configuredOther.length > 0 ? configuredOther : undefined;
console.log('');
const additionalSelected = await prompts.autocompleteMultiselect({
message: 'Select additional tools:',
options: additionalOptions,
initialValues: additionalInitialValues,
required: true,
maxItems: 6,
placeholder: 'Type to search...',
});
// Handle "__SKIP__" selection
if (additionalSelected && additionalSelected.includes('__SKIP__')) {
// User chose to skip - keep only recommended selections
selectedAdditional = [];
} else {
selectedAdditional = (additionalSelected || []).filter((v) => v !== '__SKIP__');
}
}
// Combine selections
const allSelectedIdes = [...selectedRecommended, ...selectedAdditional];
return {
ides: allSelectedIdes,
skipIde: allSelectedIdes.length === 0,
};
}