Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/nuxi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@nuxt/kit": "^4.2.2",
"@nuxt/schema": "^4.2.2",
"@nuxt/test-utils": "^3.23.0",
"@posva/prompts": "^2.4.4",
"@types/copy-paste": "^2.1.0",
"@types/debug": "^4.1.12",
"@types/node": "^24.10.7",
Expand All @@ -50,6 +51,7 @@
"defu": "^6.1.4",
"exsolve": "^1.0.8",
"fuse.js": "^7.1.0",
"fzf": "^0.5.2",
"giget": "^2.0.0",
"h3": "^1.15.4",
"h3-next": "npm:h3@^2.0.1-rc.7",
Expand Down
33 changes: 11 additions & 22 deletions packages/nuxi/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { TemplateData } from '../utils/starter-templates'
import { existsSync } from 'node:fs'
import process from 'node:process'

import { box, cancel, confirm, intro, isCancel, multiselect, outro, select, spinner, tasks, text } from '@clack/prompts'
import { box, cancel, confirm, intro, isCancel, outro, select, spinner, tasks, text } from '@clack/prompts'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
import { downloadTemplate, startShell } from 'giget'
Expand All @@ -23,6 +23,7 @@ import { relativeToProcess } from '../utils/paths'
import { getTemplates } from '../utils/starter-templates'
import { getNuxtVersion } from '../utils/versions'
import { cwdArgs, logLevelArgs } from './_shared'
import { selectModulesAutocomplete } from './module/_autocomplete'
import { checkNuxtCompatibility, fetchModules } from './module/_utils'
import addModuleCommand from './module/add'

Expand Down Expand Up @@ -423,11 +424,11 @@ export default defineCommand({
}
}

// ...or offer to install official modules (if not offline)
// ...or offer to browse and install modules (if not offline)
else if (!ctx.args.offline && !ctx.args.preferOffline) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
message: `Would you like to install any of the official modules?`,
message: `Would you like to browse and install modules?`,
initialValue: false,
})

