From 260aa40bacba46964df42b8a3b560e2e864fe859 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 18 Sep 2026 17:00:46 +0200 Subject: [PATCH 1/2] 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 09d4b47d7..ef93adb55 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 4708eed9a..ba4dbb86e 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -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) 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 3daf6c9ea..9ddc59eb2 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 a77cc64c5..9d059e367 100644 --- a/packages/nuxt-cli/src/dev/tui/request-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/request-overlay.ts @@ -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) } @@ -152,6 +153,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 706e123fe..d087e39ac 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))) { @@ -314,24 +329,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 41404ca76..9fa77e1a8 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1326,6 +1326,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' })) @@ -1602,6 +1645,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 322dedb5363515fe2a830cf45a3e460085b71a28 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 18 Sep 2026 17:11:59 +0200 Subject: [PATCH 2/2] 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 d087e39ac..6b082340b 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -346,7 +346,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 9fa77e1a8..2d5e51492 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1661,6 +1661,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 = ''