From 2d9ebcaf2f9e071ff3d155876c4c8dd78ac30a19 Mon Sep 17 00:00:00 2001 From: Davor Racic Date: Wed, 4 Feb 2026 00:39:05 +0100 Subject: [PATCH] feat: Update @clack/prompts to v1.0.0 and Add autocompleteMultiselect prompt (#1514) * feat: Update @clack/prompts to v1.0.0 and Add autocompleteMultiselect prompt * fix(cli): flexible tool selection (skip recommended or additional) + fix spacing * feat(cli): improve tool selection UX with autocomplete and upgrade path * feat(cli): display selected tools after IDE selection with preferred markers * fix: formatting * fix: make selection message more clear * fix: formatting * fix: Remove redundant colon --------- Co-authored-by: Brian --- package-lock.json | 18 +- package.json | 4 +- .../installers/lib/core/config-collector.js | 6 +- tools/cli/installers/lib/core/installer.js | 3 - tools/cli/lib/prompts.js | 198 ++++++++++++- tools/cli/lib/ui.js | 273 +++++++++++++----- 6 files changed, 406 insertions(+), 96 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 8df5ea00..8798a620 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,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", @@ -82,6 +83,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/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index cb146270..edb15112 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -695,9 +695,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/prompts.js b/tools/cli/lib/prompts.js index 5d85e2b4..7ab2d21e 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,118 @@ 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?}] + * @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 core = await getClackCore(); + const clack = await getClack(); + const color = await getPicocolors(); + + const filterFn = options.filter ?? defaultAutocompleteFilter; + + const prompt = new core.AutocompletePrompt({ + options: options.options, + 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 || 'Type to search...'; + 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} ${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; +} + /** * Confirm prompt (replaces Inquirer 'confirm' type) * @param {Object} options - Prompt options @@ -211,7 +347,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 +361,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 +608,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..89dc11c5 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,190 @@ 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, - }; + // ───────────────────────────────────────────────────────────────────────────── + // 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: 'Integrate with', + 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 }; + } + + // Display selected tools + this.displaySelectedTools(selectedIdes, preferredIdes, allTools); + + 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); + return { + label: isConfigured ? `${ide.name} ⭐ ✅` : `${ide.name} ⭐`, + value: ide.value, + }; + }); + + // 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__', }); } - // 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, - })); - } + // Pre-select previously configured preferred tools + const recommendedInitialValues = configuredPreferred.length > 0 ? configuredPreferred : undefined; - // 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, + const recommendedSelected = await prompts.multiselect({ + message: `Integrate with ${chalk.dim('(↑/↓ to navigate • SPACE: select • ENTER: confirm)')}:`, + options: recommendedOptions, + initialValues: recommendedInitialValues, + required: 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 = []; + const selectedRecommended = recommendedSelected || []; + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: Handle "Browse All" selection - show additional tools if requested + // ───────────────────────────────────────────────────────────────────────────── + const wantsBrowseAll = selectedRecommended.includes('__BROWSE_ALL__'); + const filteredRecommended = selectedRecommended.filter((v) => v !== '__BROWSE_ALL__'); + + // 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 selectedAdditionalOrAll = []; + + 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, + value: ide.value, + }; + }); + + // Pre-select: previously configured tools + any recommended tools already selected + 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; + + 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: + // - 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 + // ───────────────────────────────────────────────────────────────────────────── + 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, + }; + } + + // Display selected tools + const allTools = [...preferredIdes, ...otherIdes]; + this.displaySelectedTools(allSelectedIdes, preferredIdes, allTools); + return { - ides: selectedIdes || [], - skipIde: !selectedIdes || selectedIdes.length === 0, + ides: allSelectedIdes, + skipIde: allSelectedIdes.length === 0, }; } @@ -1655,6 +1753,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 };