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/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/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..5f969a6d 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,111 @@ 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) - optional, user can skip + // ───────────────────────────────────────────────────────────────────────────── + const recommendedOptions = preferredIdes.map((ide) => { + const isConfigured = configuredPreferred.includes(ide.value); + return { + label: isConfigured ? `${ide.name} ⭐ ✅` : `${ide.name} ⭐`, + value: ide.value, + }; + }); + + // 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: false, + }); + + const selectedRecommended = recommendedSelected || []; + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: Additional Tools - show if user has configured "other" tools, + // selected no recommended tools, or wants to add more + // ───────────────────────────────────────────────────────────────────────────── + let showAdditionalPrompt = configuredOther.length > 0 || selectedRecommended.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 = []; + + 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, + }; + }); + + // Pre-select previously configured other tools + const additionalInitialValues = configuredOther.length > 0 ? configuredOther : undefined; + + 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...', + }); + + selectedAdditional = additionalSelected || []; } - // Add standalone "None" option at the end - groupedOptions[' '] = [ - { - label: '⚠ None - I am not installing any tools', - value: '__NONE__', - }, - ]; + // Combine selections + const allSelectedIdes = [...selectedRecommended, ...selectedAdditional]; - let selectedIdes = []; + // ───────────────────────────────────────────────────────────────────────────── + // 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, + }); - 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 (!confirmNoTools) { + // User wants to select tools - recurse + return this.promptToolSelection(projectDir); + } - // 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 = []; + return { + ides: [], + skipIde: true, + }; } return { - ides: selectedIdes || [], - skipIde: !selectedIdes || selectedIdes.length === 0, + ides: allSelectedIdes, + skipIde: allSelectedIdes.length === 0, }; }