From ef0cc1cf03eee49f283ec2d167a477cf8d06e281 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 18 Sep 2026 17:00:46 +0200 Subject: [PATCH 1/5] feat(dev): copy everything a view shows with `shift-y` --- docs/dev.md | 2 + packages/nuxt-cli/src/commands/info.ts | 143 ++++++++++-------- packages/nuxt-cli/src/dev/tui/index.ts | 5 + packages/nuxt-cli/src/dev/tui/info-overlay.ts | 26 +++- packages/nuxt-cli/src/dev/tui/overlay.ts | 3 +- .../nuxt-cli/src/dev/tui/request-overlay.ts | 4 +- .../nuxt-cli/src/dev/tui/route-overlay.ts | 3 +- packages/nuxt-cli/src/dev/tui/screen.ts | 70 ++++++++- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 78 ++++++++++ 9 files changed, 257 insertions(+), 77 deletions(-) diff --git a/docs/dev.md b/docs/dev.md index eb9dd09bc..ebae0b859 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -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) diff --git a/packages/nuxt-cli/src/commands/info.ts b/packages/nuxt-cli/src/commands/info.ts index 7df7817cd..ec28a2d7b 100644 --- a/packages/nuxt-cli/src/commands/info.ts +++ b/packages/nuxt-cli/src/commands/info.ts @@ -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>() - 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 = { 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)] @@ -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) @@ -173,6 +112,82 @@ export default defineCommand({ }, }) +export interface ProjectInfo { + /** Display label to value, in the order it is shown and pasted. */ + info: Record + configKeys: string[] + moduleNames: string[] + rootDir: string +} + +/** Everything a bug report asks for about the project in `cwd`. */ +export async function collectProjectInfo(cwd: string): Promise { + 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>() + 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, diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index feb028fc8..b81cdc1e4 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -140,6 +140,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) diff --git a/packages/nuxt-cli/src/dev/tui/info-overlay.ts b/packages/nuxt-cli/src/dev/tui/info-overlay.ts index ab2f03338..35a5db733 100644 --- a/packages/nuxt-cli/src/dev/tui/info-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/info-overlay.ts @@ -25,12 +25,15 @@ const VALUE_STYLES: Array<{ pattern: RegExp, style: Parameters export class InfoOverlay extends ScreenOverlay { #sections: () => InfoSection[] #panel: () => string | undefined + #report?: () => Promise 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, ) { super({ write, @@ -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 { + if (!this.#report) { + return undefined + } + this.notify('collecting project info…') + return this.#report() } protected get closeKeys(): readonly string[] { @@ -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) } } diff --git a/packages/nuxt-cli/src/dev/tui/overlay.ts b/packages/nuxt-cli/src/dev/tui/overlay.ts index d05c3dbdc..415324227 100644 --- a/packages/nuxt-cli/src/dev/tui/overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/overlay.ts @@ -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) } diff --git a/packages/nuxt-cli/src/dev/tui/request-overlay.ts b/packages/nuxt-cli/src/dev/tui/request-overlay.ts index 3cbf8a55b..1516e5685 100644 --- a/packages/nuxt-cli/src/dev/tui/request-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/request-overlay.ts @@ -132,7 +132,8 @@ export class RequestOverlay extends ScreenOverlay { if (this.#detail) { return formatHints([ ['↑/↓', 'select'], - ['enter', 'copy'], + ['y', 'copy'], + ['Y', 'copy all'], ['esc', 'back'], ], columns) } @@ -146,6 +147,7 @@ export class RequestOverlay extends ScreenOverlay { ['b', 'bundler'], ['/', 'search'], ['y', 'copy'], + ['Y', 'copy all'], ['q', 'close'], ], columns) } diff --git a/packages/nuxt-cli/src/dev/tui/route-overlay.ts b/packages/nuxt-cli/src/dev/tui/route-overlay.ts index 81e53d20c..b6f5bec65 100644 --- a/packages/nuxt-cli/src/dev/tui/route-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/route-overlay.ts @@ -108,7 +108,8 @@ export class RouteOverlay extends ScreenOverlay { ['s', 'server'], ['a', 'all'], ['/', 'search'], - ['enter', 'copy'], + ['y', 'copy'], + ['Y', 'copy all'], ['q', 'close'], ], columns) } diff --git a/packages/nuxt-cli/src/dev/tui/screen.ts b/packages/nuxt-cli/src/dev/tui/screen.ts index cde3825d6..d236369fa 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -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 = ' ' @@ -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 { + return undefined + } + get isOpen(): boolean { return this.#open } @@ -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))) { @@ -329,24 +344,63 @@ 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 { + let custom: string | undefined + try { + custom = await this.copyAllText() + } + catch { + this.notify('could not gather what to copy') + return + } + if (custom) { + return this.#copy(custom, '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 { + // 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() diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index cd2f08fbb..b174de1e6 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1480,6 +1480,49 @@ describe('log overlay', () => { expect(copied[0]).not.toContain('\u001B') }) + it('copies every entry the filters leave', async () => { + const events = new DevEventLog() + events.push(event({ message: 'all good', source: 'cli' })) + events.push(event({ message: 'boom\n at handler (server/api/x.ts:3:9)', level: 0, type: 'error', request: 'GET /x', requestId: 1, source: 'runtime' })) + events.push(event({ message: 'careful', level: 1, type: 'warn', tag: 'vite', source: 'build' })) + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'w' }) + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(copied).toHaveLength(1)) + const lines = copied[0]!.split('\n') + expect(lines[0]).toMatch(/GET \/x boom$/) + expect(lines[1]).toContain('at handler (server/api/x.ts:3:9)') + expect(lines[2]).toMatch(/careful$/) + expect(copied[0]).not.toContain('all good') + await vi.waitFor(() => expect(strip(lastFrame())).toContain('copied 2 entries to clipboard')) + }) + + it('keeps the newest entries when there are too many to paste', async () => { + const events = new DevEventLog() + for (let index = 0; index < 1500; index++) { + events.push(event({ message: `entry ${index} ${'x'.repeat(80)}`, source: 'runtime' })) + } + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(copied).toHaveLength(1)) + expect(copied[0]!.length).toBeLessThanOrEqual(60_000) + expect(copied[0]).toContain('entry 1499 ') + expect(copied[0]).not.toContain('entry 0 ') + await vi.waitFor(() => expect(strip(lastFrame())).toMatch(/copied the last \d+ of 1500 entries/)) + }) + + it('says so when there is nothing to copy at all', async () => { + const { overlay, lastFrame } = create(new DevEventLog()) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + await vi.waitFor(() => expect(strip(lastFrame())).toContain('nothing to copy')) + expect(copied).toHaveLength(0) + }) + it('says so when there is nothing selected to copy', async () => { const events = new DevEventLog() events.push(event({ message: 'anything' })) @@ -1756,6 +1799,41 @@ describe('info overlay', () => { expect(frame.indexOf('versions')).toBeLessThan(frame.indexOf('urls')) }) + it('copies the issue report rather than the rows it is showing', async () => { + copied.length = 0 + const overlay = new InfoOverlay( + () => [{ heading: 'urls', entries: [['local', 'http://localhost:3000/']] }], + () => {}, + () => {}, + () => 'QR-A\nQR-B', + async () => '| **Nuxt version** | `4.5.1` |', + ) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(copied).toHaveLength(1)) + expect(copied[0]).toBe('| **Nuxt version** | `4.5.1` |') + }) + + it('says so rather than copying its rows when the report cannot be gathered', async () => { + copied.length = 0 + let output = '' + const overlay = new InfoOverlay( + () => [{ heading: 'urls', entries: [['local', 'http://localhost:3000/']] }], + (chunk) => { + output += chunk + }, + () => {}, + () => 'QR-A\nQR-B', + () => Promise.reject(new Error('broken config')), + ) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(strip(output)).toContain('could not gather what to copy')) + expect(copied).toHaveLength(0) + }) + it('puts a side panel to the right when there is room', () => { let output = '' const overlay = new InfoOverlay( From f03b54a0beab40754fed6bc5825ba636ea349044 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 18 Sep 2026 17:11:59 +0200 Subject: [PATCH 2/5] fix(dev): cap a view's own copy text too --- packages/nuxt-cli/src/dev/tui/screen.ts | 3 ++- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-cli/src/dev/tui/screen.ts b/packages/nuxt-cli/src/dev/tui/screen.ts index d236369fa..fed2b4054 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -361,7 +361,8 @@ export abstract class ScreenOverlay { return } if (custom) { - return this.#copy(custom, 'copied') + // 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) { diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index b174de1e6..3a5b2215e 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1815,6 +1815,17 @@ describe('info overlay', () => { expect(copied[0]).toBe('| **Nuxt version** | `4.5.1` |') }) + it('holds the issue report to the same limit as any other copy', async () => { + copied.length = 0 + const overlay = new InfoOverlay(() => [], () => {}, () => {}, undefined, async () => `head${'x'.repeat(100_000)}`) + overlay.open() + overlay.handleKey({ name: 'y', sequence: 'Y' }) + + await vi.waitFor(() => expect(copied).toHaveLength(1)) + expect(copied[0]).toHaveLength(60_000) + expect(copied[0]!.startsWith('head')).toBe(true) + }) + it('says so rather than copying its rows when the report cannot be gathered', async () => { copied.length = 0 let output = '' From 1ee6a6a8fc646c9b970d86a617c04c098b2111e1 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 26 Sep 2026 11:55:54 +0000 Subject: [PATCH 3/5] fix(dev): gather the info report in a separate process --- packages/nuxt-cli/src/commands/info.ts | 155 +++++++++--------- packages/nuxt-cli/src/dev/tui/index.ts | 7 +- .../nuxt-cli/src/dev/tui/project-report.ts | 14 ++ .../test/unit/commands/info-run.spec.ts | 15 +- 4 files changed, 106 insertions(+), 85 deletions(-) create mode 100644 packages/nuxt-cli/src/dev/tui/project-report.ts diff --git a/packages/nuxt-cli/src/commands/info.ts b/packages/nuxt-cli/src/commands/info.ts index ec28a2d7b..91672b30f 100644 --- a/packages/nuxt-cli/src/commands/info.ts +++ b/packages/nuxt-cli/src/commands/info.ts @@ -60,14 +60,75 @@ export default defineCommand({ }, async run(ctx) { const cwd = resolveRootDir(ctx.args) - const { info: infoObj, configKeys, moduleNames, rootDir } = await collectProjectInfo(cwd) + 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>() + 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(', '), + } 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 = { config: configKeys, modules: moduleNames } const payload = JSON.stringify({ - rootDir, + rootDir: nuxtConfig.rootDir || cwd, ...Object.fromEntries(Object.entries(infoObj).map(([label, value]) => { const key = JSON_KEYS[label] ?? camelCase(label) return [key, lists[key] ?? (value?.replaceAll('`', '') || null)] @@ -77,7 +138,7 @@ export default defineCommand({ return } - logger.info(`Nuxt root directory: ${styleText('cyan', rootDir)}\n`) + logger.info(`Nuxt root directory: ${styleText('cyan', nuxtConfig.rootDir || cwd)}\n`) const boxStr = formatInfoBox(infoObj) @@ -112,82 +173,6 @@ export default defineCommand({ }, }) -export interface ProjectInfo { - /** Display label to value, in the order it is shown and pasted. */ - info: Record - configKeys: string[] - moduleNames: string[] - rootDir: string -} - -/** Everything a bug report asks for about the project in `cwd`. */ -export async function collectProjectInfo(cwd: string): Promise { - 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>() - 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, @@ -210,6 +195,18 @@ async function resolveDependencyVersion( ?? devDependencies[name] } +/** Render `nuxt info --json` output as the Markdown table `nuxt info` copies. */ +export function formatJsonAsMarkdownTable(json: Record): string { + const labels = Object.fromEntries(Object.entries(JSON_KEYS).map(([label, key]) => [key, label])) + const info: Record = {} + for (const [key, value] of Object.entries(json)) { + if (labels[key]) { + info[labels[key]] = Array.isArray(value) ? value.map(item => `\`${item}\``).join(', ') : (value as string | null) ?? undefined + } + } + return formatMarkdownTable(info) +} + export function formatMarkdownTable(info: Record): string { const entries = Object.entries(info).map(([label, value]) => [label, value || '-'] as const) const labelWidth = Math.max(...entries.map(([label]) => label.length + 4)) diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index b81cdc1e4..6ac1acf06 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -31,6 +31,7 @@ import { attachKeys } from './keys' import { LOGO_FRAME_MS } from './logo' import { LogOverlay } from './overlay' import { describeListenURLs, URL_LABELS, URL_STYLES } from './panel' +import { readProjectReport } from './project-report' import { RequestOverlay } from './request-overlay' import { RequestLog } from './requests' import { RouteOverlay } from './route-overlay' @@ -140,11 +141,7 @@ 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) - }, + () => readProjectReport(cwd), ) const views = [overlay, trafficOverlay, routeOverlay, helpOverlay, infoOverlay] const openOverlay = () => views.find(view => view.isOpen) diff --git a/packages/nuxt-cli/src/dev/tui/project-report.ts b/packages/nuxt-cli/src/dev/tui/project-report.ts new file mode 100644 index 000000000..f63d92847 --- /dev/null +++ b/packages/nuxt-cli/src/dev/tui/project-report.ts @@ -0,0 +1,14 @@ +import { execFile } from 'node:child_process' +import process from 'node:process' +import { promisify } from 'node:util' + +/** The `nuxt info` table for the project in `cwd`, gathered in a separate process. */ +export async function readProjectReport(cwd: string): Promise { + const { stdout } = await promisify(execFile)( + process.execPath, + [globalThis.__nuxt_cli__!.entry, 'info', '--json', cwd], + { cwd, timeout: 30_000 }, + ) + const { formatJsonAsMarkdownTable } = await import('../../commands/info') + return formatJsonAsMarkdownTable(JSON.parse(stdout)) +} diff --git a/packages/nuxt-cli/test/unit/commands/info-run.spec.ts b/packages/nuxt-cli/test/unit/commands/info-run.spec.ts index 6a4729f1b..d2ef904b8 100644 --- a/packages/nuxt-cli/test/unit/commands/info-run.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/info-run.spec.ts @@ -6,7 +6,7 @@ import { runCommand } from 'citty' import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import info from '../../../src/commands/info' +import info, { formatJsonAsMarkdownTable } from '../../../src/commands/info' import { render, screen } from '../../utils/terminal' vi.mock('tinyclip', () => ({ writeText: () => Promise.reject(new Error('no clipboard')) })) @@ -83,6 +83,19 @@ describe('info command', () => { expect(payload.modules).toEqual(['./modules/a, b.ts']) }) + it('should render `--json` output as the table it prints', async () => { + await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'app', private: true })) + await writeFile(join(cwd, 'nuxt.config.mjs'), `export default { modules: ['@nuxt/image'], app: {} }`) + + const table = formatJsonAsMarkdownTable(await runInfoJSON()) + vi.restoreAllMocks() + const output = await runInfo() + + for (const row of table.trim().split('\n')) { + expect(output).toContain(row) + } + }) + it('should still report on a project with no config', async () => { await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'app', private: true })) From e06243ed1b3fdc676d94ac13fbfaaa614f41371a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 26 Sep 2026 11:55:55 +0000 Subject: [PATCH 4/5] refactor(dev): trim copy-all comments and repaint only open views --- docs/dev.md | 2 +- packages/nuxt-cli/src/dev/tui/info-overlay.ts | 8 +---- packages/nuxt-cli/src/dev/tui/screen.ts | 33 ++++--------------- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 18 +++++++--- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/docs/dev.md b/docs/dev.md index ebae0b859..2511b5851 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -88,7 +88,7 @@ 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. +Inside a view, `y` copies the selected row and `shift-y` copies every row the filters and search leave, keeping the newest when there is too much to paste. In the info view, `shift-y` copies the [`nuxt info`](/docs/api/commands/info) table instead. 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. diff --git a/packages/nuxt-cli/src/dev/tui/info-overlay.ts b/packages/nuxt-cli/src/dev/tui/info-overlay.ts index 35a5db733..ae306b8ee 100644 --- a/packages/nuxt-cli/src/dev/tui/info-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/info-overlay.ts @@ -32,7 +32,7 @@ export class InfoOverlay extends ScreenOverlay { write: (chunk: string) => void, onClose: () => void, panel: () => string | undefined = () => undefined, - /** What belongs in a bug report, which is not what the view shows. */ + /** The text `Y` copies in place of the rows. */ report?: () => Promise, ) { super({ @@ -50,11 +50,6 @@ export class InfoOverlay extends ScreenOverlay { 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 { if (!this.#report) { return undefined @@ -85,7 +80,6 @@ export class InfoOverlay extends ScreenOverlay { return withSidePanel(rows, this.#panel(), columns).map(line => ({ lines: [line], - // A single value is what `y` is for; `Y` copies the issue report. copy: stripAnsi(line).trim().split(/\s{2,}/).at(-1), })) } diff --git a/packages/nuxt-cli/src/dev/tui/screen.ts b/packages/nuxt-cli/src/dev/tui/screen.ts index fed2b4054..11ac3943c 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -12,11 +12,7 @@ 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. - */ +/** Most characters copying a whole view puts on the clipboard, keeping the newest entries. */ const COPY_ALL_MAX_CHARS = 60_000 /** Marks the selected entry; the same width is reserved on every row. */ @@ -84,10 +80,7 @@ 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. - */ + /** Text `Y` copies instead of every entry's own. */ protected copyAllText(): Promise | string | undefined { return undefined } @@ -350,7 +343,6 @@ export abstract class ScreenOverlay { await this.#copy(text, 'copied') } - /** Copy everything the view is showing, filters and search applied. */ async #copyAll(): Promise { let custom: string | undefined try { @@ -361,32 +353,28 @@ export abstract class ScreenOverlay { 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') + return this.#copy(custom, '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 { - // 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(`${done} to clipboard`) } @@ -398,15 +386,8 @@ export abstract class ScreenOverlay { /** Replace the hint line with `text` for a moment. */ protected notify(text: string): void { this.#notice = { text: ` ${text}`, until: Date.now() + NOTICE_MS } - // Copying is asynchronous, and the view may have been closed meanwhile. - if (this.#open) { - this.render() - } - setTimeout(() => { - if (this.#open) { - this.render() - } - }, NOTICE_MS + 50).unref?.() + this.repaint() + setTimeout(() => this.repaint(), NOTICE_MS + 50).unref?.() } #scheduleRender(): void { diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 3a5b2215e..34363af8c 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1815,15 +1815,23 @@ describe('info overlay', () => { expect(copied[0]).toBe('| **Nuxt version** | `4.5.1` |') }) - it('holds the issue report to the same limit as any other copy', async () => { + it('does not draw once closed while the report is gathered', async () => { copied.length = 0 - const overlay = new InfoOverlay(() => [], () => {}, () => {}, undefined, async () => `head${'x'.repeat(100_000)}`) + let output = '' + let finish!: (text: string) => void + const overlay = new InfoOverlay(() => [], (chunk) => { + output += chunk + }, () => {}, undefined, () => new Promise((resolve) => { + finish = resolve + })) overlay.open() overlay.handleKey({ name: 'y', sequence: 'Y' }) + overlay.close() + const closedAt = output.length + finish('| report |') - await vi.waitFor(() => expect(copied).toHaveLength(1)) - expect(copied[0]).toHaveLength(60_000) - expect(copied[0]!.startsWith('head')).toBe(true) + await vi.waitFor(() => expect(copied).toEqual(['| report |'])) + expect(output.length).toBe(closedAt) }) it('says so rather than copying its rows when the report cannot be gathered', async () => { From 28fb8c1bd543f7c6a7a271f923d7d131777cf7b2 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 26 Sep 2026 12:56:13 +0000 Subject: [PATCH 5/5] fix(dev): cap custom copy text, fix fallback cli entry and list view keys in help --- packages/nuxi/src/run.ts | 2 +- packages/nuxt-cli/src/dev/tui/help-overlay.ts | 17 ++++++++++++++--- packages/nuxt-cli/src/dev/tui/screen.ts | 2 +- packages/nuxt-cli/src/run.ts | 2 +- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 9 ++++++++- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/nuxi/src/run.ts b/packages/nuxi/src/run.ts index e1c6a8ad6..7e8a4ea69 100644 --- a/packages/nuxi/src/run.ts +++ b/packages/nuxi/src/run.ts @@ -4,7 +4,7 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { // Programmatic usage fallback startTime: Date.now(), entry: fileURLToPath( - new URL('../../bin/nuxi.mjs', import.meta.url), + new URL('../bin/nuxi.mjs', import.meta.url), ), } diff --git a/packages/nuxt-cli/src/dev/tui/help-overlay.ts b/packages/nuxt-cli/src/dev/tui/help-overlay.ts index c56e02b55..480446ee7 100644 --- a/packages/nuxt-cli/src/dev/tui/help-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/help-overlay.ts @@ -13,6 +13,12 @@ export interface HelpEntry { description: string } +const VIEW_ENTRIES: HelpEntry[] = [ + { keys: ['y', 'enter'], description: 'copy the selected line' }, + { keys: ['Y'], description: 'copy the whole view' }, + { keys: ['/'], description: 'search' }, +] + /** The keyboard shortcuts, as a view rather than a wall of log output. */ export class HelpOverlay extends ScreenOverlay { #entries: () => HelpEntry[] @@ -32,10 +38,15 @@ export class HelpOverlay extends ScreenOverlay { protected renderEntries(): OverlayEntry[] { const entries = this.#entries() - const width = Math.max(...entries.map(entry => formatKeys(entry).length)) - return entries.map(entry => ({ + const width = Math.max(...[...entries, ...VIEW_ENTRIES].map(entry => formatKeys(entry).length)) + const row = (entry: HelpEntry): OverlayEntry => ({ lines: [`${styleText('bold', formatKeys(entry).padEnd(width))} ${styleText(MUTED, entry.description)}`], - })) + }) + return [ + ...entries.map(row), + { lines: ['', styleText('bold', 'in a view')] }, + ...VIEW_ENTRIES.map(row), + ] } protected renderHints(columns: number): string { diff --git a/packages/nuxt-cli/src/dev/tui/screen.ts b/packages/nuxt-cli/src/dev/tui/screen.ts index 11ac3943c..25d3d4956 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -353,7 +353,7 @@ export abstract class ScreenOverlay { return } if (custom) { - return this.#copy(custom, 'copied') + 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) { diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index aad0501c4..9d0b2c7c0 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -12,7 +12,7 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { // Programmatic usage fallback startTime: Date.now(), entry: fileURLToPath( - new URL('../../bin/nuxi.mjs', import.meta.url), + new URL('../bin/nuxi.mjs', import.meta.url), ), devEntry: fileURLToPath( new URL('../dev/index.mjs', import.meta.url), diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 34363af8c..e0179c7b8 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1483,7 +1483,7 @@ describe('log overlay', () => { it('copies every entry the filters leave', async () => { const events = new DevEventLog() events.push(event({ message: 'all good', source: 'cli' })) - events.push(event({ message: 'boom\n at handler (server/api/x.ts:3:9)', level: 0, type: 'error', request: 'GET /x', requestId: 1, source: 'runtime' })) + events.push(event({ message: 'boom\n at handler (server/api/x.ts:3:9)', level: 0, type: 'error', request: 'GET /x', requestId: '1', source: 'runtime' })) events.push(event({ message: 'careful', level: 1, type: 'warn', tag: 'vite', source: 'build' })) const { overlay, lastFrame } = create(events) overlay.open() @@ -1755,6 +1755,13 @@ describe('help overlay', () => { expect(lastFrame()).toContain('restart the dev server') }) + it('lists the keys available inside a view', () => { + const { overlay, lastFrame } = create() + overlay.open() + expect(lastFrame()).toContain('in a view') + expect(lastFrame()).toMatch(/shift-y\s+copy the whole view/) + }) + it('closes on h as well as q', () => { const { overlay, closed } = create() overlay.open()