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 docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ In an interactive terminal, `nuxt dev` renders a pinned panel: the server URLs,
| `?` | Show all shortcuts |
| `q` | Quit |

Inside a view, `y` copies the selected row and `shift-y` copies everything the view is showing with its filters and search applied, keeping the newest entries when there is too much to paste. In the logs that is the history as plain text, ready to hand to an agent. In the info view it is the table [`nuxt info`](/docs/api/commands/info) produces, ready for an issue.

Pass `--no-tui` to stream logs instead, which is also what `NUXT_TUI=plain` does for good. `NUXT_TUI=1` forces the UI on where the environment checks would otherwise turn it off, but never where the output is piped or redirected.

![nuxt dev with plain output](/capture/output/nuxt-dev-plain-static.svg)
Expand Down
143 changes: 79 additions & 64 deletions packages/nuxt-cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,75 +60,14 @@ export default defineCommand({
},
async run(ctx) {
const cwd = resolveRootDir(ctx.args)
const [nuxtConfig, projectPkg, detectedPackageManager] = await Promise.all([
getNuxtConfig(cwd),
readPackageJSON(cwd).catch(() => ({} as PackageJson)),
detectPackageManager(cwd),
])
const { dependencies = {}, devDependencies = {} } = projectPkg
const nuxtPath = tryResolveNuxt(cwd)
const versions = new Map<string, Promise<string | undefined>>()
const getDepVersion = (name: string) => {
let version = versions.get(name)
if (!version) {
version = resolveDependencyVersion(name, [cwd, nuxtPath], cwd, projectPkg, dependencies, devDependencies)
versions.set(name, version)
}
return version
}

const modulesPromise = Promise.all((nuxtConfig.modules || []).map(async (module) => {
const name = normalizeConfigModule(module, cwd)
if (!name) {
return null
}
const specifier = Array.isArray(module) ? module[0] : module
const packageName = typeof specifier === 'string' && getPackageName(specifier)
const version = packageName && await getDepVersion(packageName)
return version ? `${name}@${version}` : name
}))
const [modules, nuxtVersion = '-', nitroVersion] = await Promise.all([
modulesPromise,
getDepVersion('nuxt').then(version => version || getDepVersion('nuxt-nightly')),
resolveNitroVersion(cwd, getDepVersion),
])
const configKeys = Object.keys(nuxtConfig).sort()
const moduleNames = modules.filter(module => module !== null)
const builder = nuxtConfig.builder || 'vite'
const packageManager = detectedPackageManager
? `${detectedPackageManager.name}@${getPackageManagerVersion(detectedPackageManager.command)}`
: 'unknown'
const osType = os.type()
const cpus = os.cpus()
const builderInfo = typeof builder === 'string' && ['vite', '@nuxt/vite-builder', 'webpack', '@nuxt/webpack-builder', 'rspack', '@nuxt/rspack-builder'].includes(builder)
? getBuilder(cwd, builder)
: { name: 'custom', version: '0.0.0' }

const infoObj = {
'Operating system': osType === 'Darwin' ? `macOS ${os.release()}` : osType === 'Windows_NT' ? `Windows ${os.release()}` : `${osType} ${os.release()}`,
'CPU': `${cpus[0]?.model || 'unknown'} (${cpus.length} cores)`,
...isBun
// @ts-expect-error Bun global
? { 'Bun version': Bun?.version as string }
: isDeno
// @ts-expect-error Deno global
? { 'Deno version': Deno?.version.deno as string }
: { 'Node.js version': process.version as string },
'nuxt/cli version': nuxiVersion,
'Package manager': packageManager,
'Nuxt version': nuxtVersion,
'Nitro version': nitroVersion,
'Builder': builderInfo.name === 'custom' ? 'custom' : `${builderInfo.name.toLowerCase()}@${builderInfo.version}`,
'Config': configKeys.map(key => `\`${key}\``).join(', '),
'Modules': moduleNames.map(name => `\`${name}\``).join(', '),
}
const { info: infoObj, configKeys, moduleNames, rootDir } = await collectProjectInfo(cwd)

if (ctx.args.json) {
// Arrays come from the source values rather than the rendered string, so a
// key or module path containing `, ` stays a single entry.
const lists: Record<string, string[]> = { config: configKeys, modules: moduleNames }
const payload = JSON.stringify({
rootDir: nuxtConfig.rootDir || cwd,
rootDir,
...Object.fromEntries(Object.entries(infoObj).map(([label, value]) => {
const key = JSON_KEYS[label] ?? camelCase(label)
return [key, lists[key] ?? (value?.replaceAll('`', '') || null)]
Expand All @@ -138,7 +77,7 @@ export default defineCommand({
return
}

logger.info(`Nuxt root directory: ${styleText('cyan', nuxtConfig.rootDir || cwd)}\n`)
logger.info(`Nuxt root directory: ${styleText('cyan', rootDir)}\n`)

const boxStr = formatInfoBox(infoObj)

Expand Down Expand Up @@ -173,6 +112,82 @@ export default defineCommand({
},
})

export interface ProjectInfo {
/** Display label to value, in the order it is shown and pasted. */
info: Record<string, string | undefined>
configKeys: string[]
moduleNames: string[]
rootDir: string
}

/** Everything a bug report asks for about the project in `cwd`. */
export async function collectProjectInfo(cwd: string): Promise<ProjectInfo> {
const [nuxtConfig, projectPkg, detectedPackageManager] = await Promise.all([
getNuxtConfig(cwd),
readPackageJSON(cwd).catch(() => ({} as PackageJson)),
detectPackageManager(cwd),
])
const { dependencies = {}, devDependencies = {} } = projectPkg
const nuxtPath = tryResolveNuxt(cwd)
const versions = new Map<string, Promise<string | undefined>>()
const getDepVersion = (name: string) => {
let version = versions.get(name)
if (!version) {
version = resolveDependencyVersion(name, [cwd, nuxtPath], cwd, projectPkg, dependencies, devDependencies)
versions.set(name, version)
}
return version
}

const modulesPromise = Promise.all((nuxtConfig.modules || []).map(async (module) => {
const name = normalizeConfigModule(module, cwd)
if (!name) {
return null
}
const specifier = Array.isArray(module) ? module[0] : module
const packageName = typeof specifier === 'string' && getPackageName(specifier)
const version = packageName && await getDepVersion(packageName)
return version ? `${name}@${version}` : name
}))
const [modules, nuxtVersion = '-', nitroVersion] = await Promise.all([
modulesPromise,
getDepVersion('nuxt').then(version => version || getDepVersion('nuxt-nightly')),
resolveNitroVersion(cwd, getDepVersion),
])
const configKeys = Object.keys(nuxtConfig).sort()
const moduleNames = modules.filter(module => module !== null)
const builder = nuxtConfig.builder || 'vite'
const packageManager = detectedPackageManager
? `${detectedPackageManager.name}@${getPackageManagerVersion(detectedPackageManager.command)}`
: 'unknown'
const osType = os.type()
const cpus = os.cpus()
const builderInfo = typeof builder === 'string' && ['vite', '@nuxt/vite-builder', 'webpack', '@nuxt/webpack-builder', 'rspack', '@nuxt/rspack-builder'].includes(builder)
? getBuilder(cwd, builder)
: { name: 'custom', version: '0.0.0' }

const infoObj = {
'Operating system': osType === 'Darwin' ? `macOS ${os.release()}` : osType === 'Windows_NT' ? `Windows ${os.release()}` : `${osType} ${os.release()}`,
'CPU': `${cpus[0]?.model || 'unknown'} (${cpus.length} cores)`,
...isBun
// @ts-expect-error Bun global
? { 'Bun version': Bun?.version as string }
: isDeno
// @ts-expect-error Deno global
? { 'Deno version': Deno?.version.deno as string }
: { 'Node.js version': process.version as string },
'nuxt/cli version': nuxiVersion,
'Package manager': packageManager,
'Nuxt version': nuxtVersion,
'Nitro version': nitroVersion,
'Builder': builderInfo.name === 'custom' ? 'custom' : `${builderInfo.name.toLowerCase()}@${builderInfo.version}`,
'Config': configKeys.map(key => `\`${key}\``).join(', '),
'Modules': moduleNames.map(name => `\`${name}\``).join(', '),
}

return { info: infoObj, configKeys, moduleNames, rootDir: nuxtConfig.rootDir || cwd }
}

async function resolveDependencyVersion(
name: string,
roots: Array<string | null>,
Expand Down
5 changes: 5 additions & 0 deletions packages/nuxt-cli/src/dev/tui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
write,
release,
() => qrCode,
// Loaded on demand: gathering it evaluates the project's config.
async () => {
const { collectProjectInfo, formatMarkdownTable } = await import('../../commands/info')
return formatMarkdownTable((await collectProjectInfo(cwd)).info)
},
)
const views = [overlay, trafficOverlay, routeOverlay, helpOverlay, infoOverlay]
const openOverlay = () => views.find(view => view.isOpen)
Expand Down
26 changes: 24 additions & 2 deletions packages/nuxt-cli/src/dev/tui/info-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ const VALUE_STYLES: Array<{ pattern: RegExp, style: Parameters<typeof styleText>
export class InfoOverlay extends ScreenOverlay {
#sections: () => InfoSection[]
#panel: () => string | undefined
#report?: () => Promise<string>

constructor(
sections: () => InfoSection[],
write: (chunk: string) => void,
onClose: () => void,
panel: () => string | undefined = () => undefined,
/** What belongs in a bug report, which is not what the view shows. */
report?: () => Promise<string>,
) {
super({
write,
Expand All @@ -44,6 +47,20 @@ export class InfoOverlay extends ScreenOverlay {
})
this.#sections = sections
this.#panel = panel
this.#report = report
}

/**
* The rows here are for whoever is at the terminal: URLs, uptime, a QR code.
* An issue wants the project's versions, config and modules instead, in the
* table `nuxt info` produces.
*/
protected async copyAllText(): Promise<string | undefined> {
if (!this.#report) {
return undefined
}
this.notify('collecting project info…')
return this.#report()
}

protected get closeKeys(): readonly string[] {
Expand All @@ -68,13 +85,18 @@ export class InfoOverlay extends ScreenOverlay {

return withSidePanel(rows, this.#panel(), columns).map(line => ({
lines: [line],
// Copying a whole info screen is rarely useful; a single value is.
// A single value is what `y` is for; `Y` copies the issue report.
copy: stripAnsi(line).trim().split(/\s{2,}/).at(-1),
}))
}

protected renderHints(columns: number): string {
return formatHints([['q', 'close']], columns)
return formatHints([
['↑/↓', 'select'],
['y', 'copy'],
...this.#report ? [['Y', 'copy for an issue'] as [string, string]] : [],
['q', 'close'],
], columns)
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/dev/tui/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ export class LogOverlay extends ScreenOverlay {
['c/b/r', 'cli/build/runtime'],
['/', 'search'],
['x', 'clear'],
['enter', 'copy'],
['y', 'copy'],
['Y', 'copy all'],
['q', 'close'],
], columns)
}
Expand Down
4 changes: 3 additions & 1 deletion packages/nuxt-cli/src/dev/tui/request-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export class RequestOverlay extends ScreenOverlay {
if (this.#detail) {
return formatHints([
['↑/↓', 'select'],
['enter', 'copy'],
['y', 'copy'],
['Y', 'copy all'],
['esc', 'back'],
], columns)
}
Expand All @@ -152,6 +153,7 @@ export class RequestOverlay extends ScreenOverlay {
['b', 'bundler'],
['/', 'search'],
['y', 'copy'],
['Y', 'copy all'],
['q', 'close'],
], columns)
}
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/dev/tui/route-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export class RouteOverlay extends ScreenOverlay {
['s', 'server'],
['a', 'all'],
['/', 'search'],
['enter', 'copy'],
['y', 'copy'],
['Y', 'copy all'],
['q', 'close'],
], columns)
}
Expand Down
71 changes: 63 additions & 8 deletions packages/nuxt-cli/src/dev/tui/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const RENDER_DELAY_MS = 50
/** How long a copy confirmation stays in the hint line. */
const NOTICE_MS = 2000

/**
* The most that copying a whole view puts on the clipboard. What gets pasted is
* going into an issue or an agent's prompt, where the newest entries matter and
* ten thousand of them help nobody.
*/
const COPY_ALL_MAX_CHARS = 60_000

/** Marks the selected entry; the same width is reserved on every row. */
const SELECTED_GUTTER = 'β–Ž '
const GUTTER = ' '
Expand Down Expand Up @@ -77,6 +84,14 @@ export abstract class ScreenOverlay {
return false
}

/**
* Text for copying the whole view, for views whose rows are not what belongs
* on the clipboard. Every entry's own text is the fallback.
*/
protected copyAllText(): Promise<string | undefined> | string | undefined {
return undefined
}

get isOpen(): boolean {
return this.#open
}
Expand Down Expand Up @@ -160,7 +175,7 @@ export abstract class ScreenOverlay {
void this.#copySelected()
return
case 'y':
void this.#copySelected()
void (key.sequence === 'Y' ? this.#copyAll() : this.#copySelected())
return
default:
if ((key.name && this.closeKeys.includes(key.name)) || (key.sequence && this.closeKeys.includes(key.sequence))) {
Expand Down Expand Up @@ -314,24 +329,64 @@ export abstract class ScreenOverlay {
const entries = this.#entries()
const text = this.#selected === undefined ? undefined : entries[this.#selected]?.copy
if (!text) {
this.#notify('nothing selected to copy')
this.notify('nothing selected to copy')
return
}
await this.#copy(text, 'copied')
}

/** Copy everything the view is showing, filters and search applied. */
async #copyAll(): Promise<void> {
let custom: string | undefined
try {
custom = await this.copyAllText()
}
catch {
this.notify('could not gather what to copy')
return
}
if (custom) {
// A view's own text reads from the top, so the head is what is kept.
return this.#copy(custom.slice(0, COPY_ALL_MAX_CHARS), 'copied')
}
const texts = this.#entries().map(entry => entry.copy).filter(text => !!text) as string[]
if (!texts.length) {
this.notify('nothing to copy')
return
}
// The tail is kept: entries run oldest first, and the newest are the ones
// that describe what just went wrong.
let length = 0
let start = texts.length
while (start > 0 && length + texts[start - 1]!.length + 1 <= COPY_ALL_MAX_CHARS) {
length += texts[--start]!.length + 1
}
// A single entry over the limit is still worth having, cut short.
const kept = start === texts.length ? [texts.at(-1)!.slice(0, COPY_ALL_MAX_CHARS)] : texts.slice(start)
const count = kept.length === texts.length ? `${kept.length}` : `the last ${kept.length} of ${texts.length}`
await this.#copy(kept.join('\n'), `copied ${count} ${texts.length === 1 ? 'entry' : 'entries'}`)
}

async #copy(text: string, done: string): Promise<void> {
// What lands on the clipboard is going into an issue, a search box or an
// agent's prompt, so it should carry no colour or hyperlink escapes.
try {
const { writeText } = await import('tinyclip')
// What lands on the clipboard is going into an issue or a search box,
// so it should carry no colour or hyperlink escapes.
await writeText(stripAnsi(text))
this.#notify('copied to clipboard')
this.notify(`${done} to clipboard`)
}
catch {
this.#notify('no clipboard available')
this.notify('no clipboard available')
}
}

#notify(text: string): void {
/** Replace the hint line with `text` for a moment. */
protected notify(text: string): void {
this.#notice = { text: ` ${text}`, until: Date.now() + NOTICE_MS }
this.render()
// Copying is asynchronous, and the view may have been closed meanwhile.
if (this.#open) {
this.render()
}
setTimeout(() => {
if (this.#open) {
this.render()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
Loading
Loading