From 25ad95327c9ab3dfe6eabd2a4e33dbbc2ebbb493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Mon, 2 Feb 2026 22:18:15 +0100 Subject: [PATCH 1/5] feat: Update @clack/prompts to v1.0.0 and Add autocompleteMultiselect prompt --- package-lock.json | 18 +- package.json | 4 +- .../installers/lib/core/config-collector.js | 6 +- tools/cli/lib/prompts.js | 115 ++++++++++- tools/cli/lib/ui.js | 192 +++++++++++------- 5 files changed, 240 insertions(+), 95 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe41085b..d3888b28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index f3342a41..1edbd3fc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index f8b2042a..f4eaf5e3 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -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', diff --git a/tools/cli/lib/prompts.js b/tools/cli/lib/prompts.js index 5d85e2b4..c9b7c09a 100644 --- a/tools/cli/lib/prompts.js +++ b/tools/cli/lib/prompts.js @@ -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} The clack core module + */ +async function getClackCore() { + if (!_clackCore) { + _clackCore = await import('@clack/core'); + } + return _clackCore; +} + +/** + * Lazy-load picocolors + * @returns {Promise} 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 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} 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, diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index da5420cb..13b31bd4 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -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; - } - - // 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; - } + // 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(', ')}`)); } - // 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); - return { - label: `${ide.name} ⭐`, - value: ide.value, - }; + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: Recommended Tools (multiselect) + // ───────────────────────────────────────────────────────────────────────────── + const recommendedOptions = preferredIdes.map((ide) => { + const isConfigured = configuredPreferred.includes(ide.value); + return { + label: isConfigured ? `${ide.name} ⭐ ✅` : `${ide.name} ⭐`, + value: ide.value, + }; + }); + + // Add "__NONE__" option at the end + recommendedOptions.push({ + label: '⚠ None - I am not installing any tools', + value: '__NONE__', + }); + + // 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(); + } + 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, }); } - // 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, - })); + 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 { + 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__'); + } } - // Add standalone "None" option at the end - groupedOptions[' '] = [ - { - 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) { - 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 = []; - } + // Combine selections + const allSelectedIdes = [...selectedRecommended, ...selectedAdditional]; return { - ides: selectedIdes || [], - skipIde: !selectedIdes || selectedIdes.length === 0, + ides: allSelectedIdes, + skipIde: allSelectedIdes.length === 0, }; } From ec63026bbbd1fbd649932cc756da5c953dd4482b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Mon, 2 Feb 2026 22:33:50 +0100 Subject: [PATCH 2/5] fix(cli): flexible tool selection (skip recommended or additional) + fix spacing --- tools/cli/installers/lib/core/installer.js | 3 - tools/cli/lib/ui.js | 75 +++++++++------------- 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index a14c3d19..be8dba67 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -697,9 +697,6 @@ class Installer { config.skipIde = toolSelection.skipIde; const ideConfigurations = toolSelection.configurations; - // Add spacing after prompts before installation progress - console.log(''); - if (spinner.isSpinning) { spinner.text = 'Continuing installation...'; } else { diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 13b31bd4..5f969a6d 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -381,7 +381,7 @@ class UI { } // ───────────────────────────────────────────────────────────────────────────── - // STEP 1: Recommended Tools (multiselect) + // STEP 1: Recommended Tools (multiselect) - optional, user can skip // ───────────────────────────────────────────────────────────────────────────── const recommendedOptions = preferredIdes.map((ide) => { const isConfigured = configuredPreferred.includes(ide.value); @@ -391,12 +391,6 @@ class UI { }; }); - // Add "__NONE__" option at the end - recommendedOptions.push({ - label: '⚠ None - I am not installing any tools', - value: '__NONE__', - }); - // Pre-select previously configured preferred tools const recommendedInitialValues = configuredPreferred.length > 0 ? configuredPreferred : undefined; @@ -404,31 +398,16 @@ class UI { message: `Select recommended tools ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`, options: recommendedOptions, initialValues: recommendedInitialValues, - required: true, + required: false, }); - // 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(); - } - return { - ides: [], - skipIde: true, - }; - } - - // Filter out any special values from recommended selection - const selectedRecommended = (recommendedSelected || []).filter((v) => v !== '__NONE__'); + const selectedRecommended = recommendedSelected || []; // ───────────────────────────────────────────────────────────────────────────── - // STEP 2: "Add more tools?" confirmation + // STEP 2: Additional Tools - show if user has configured "other" tools, + // selected no recommended tools, or wants to add more // ───────────────────────────────────────────────────────────────────────────── - // Auto-show additional tools prompt if user has configured "other" tools - // Otherwise, ask if they want to add more - let showAdditionalPrompt = configuredOther.length > 0; + let showAdditionalPrompt = configuredOther.length > 0 || selectedRecommended.length === 0; if (!showAdditionalPrompt && otherIdes.length > 0) { console.log(''); @@ -440,9 +419,6 @@ class UI { 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 @@ -455,37 +431,46 @@ class UI { }; }); - // 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:', + message: `Select additional tools ${chalk.dim('(type to search, SPACE toggles, ENTER to confirm)')}:`, options: additionalOptions, initialValues: additionalInitialValues, - required: true, + required: false, 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__'); - } + selectedAdditional = additionalSelected || []; } // Combine selections const allSelectedIdes = [...selectedRecommended, ...selectedAdditional]; + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: Confirm if no tools selected + // ───────────────────────────────────────────────────────────────────────────── + if (allSelectedIdes.length === 0) { + console.log(''); + const confirmNoTools = await prompts.confirm({ + message: 'No tools selected. Continue without installing any tools?', + default: false, + }); + + if (!confirmNoTools) { + // User wants to select tools - recurse + return this.promptToolSelection(projectDir); + } + + return { + ides: [], + skipIde: true, + }; + } + return { ides: allSelectedIdes, skipIde: allSelectedIdes.length === 0, From 7a3f623b837bb3106ff9f70809d4169d75f38870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Tue, 3 Feb 2026 09:53:00 +0100 Subject: [PATCH 3/5] feat(cli): improve tool selection UX with autocomplete and upgrade path --- tools/cli/lib/prompts.js | 139 ++++++++++++++++++++++++++++++----- tools/cli/lib/ui.js | 151 ++++++++++++++++++++++++++++++--------- 2 files changed, 238 insertions(+), 52 deletions(-) diff --git a/tools/cli/lib/prompts.js b/tools/cli/lib/prompts.js index c9b7c09a..53aacdfc 100644 --- a/tools/cli/lib/prompts.js +++ b/tools/cli/lib/prompts.js @@ -215,8 +215,20 @@ async function groupMultiselect(options) { return result; } +/** + * Default filter function for autocomplete - case-insensitive label matching + * @param {string} search - Search string + * @param {Object} option - Option object with label + * @returns {boolean} Whether the option matches + */ +function defaultAutocompleteFilter(search, option) { + const label = option.label ?? String(option.value ?? ''); + return label.toLowerCase().includes(search.toLowerCase()); +} + /** * Autocomplete multi-select prompt with type-ahead filtering + * Custom implementation that always shows "Space/Tab:" in the hint * @param {Object} options - Prompt options * @param {string} options.message - The question to ask * @param {Array} options.options - Array of choices [{label, value, hint?}] @@ -228,18 +240,111 @@ async function groupMultiselect(options) { * @returns {Promise} Array of selected values */ async function autocompleteMultiselect(options) { + const core = await getClackCore(); const clack = await getClack(); + const color = await getPicocolors(); - const result = await clack.autocompleteMultiselect({ - message: options.message, + const filterFn = options.filter ?? defaultAutocompleteFilter; + + const prompt = new core.AutocompletePrompt({ options: options.options, - placeholder: options.placeholder || 'Type to search...', - initialValues: options.initialValues, - required: options.required || false, - maxItems: options.maxItems || 5, - filter: options.filter, + multiple: true, + filter: filterFn, + validate: () => { + if (options.required && prompt.selectedValues.length === 0) { + return 'Please select at least one item'; + } + }, + initialValue: options.initialValues, + render() { + const barColor = this.state === 'error' ? color.yellow : color.cyan; + const bar = barColor(clack.S_BAR); + const barEnd = barColor(clack.S_BAR_END); + + const title = `${color.gray(clack.S_BAR)}\n${clack.symbol(this.state)} ${options.message}\n`; + + const userInput = this.userInput; + const placeholder = options.placeholder || 'Start typing to find your IDE...'; + const hasPlaceholder = userInput === '' && placeholder !== undefined; + + // Show placeholder or user input with cursor + const searchDisplay = this.isNavigating || hasPlaceholder + ? color.dim(hasPlaceholder ? placeholder : userInput) + : this.userInputWithCursor; + + const allOptions = this.options; + const matchCount = this.filteredOptions.length === allOptions.length + ? '' + : color.dim(` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`); + + // Render option with checkbox + const renderOption = (opt, isHighlighted) => { + const isSelected = this.selectedValues.includes(opt.value); + const label = opt.label ?? String(opt.value ?? ''); + const hintText = opt.hint && opt.value === this.focusedValue ? color.dim(` (${opt.hint})`) : ''; + const checkbox = isSelected ? color.green(clack.S_CHECKBOX_SELECTED) : color.dim(clack.S_CHECKBOX_INACTIVE); + return isHighlighted + ? `${checkbox} ${label}${hintText}` + : `${checkbox} ${color.dim(label)}`; + }; + + switch (this.state) { + case 'submit': { + return `${title}${color.gray(clack.S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`; + } + + case 'cancel': { + return `${title}${color.gray(clack.S_BAR)} ${color.strikethrough(color.dim(userInput))}`; + } + + default: { + // Always show "SPACE:" regardless of isNavigating state + const hints = [ + `${color.dim('↑/↓')} to navigate`, + `${color.dim('TAB/SPACE:')} select`, + `${color.dim('ENTER:')} confirm`, + ]; + + const noMatchesLine = this.filteredOptions.length === 0 && userInput + ? [`${bar} ${color.yellow('No matches found')}`] + : []; + + const errorLine = this.state === 'error' + ? [`${bar} ${color.yellow(this.error)}`] + : []; + + const headerLines = [ + ...`${title}${bar}`.split('\n'), + `${bar} ${color.dim('Search:')} ${searchDisplay}${matchCount}`, + ...noMatchesLine, + ...errorLine, + ]; + + const footerLines = [ + `${bar} ${color.dim(hints.join(' • '))}`, + `${barEnd}`, + ]; + + const optionLines = clack.limitOptions({ + cursor: this.cursor, + options: this.filteredOptions, + style: renderOption, + maxItems: options.maxItems || 5, + output: options.output, + rowPadding: headerLines.length + footerLines.length, + }); + + return [ + ...headerLines, + ...optionLines.map((line) => `${bar} ${line}`), + ...footerLines, + ].join('\n'); + } + } + }, }); + const result = await prompt.prompt(); await handleCancel(result); return result; } @@ -446,12 +551,12 @@ async function prompt(questions) { default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, validate: validate ? (val) => { - const result = validate(val, answers); - if (result instanceof Promise) { - throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); - } - return result === true ? undefined : result; + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); } + return result === true ? undefined : result; + } : undefined, }); break; @@ -489,12 +594,12 @@ async function prompt(questions) { message, validate: validate ? (val) => { - const result = validate(val, answers); - if (result instanceof Promise) { - throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); - } - return result === true ? undefined : result; + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); } + return result === true ? undefined : result; + } : undefined, }); break; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 5f969a6d..17fa4f4c 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -381,7 +381,60 @@ class UI { } // ───────────────────────────────────────────────────────────────────────────── - // STEP 1: Recommended Tools (multiselect) - optional, user can skip + // UPGRADE PATH: If tools already configured, show all tools with configured at top + // ───────────────────────────────────────────────────────────────────────────── + if (configuredIdes.length > 0) { + const allTools = [...preferredIdes, ...otherIdes]; + + // Sort: configured tools first, then preferred, then others + const sortedTools = [ + ...allTools.filter((ide) => configuredIdes.includes(ide.value)), + ...allTools.filter((ide) => !configuredIdes.includes(ide.value)), + ]; + + const upgradeOptions = sortedTools.map((ide) => { + const isConfigured = configuredIdes.includes(ide.value); + const isPreferred = preferredIdes.some((p) => p.value === ide.value); + let label = ide.name; + if (isPreferred) label += ' ⭐'; + if (isConfigured) label += ' ✅'; + return { label, value: ide.value }; + }); + + // Sort initialValues to match display order + const sortedInitialValues = sortedTools + .filter((ide) => configuredIdes.includes(ide.value)) + .map((ide) => ide.value); + + const upgradeSelected = await prompts.autocompleteMultiselect({ + message: 'Select tools to install:', + options: upgradeOptions, + initialValues: sortedInitialValues, + required: false, + maxItems: 8, + }); + + const selectedIdes = upgradeSelected || []; + + if (selectedIdes.length === 0) { + console.log(''); + const confirmNoTools = await prompts.confirm({ + message: 'No tools selected. Continue without installing any tools?', + default: false, + }); + + if (!confirmNoTools) { + return this.promptToolSelection(projectDir); + } + + return { ides: [], skipIde: true }; + } + + return { ides: selectedIdes, skipIde: false }; + } + + // ───────────────────────────────────────────────────────────────────────────── + // NEW INSTALL: Show recommended tools first with "Browse All" option // ───────────────────────────────────────────────────────────────────────────── const recommendedOptions = preferredIdes.map((ide) => { const isConfigured = configuredPreferred.includes(ide.value); @@ -391,11 +444,20 @@ class UI { }; }); + // Add "browse all" option at the end if there are additional tools + if (otherIdes.length > 0) { + const totalTools = preferredIdes.length + otherIdes.length; + recommendedOptions.push({ + label: `→ Browse all supported tools (${totalTools} total)...`, + value: '__BROWSE_ALL__', + }); + } + // 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)')}:`, + message: `Select tools to install ${chalk.dim('(↑/↓ to navigate • SPACE: select • ENTER: confirm)')}:`, options: recommendedOptions, initialValues: recommendedInitialValues, required: false, @@ -404,51 +466,70 @@ class UI { const selectedRecommended = recommendedSelected || []; // ───────────────────────────────────────────────────────────────────────────── - // STEP 2: Additional Tools - show if user has configured "other" tools, - // selected no recommended tools, or wants to add more + // STEP 2: Handle "Browse All" selection - show additional tools if requested // ───────────────────────────────────────────────────────────────────────────── - let showAdditionalPrompt = configuredOther.length > 0 || selectedRecommended.length === 0; + const wantsBrowseAll = selectedRecommended.includes('__BROWSE_ALL__'); + const filteredRecommended = selectedRecommended.filter((v) => v !== '__BROWSE_ALL__'); - if (!showAdditionalPrompt && otherIdes.length > 0) { - console.log(''); - showAdditionalPrompt = await prompts.confirm({ - message: 'Add more tools from the extended list?', - default: false, - }); - } + // Show additional tools if: + // 1. User explicitly chose "Browse All", OR + // 2. User has previously configured "other" tools, OR + // 3. User selected no recommended tools (allow them to pick from other tools) + const showAdditionalTools = wantsBrowseAll || configuredOther.length > 0 || filteredRecommended.length === 0; - let selectedAdditional = []; + let selectedAdditionalOrAll = []; - 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); + if (showAdditionalTools) { + // Show ALL tools if: + // - User explicitly chose "Browse All", OR + // - User selected nothing from recommended (so they can pick from everything) + // Otherwise, show only "other" tools as additional options + const showAllTools = wantsBrowseAll || filteredRecommended.length === 0; + const toolsToShow = showAllTools + ? [...preferredIdes, ...otherIdes] + : otherIdes; + + if (toolsToShow.length > 0) { + const allToolOptions = toolsToShow.map((ide) => { + const isConfigured = configuredIdes.includes(ide.value); + const isPreferred = preferredIdes.some((p) => p.value === ide.value); + let label = ide.name; + if (isPreferred) label += ' ⭐'; + if (isConfigured) label += ' ✅'; return { - label: isConfigured ? `${ide.name} ✅` : ide.name, + label, value: ide.value, }; }); - // Pre-select previously configured other tools - const additionalInitialValues = configuredOther.length > 0 ? configuredOther : undefined; + // Pre-select: previously configured tools + any recommended tools already selected + const initialValues = [ + ...configuredIdes, + ...filteredRecommended, + ].filter((v, i, arr) => arr.indexOf(v) === i); // dedupe - console.log(''); - const additionalSelected = await prompts.autocompleteMultiselect({ - message: `Select additional tools ${chalk.dim('(type to search, SPACE toggles, ENTER to confirm)')}:`, - options: additionalOptions, - initialValues: additionalInitialValues, - required: false, - maxItems: 6, - placeholder: 'Type to search...', - }); + // Use "additional" only if user already selected some recommended tools + const isAdditional = !wantsBrowseAll && filteredRecommended.length > 0; - selectedAdditional = additionalSelected || []; + console.log(''); + const selected = await prompts.autocompleteMultiselect({ + message: isAdditional ? 'Select additional tools:' : 'Select tools:', + options: allToolOptions, + initialValues: initialValues.length > 0 ? initialValues : undefined, + required: false, + maxItems: 8, + }); + + selectedAdditionalOrAll = selected || []; + } } - // Combine selections - const allSelectedIdes = [...selectedRecommended, ...selectedAdditional]; + // Combine selections: + // - If "Browse All" was used, the second prompt contains ALL selections + // - Otherwise, combine recommended + additional + const allSelectedIdes = wantsBrowseAll + ? selectedAdditionalOrAll + : [...filteredRecommended, ...selectedAdditionalOrAll]; // ───────────────────────────────────────────────────────────────────────────── // STEP 3: Confirm if no tools selected @@ -920,7 +1001,7 @@ class UI { console.log( chalk.gray(`Directory exists and contains ${files.length} item(s)`) + - (hasBmadInstall ? chalk.yellow(` including existing BMAD installation (${path.basename(bmadResult.bmadDir)})`) : ''), + (hasBmadInstall ? chalk.yellow(` including existing BMAD installation (${path.basename(bmadResult.bmadDir)})`) : ''), ); } else { console.log(chalk.gray('Directory exists and is empty')); From 3e2da9c728dc1b2ecc3125438f2a0f59bfca5ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Tue, 3 Feb 2026 10:02:34 +0100 Subject: [PATCH 4/5] feat(cli): display selected tools after IDE selection with preferred markers --- tools/cli/lib/ui.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 17fa4f4c..a4533a2d 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -430,6 +430,9 @@ class UI { return { ides: [], skipIde: true }; } + // Display selected tools + this.displaySelectedTools(selectedIdes, preferredIdes, allTools); + return { ides: selectedIdes, skipIde: false }; } @@ -552,6 +555,10 @@ class UI { }; } + // Display selected tools + const allTools = [...preferredIdes, ...otherIdes]; + this.displaySelectedTools(allSelectedIdes, preferredIdes, allTools); + return { ides: allSelectedIdes, skipIde: allSelectedIdes.length === 0, @@ -1755,6 +1762,27 @@ class UI { console.log(''); } + + /** + * Display list of selected tools after IDE selection + * @param {Array} selectedIdes - Array of selected IDE values + * @param {Array} preferredIdes - Array of preferred IDE objects + * @param {Array} allTools - Array of all tool objects + */ + displaySelectedTools(selectedIdes, preferredIdes, allTools) { + if (selectedIdes.length === 0) return; + + const preferredValues = new Set(preferredIdes.map((ide) => ide.value)); + + console.log(''); + console.log(chalk.dim(' Selected tools:')); + for (const ideValue of selectedIdes) { + const tool = allTools.find((t) => t.value === ideValue); + const name = tool?.name || ideValue; + const marker = preferredValues.has(ideValue) ? ' ⭐' : ''; + console.log(chalk.dim(` • ${name}${marker}`)); + } + } } module.exports = { UI }; From 1ff3323c870e937793bcc033b4f2f47ecd2e73ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Tue, 3 Feb 2026 10:27:50 +0100 Subject: [PATCH 5/5] fix: formatting --- tools/cli/lib/prompts.js | 61 +++++++++++++++------------------------- tools/cli/lib/ui.js | 19 ++++--------- 2 files changed, 27 insertions(+), 53 deletions(-) diff --git a/tools/cli/lib/prompts.js b/tools/cli/lib/prompts.js index 53aacdfc..5e411f34 100644 --- a/tools/cli/lib/prompts.js +++ b/tools/cli/lib/prompts.js @@ -268,14 +268,14 @@ async function autocompleteMultiselect(options) { const hasPlaceholder = userInput === '' && placeholder !== undefined; // Show placeholder or user input with cursor - const searchDisplay = this.isNavigating || hasPlaceholder - ? color.dim(hasPlaceholder ? placeholder : userInput) - : this.userInputWithCursor; + const searchDisplay = + this.isNavigating || hasPlaceholder ? color.dim(hasPlaceholder ? placeholder : userInput) : this.userInputWithCursor; const allOptions = this.options; - const matchCount = this.filteredOptions.length === allOptions.length - ? '' - : color.dim(` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`); + const matchCount = + this.filteredOptions.length === allOptions.length + ? '' + : color.dim(` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`); // Render option with checkbox const renderOption = (opt, isHighlighted) => { @@ -283,9 +283,7 @@ async function autocompleteMultiselect(options) { const label = opt.label ?? String(opt.value ?? ''); const hintText = opt.hint && opt.value === this.focusedValue ? color.dim(` (${opt.hint})`) : ''; const checkbox = isSelected ? color.green(clack.S_CHECKBOX_SELECTED) : color.dim(clack.S_CHECKBOX_INACTIVE); - return isHighlighted - ? `${checkbox} ${label}${hintText}` - : `${checkbox} ${color.dim(label)}`; + return isHighlighted ? `${checkbox} ${label}${hintText}` : `${checkbox} ${color.dim(label)}`; }; switch (this.state) { @@ -299,19 +297,11 @@ async function autocompleteMultiselect(options) { default: { // Always show "SPACE:" regardless of isNavigating state - const hints = [ - `${color.dim('↑/↓')} to navigate`, - `${color.dim('TAB/SPACE:')} select`, - `${color.dim('ENTER:')} confirm`, - ]; + const hints = [`${color.dim('↑/↓')} to navigate`, `${color.dim('TAB/SPACE:')} select`, `${color.dim('ENTER:')} confirm`]; - const noMatchesLine = this.filteredOptions.length === 0 && userInput - ? [`${bar} ${color.yellow('No matches found')}`] - : []; + const noMatchesLine = this.filteredOptions.length === 0 && userInput ? [`${bar} ${color.yellow('No matches found')}`] : []; - const errorLine = this.state === 'error' - ? [`${bar} ${color.yellow(this.error)}`] - : []; + const errorLine = this.state === 'error' ? [`${bar} ${color.yellow(this.error)}`] : []; const headerLines = [ ...`${title}${bar}`.split('\n'), @@ -320,10 +310,7 @@ async function autocompleteMultiselect(options) { ...errorLine, ]; - const footerLines = [ - `${bar} ${color.dim(hints.join(' • '))}`, - `${barEnd}`, - ]; + const footerLines = [`${bar} ${color.dim(hints.join(' • '))}`, `${barEnd}`]; const optionLines = clack.limitOptions({ cursor: this.cursor, @@ -334,11 +321,7 @@ async function autocompleteMultiselect(options) { rowPadding: headerLines.length + footerLines.length, }); - return [ - ...headerLines, - ...optionLines.map((line) => `${bar} ${line}`), - ...footerLines, - ].join('\n'); + return [...headerLines, ...optionLines.map((line) => `${bar} ${line}`), ...footerLines].join('\n'); } } }, @@ -551,12 +534,12 @@ async function prompt(questions) { default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, validate: validate ? (val) => { - const result = validate(val, answers); - if (result instanceof Promise) { - throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + } + return result === true ? undefined : result; } - return result === true ? undefined : result; - } : undefined, }); break; @@ -594,12 +577,12 @@ async function prompt(questions) { message, validate: validate ? (val) => { - const result = validate(val, answers); - if (result instanceof Promise) { - throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + } + return result === true ? undefined : result; } - return result === true ? undefined : result; - } : undefined, }); break; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index a4533a2d..f40050e8 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -402,9 +402,7 @@ class UI { }); // Sort initialValues to match display order - const sortedInitialValues = sortedTools - .filter((ide) => configuredIdes.includes(ide.value)) - .map((ide) => ide.value); + const sortedInitialValues = sortedTools.filter((ide) => configuredIdes.includes(ide.value)).map((ide) => ide.value); const upgradeSelected = await prompts.autocompleteMultiselect({ message: 'Select tools to install:', @@ -488,9 +486,7 @@ class UI { // - User selected nothing from recommended (so they can pick from everything) // Otherwise, show only "other" tools as additional options const showAllTools = wantsBrowseAll || filteredRecommended.length === 0; - const toolsToShow = showAllTools - ? [...preferredIdes, ...otherIdes] - : otherIdes; + const toolsToShow = showAllTools ? [...preferredIdes, ...otherIdes] : otherIdes; if (toolsToShow.length > 0) { const allToolOptions = toolsToShow.map((ide) => { @@ -506,10 +502,7 @@ class UI { }); // Pre-select: previously configured tools + any recommended tools already selected - const initialValues = [ - ...configuredIdes, - ...filteredRecommended, - ].filter((v, i, arr) => arr.indexOf(v) === i); // dedupe + const initialValues = [...configuredIdes, ...filteredRecommended].filter((v, i, arr) => arr.indexOf(v) === i); // dedupe // Use "additional" only if user already selected some recommended tools const isAdditional = !wantsBrowseAll && filteredRecommended.length > 0; @@ -530,9 +523,7 @@ class UI { // Combine selections: // - If "Browse All" was used, the second prompt contains ALL selections // - Otherwise, combine recommended + additional - const allSelectedIdes = wantsBrowseAll - ? selectedAdditionalOrAll - : [...filteredRecommended, ...selectedAdditionalOrAll]; + const allSelectedIdes = wantsBrowseAll ? selectedAdditionalOrAll : [...filteredRecommended, ...selectedAdditionalOrAll]; // ───────────────────────────────────────────────────────────────────────────── // STEP 3: Confirm if no tools selected @@ -1008,7 +999,7 @@ class UI { console.log( chalk.gray(`Directory exists and contains ${files.length} item(s)`) + - (hasBmadInstall ? chalk.yellow(` including existing BMAD installation (${path.basename(bmadResult.bmadDir)})`) : ''), + (hasBmadInstall ? chalk.yellow(` including existing BMAD installation (${path.basename(bmadResult.bmadDir)})`) : ''), ); } else { console.log(chalk.gray('Directory exists and is empty'));