Expand All @@ -448,33 +449,21 @@ export default defineCommand({

modulesSpinner.stop('Modules loaded')

const officialModules = response
const allModules = response
.filter(module =>
module.type === 'official'
&& module.npm !== '@nuxt/devtools'
module.npm !== '@nuxt/devtools'
&& !templateDeps.includes(module.npm)
&& (!module.compatibility.nuxt || checkNuxtCompatibility(module, nuxtVersion)),
)

if (officialModules.length === 0) {
logger.info('All official modules are already included in this template.')
if (allModules.length === 0) {
logger.info('All modules are already included in this template.')
}
else {
const selectedOfficialModules = await multiselect({
message: 'Pick the modules to install:',
options: officialModules.map(module => ({
label: `${colors.bold(colors.greenBright(module.npm))} – ${module.description.replace(/\.$/, '')}`,
value: module.npm,
})),
required: false,
})

if (isCancel(selectedOfficialModules)) {
process.exit(1)
}
const result = await selectModulesAutocomplete({ modules: allModules })

if (selectedOfficialModules.length > 0) {
const modules = selectedOfficialModules as unknown as string[]
if (result.selected.length > 0) {
const modules = result.selected

const allDependencies = Object.fromEntries(
await Promise.all(modules.map(async module =>
Expand Down
150 changes: 150 additions & 0 deletions packages/nuxi/src/commands/module/_autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { Choice } from '@posva/prompts'
import type { NuxtModule } from './_utils'

import process from 'node:process'
import prompts from '@posva/prompts'
import { colors } from 'consola/utils'
import { byLengthAsc, Fzf } from 'fzf'
import { hasTTY } from 'std-env'

import { logger } from '../../utils/logger'

export interface AutocompleteOptions {
modules: NuxtModule[]
message?: string
}

export interface AutocompleteResult {
selected: string[]
cancelled: boolean
}

/**
* Interactive fuzzy search for selecting Nuxt modules
* Returns object with selected module npm package names and cancellation status
*/
export async function selectModulesAutocomplete(options: AutocompleteOptions): Promise<AutocompleteResult> {
const { modules, message = 'Search modules (Esc to finish):' } = options

if (!hasTTY) {
logger.warn('Interactive module selection requires a TTY. Skipping.')
return { selected: [], cancelled: false }
}

// Sort: official modules first, then alphabetically
const sortedModules = [...modules].sort((a, b) => {
if (a.type === 'official' && b.type !== 'official')
return -1
if (a.type !== 'official' && b.type === 'official')
return 1
return a.npm.localeCompare(b.npm)
})

// Setup fzf for fast fuzzy search
const fzf = new Fzf(sortedModules, {
selector: m => `${m.npm} ${m.name} ${m.category}`,
casing: 'case-insensitive',
tiebreakers: [byLengthAsc],
})

// Truncate description to fit terminal
const terminalWidth = process.stdout?.columns || 80
const maxDescLength = Math.max(40, terminalWidth - 35)
const truncate = (str: string, max: number) =>
str.length > max ? `${str.slice(0, max - 1)}…` : str

// Track selected modules
const selectedModules = new Set<string>()

// Build choices with checkbox prefix
const buildChoices = () => sortedModules.map((m) => {
const isSelected = selectedModules.has(m.npm)
const check = isSelected ? colors.green('✔') : colors.dim('○')
return {
title: `${check} ${m.npm}`,
value: m.npm,
description: truncate(m.description.replace(/\.$/, ''), maxDescLength),
}
})

// Loop for multi-select via autocomplete with checkboxes
let isExited = false
let isDone = false
let lastQuery = ''

// ANSI escapes for terminal control
const clearLines = (n: number) => {
if (!hasTTY)
return
for (let i = 0; i < n; i++) {
process.stdout.write('\x1B[1A\x1B[2K')
}
}

// Show summary line
const showSummary = () => {
if (!hasTTY || selectedModules.size === 0)
return
const names = Array.from(selectedModules).map(m => colors.cyan(m.replace(/^@nuxt(js)?\//, ''))).join(', ')
process.stdout.write(`${colors.dim('Selected:')} ${names}\n`)
}

while (!isDone) {
const choices = buildChoices()

// Clear previous prompt and show fresh summary
if (lastQuery !== '' || selectedModules.size > 0) {
clearLines(selectedModules.size > 0 ? 2 : 1)
}
showSummary()

try {
const result = await prompts({
type: 'autocomplete',
name: 'module',
message,
initial: lastQuery,
choices,
limit: 10,
suggest: async (input: string, choices: Choice[]) => {
lastQuery = input
if (!input)
return choices
const results = fzf.find(input)
return results.map((r) => {
const isSelected = selectedModules.has(r.item.npm)
const check = isSelected ? colors.green('✔') : colors.dim('○')
return {
title: `${check} ${r.item.npm}`,
value: r.item.npm,
description: truncate(r.item.description.replace(/\.$/, ''), maxDescLength),
}
})
},
onState(state: { exited?: boolean }) {
if (state.exited)
isExited = true
},
})

if (isExited || !result.module) {
isDone = true
}
else {
// Toggle selection
if (selectedModules.has(result.module)) {
selectedModules.delete(result.module)
}
else {
selectedModules.add(result.module)
}
}
isExited = false
}
catch {
isDone = true
}
}

return { selected: Array.from(selectedModules), cancelled: false }
}
34 changes: 32 additions & 2 deletions packages/nuxi/src/commands/module/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { homedir } from 'node:os'
import { join } from 'node:path'

import process from 'node:process'
import { cancel, confirm, isCancel, select } from '@clack/prompts'
import { cancel, confirm, isCancel, select, spinner } from '@clack/prompts'
import { updateConfig } from 'c12/update'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
Expand All @@ -25,6 +25,7 @@ import { relativeToProcess } from '../../utils/paths'
import { getNuxtVersion } from '../../utils/versions'
import { cwdArgs, logLevelArgs } from '../_shared'
import prepareCommand from '../prepare'
import { selectModulesAutocomplete } from './_autocomplete'
import { checkNuxtCompatibility, fetchModules, getRegistryFromContent } from './_utils'

interface RegistryMeta {
Expand Down Expand Up @@ -68,7 +69,7 @@ export default defineCommand({
},
async setup(ctx) {
const cwd = resolve(ctx.args.cwd)
const modules = ctx.args._.map(e => e.trim()).filter(Boolean)
let modules = ctx.args._.map(e => e.trim()).filter(Boolean)
const projectPkg = await readPackageJSON(cwd).catch(() => ({} as PackageJson))

if (!projectPkg.dependencies?.nuxt && !projectPkg.devDependencies?.nuxt) {
Expand All @@ -84,6 +85,35 @@ export default defineCommand({
}
}

// If no modules specified, show interactive search
if (modules.length === 0) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')

const [allModules, nuxtVersion] = await Promise.all([
fetchModules(),
getNuxtVersion(cwd),
])

const compatibleModules = allModules.filter(m =>
!m.compatibility.nuxt || checkNuxtCompatibility(m, nuxtVersion),
)

modulesSpinner.stop('Modules loaded')

const result = await selectModulesAutocomplete({
modules: compatibleModules,
message: 'Search modules to add (Esc to finish):',
})

if (result.selected.length === 0) {
cancel('No modules selected.')
process.exit(0)
}

modules = result.selected
}

const resolvedModules: ResolvedModule[] = []
for (const moduleName of modules) {
const resolvedModule = await resolveModule(moduleName, cwd)
Expand Down
Loading
Loading