From b90f7e08f230077309c7bf1f74bbd9384dbe5c40 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 11:42:46 +0000 Subject: [PATCH 1/6] feat(dev): answer shortcuts before the server is up --- packages/nuxt-cli/src/commands/dev.ts | 14 ++-- packages/nuxt-cli/src/dev/shortcut-context.ts | 68 ++++++++++++++++++ packages/nuxt-cli/src/dev/shortcuts.ts | 24 +++---- packages/nuxt-cli/src/dev/tui/index.ts | 58 +++++++++++---- packages/nuxt-cli/src/dev/tui/panel.ts | 6 +- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 70 ++++++++++++++++++- .../test/unit/shortcut-context.spec.ts | 58 +++++++++++++++ 7 files changed, 266 insertions(+), 32 deletions(-) create mode 100644 packages/nuxt-cli/src/dev/shortcut-context.ts create mode 100644 packages/nuxt-cli/test/unit/shortcut-context.spec.ts diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index b9c85378b..6b1e261ad 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -19,6 +19,7 @@ import { isReusePortSupported, parsePort } from '../dev/listen' import { ForkPool } from '../dev/pool' import { preflight } from '../dev/preflight' import { formatRestartReason } from '../dev/reason' +import { deferShortcutContext } from '../dev/shortcut-context' import { SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown' import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover' import { beginDevUI, setupDevUI } from '../dev/tui/controller' @@ -231,6 +232,10 @@ const command = defineCommand({ listenOverrides.showURL = false } + const { context: shortcutContext, attach: attachServer } = deferShortcutContext({ clearCaches }) + const startingUI = ui ? await setupDevUI(shortcutContext, { ...uiOptions, enabled: true }) : undefined + setupSignalHandlers(() => shortcutContext.close()) + const started = await initialize({ cwd, args: ctx.args, handoverFrom: takeover.action === 'taken' ? takeover.pid : undefined }, { data: ctx.data, listenOverrides, @@ -262,8 +267,8 @@ const command = defineCommand({ // Disable forking when profiling to capture all activity in one process if (!ctx.args.fork || profiling) { - attachDevUI(await setupDevUI({ listener, close, onReady, clearCaches, restart: () => reload({ type: 'shortcut' }) }, { ...uiOptions, enabled: ui })) - setupSignalHandlers(close) + attachServer({ listener, close, onReady, restart: () => reload({ type: 'shortcut' }) }) + attachDevUI(startingUI ?? await setupDevUI(shortcutContext, { ...uiOptions, enabled: ui })) return { listener, close, @@ -290,7 +295,8 @@ const command = defineCommand({ pool.startWarming() }) - const devUI = attachDevUI(await setupDevUI({ listener, close: () => closeAll(), onReady, clearCaches, restart: () => restart({ type: 'shortcut' }) }, { ...uiOptions, enabled: ui })) + attachServer({ listener, close: () => closeAll(), onReady, restart: () => restart({ type: 'shortcut' }) }) + const devUI = attachDevUI(startingUI ?? await setupDevUI(shortcutContext, { ...uiOptions, enabled: ui })) // Whatever is serving the app right now: this process, then each fork in turn. let closeCurrent = close let currentPid = process.pid @@ -435,8 +441,6 @@ const command = defineCommand({ await close() } - setupSignalHandlers(closeAll) - return { close: closeAll, } diff --git a/packages/nuxt-cli/src/dev/shortcut-context.ts b/packages/nuxt-cli/src/dev/shortcut-context.ts new file mode 100644 index 000000000..a6b5931b4 --- /dev/null +++ b/packages/nuxt-cli/src/dev/shortcut-context.ts @@ -0,0 +1,68 @@ +import type { Listener } from './listen' + +export interface ShortcutContext { + /** The bound server, once there is one. */ + listener?: Listener + close: () => Promise + restart?: () => void | Promise + /** Remove the caches that make the next start cold, naming what went. */ + clearCaches?: () => Promise + onReady: (callback: (address: string) => void) => void +} + +/** What a started dev server contributes to a {@link ShortcutContext}. */ +interface ShortcutServer { + listener: Listener + close: () => Promise + restart?: () => void | Promise + onReady: (callback: (address: string) => void) => void +} + +export interface DeferredShortcutContext { + context: ShortcutContext + attach: (server: ShortcutServer) => void +} + +/** + * A context for shortcuts bound before the dev server exists, so the keyboard + * answers from the first frame. {@link ShortcutContext.listener} is undefined + * until {@link DeferredShortcutContext.attach}, and ready callbacks registered + * before then are forwarded to the server when it arrives. + */ +export function deferShortcutContext(options: Pick = {}): DeferredShortcutContext { + let server: ShortcutServer | undefined + let closing: Promise | undefined + const pendingReady: Array<(address: string) => void> = [] + + return { + context: { + clearCaches: options.clearCaches, + get listener() { + return server?.listener + }, + get restart() { + return server?.restart + }, + close: () => closing ??= server?.close() ?? Promise.resolve(), + onReady: (callback) => { + if (server) { + server.onReady(callback) + } + else { + pendingReady.push(callback) + } + }, + }, + attach: (started) => { + // A shutdown started before this existed had nothing to close. + if (closing) { + closing = closing.then(() => started.close()) + return + } + server = started + for (const callback of pendingReady.splice(0)) { + started.onReady(callback) + } + }, + } +} diff --git a/packages/nuxt-cli/src/dev/shortcuts.ts b/packages/nuxt-cli/src/dev/shortcuts.ts index 46d1cd3de..848f31d29 100644 --- a/packages/nuxt-cli/src/dev/shortcuts.ts +++ b/packages/nuxt-cli/src/dev/shortcuts.ts @@ -1,4 +1,5 @@ import type { Listener } from './listen' +import type { ShortcutContext } from './shortcut-context' import process from 'node:process' import { createInterface } from 'node:readline' @@ -9,14 +10,7 @@ import { isCI, isTest } from 'std-env' import { restoreRawMode, withDirectStdout } from '../utils/console' import { copyURL, openBrowser, printQRCode } from './listen' -export interface ShortcutContext { - listener: Listener - close: () => Promise - restart?: () => void | Promise - /** Remove the caches that make the next start cold, naming what went. */ - clearCaches?: () => Promise - onReady: (callback: (address: string) => void) => void -} +export type { ShortcutContext } from './shortcut-context' interface ActionContext extends ShortcutContext { /** Stop reading shortcuts, so a quitting server does not keep stdin open. */ @@ -40,29 +34,33 @@ const shortcuts: Shortcut[] = [ { keys: ['o', 'open'], description: 'open in browser', - action: context => openBrowser(context.listener.url), + isAvailable: context => !!context.listener, + action: context => context.listener && openBrowser(context.listener.url), }, { keys: ['u', 'urls'], description: 'show server URLs', - action: context => context.listener.showURLs(), + isAvailable: context => !!context.listener, + action: context => context.listener?.showURLs(), }, { keys: ['qr'], description: 'show a QR code for the server URL', - action: context => printQRCode(resolveShareableURL(context.listener), { showURL: true }), + isAvailable: context => !!context.listener, + action: context => context.listener && printQRCode(resolveShareableURL(context.listener), { showURL: true }), }, { keys: ['copy'], description: 'copy the server URL to the clipboard', - action: context => copyURL(resolveShareableURL(context.listener)), + isAvailable: context => !!context.listener, + action: context => context.listener && copyURL(resolveShareableURL(context.listener)), }, { keys: ['c', 'clear'], description: 'clear the console', action: async (context) => { await withDirectStdout(() => process.stdout.write('\u001B[2J\u001B[3J\u001B[H')) - context.listener.showURLs() + context.listener?.showURLs() }, }, { diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index feb9f57e1..4f48324a1 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -68,6 +68,8 @@ interface UIShortcut { */ sequence?: string description: string + /** Whether the shortcut is waiting on the server before it can act. */ + isArmed?: () => boolean action: () => void } @@ -121,6 +123,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) }) let shortcuts: UIShortcut[] = [] let qrCode: string | undefined + let armedOpen = false const helpOverlay = new HelpOverlay(() => shortcuts, write, release) const infoOverlay = new InfoOverlay( () => describeSession(context, cwd, requests, sessionStart, state.update, state.updateLink), @@ -315,6 +318,11 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) }) context.onReady(() => { + if (armedOpen && context.listener) { + armedOpen = false + openBrowser(context.listener.url) + syncHints() + } // Whether anything is still being waited for is progress's to say: a ready // listener only knows the socket is up, and a server nobody has asked for a // page yet is not warming up, it is idle. @@ -379,7 +387,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) shortcuts = [ { keys: ['r'], ctrl: 'r', hint: 'restart', priority: 80, description: 'restart the dev server', action: () => void restart() }, { keys: ['R'], sequence: 'R', description: 'restart with a cleared cache', action: () => void restart({ clearCache: true }) }, - { keys: ['o'], hint: 'open', priority: 40, description: 'open in browser', action: () => void openBrowser(context.listener.url) }, + { keys: ['o'], hint: 'open', priority: 40, description: 'open in browser', isArmed: () => armedOpen, action: () => open() }, { keys: ['y'], description: 'copy the server URL to the clipboard', action: () => void copyURL(context, showNotice) }, { keys: ['c'], ctrl: 'l', description: 'clear logs, requests and the console', action: () => { clearHistory() @@ -399,12 +407,31 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) { keys: ['q'], ctrl: 'd', hint: 'quit', priority: 90, description: 'quit', action: () => state.status === 'ready' ? quit() : update({ confirmQuit: true }) }, ] - update({ - hints: shortcuts - .filter((shortcut): shortcut is UIShortcut & { hint: string, priority: number } => !!shortcut.hint) - .map(({ keys, hint, priority }) => ({ key: keys[0]!, label: hint, priority })), - hintsDimmed: false, - }) + /** Open the app, or arm the shortcut so a starting server opens once it is up. */ + function open(): void { + if (context.listener) { + openBrowser(context.listener.url) + return + } + armedOpen = !armedOpen + syncHints() + } + + function syncHints(): void { + update({ + hints: shortcuts + .filter((shortcut): shortcut is UIShortcut & { hint: string, priority: number } => !!shortcut.hint) + .map(({ keys, hint, priority, isArmed }) => ({ + key: keys[0]!, + label: hint, + priority, + armed: isArmed?.(), + })), + hintsDimmed: false, + }) + } + + syncHints() function openView(view: { open: () => void }): void { surface.screenMode = 'alternate-screen' @@ -694,7 +721,11 @@ function clearConsole(surface: PanelSurface): void { } async function copyURL(context: ShortcutContext, notify: (text: string, tone: 'info' | 'warn' | 'success') => void): Promise { - const url = context.listener.publicURL || context.listener.url + const url = context.listener?.publicURL || context.listener?.url + if (!url) { + notify('no server to copy the url of yet', 'warn') + return + } try { const { writeText } = await import('tinyclip') await writeText(url) @@ -708,6 +739,9 @@ async function copyURL(context: ShortcutContext, notify: (text: string, tone: 'i /** The URL block, in the order a user is most likely to want them. */ function describeURLs(context: ShortcutContext): PanelURL[] { const { listener } = context + if (!listener) { + return [] + } const urls: PanelURL[] = describeListenURLs(listener.getURLs()) if (listener.publicURL && !urls.some(entry => entry.url === listener.publicURL)) { urls.push({ label: URL_LABELS.public, url: listener.publicURL, link: terminalLink(listener.publicURL, listener.publicURL), style: URL_STYLES.public }) @@ -744,8 +778,8 @@ function describeSession( { heading: 'urls', entries: [ - ...listener.getURLs().map(({ type, url }) => [type, url, URL_STYLES[type]] as InfoSection['entries'][number]), - ['public', listener.publicURL, URL_STYLES.public], + ...listener?.getURLs().map(({ type, url }) => [type, url, URL_STYLES[type]] as InfoSection['entries'][number]) ?? [], + ['public', listener?.publicURL, URL_STYLES.public], ], }, { @@ -780,8 +814,8 @@ function linkVersion(version: string): string { /** A QR code for whichever URL another device could reach, if any. */ async function resolveQRCode(context: ShortcutContext): Promise { - const url = context.listener.qrURL - || context.listener.getURLs().find(({ type }) => type !== 'local')?.url + const url = context.listener?.qrURL + || context.listener?.getURLs().find(({ type }) => type !== 'local')?.url if (!url) { return undefined } diff --git a/packages/nuxt-cli/src/dev/tui/panel.ts b/packages/nuxt-cli/src/dev/tui/panel.ts index ac66ffac3..fdf56c315 100644 --- a/packages/nuxt-cli/src/dev/tui/panel.ts +++ b/packages/nuxt-cli/src/dev/tui/panel.ts @@ -39,6 +39,8 @@ export interface PanelHint { label: string /** Higher survives longer when the line is too narrow. */ priority: number + /** The shortcut is waiting on the server, and fires as soon as it is up. */ + armed?: boolean } /** @@ -405,7 +407,9 @@ function renderHints(state: PanelState, columns: number): string { ...state.hints ?? [], ] const render = (items: PanelHint[]) => ` ${items - .map(({ key, label }) => `${styleText(state.hintsDimmed ? MUTED : 'bold', key)} ${styleText(MUTED, label)}`) + .map(({ key, label, armed }) => armed + ? `${paint('brand', key, state.background)} ${paint('brand', label, state.background)}` + : `${styleText(state.hintsDimmed ? MUTED : 'bold', key)} ${styleText(MUTED, label)}`) .join(SEPARATOR)}` while (remaining.length > 1 && visibleWidth(render(remaining)) > columns) { diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 5557286b8..dc1e7fcec 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -9,6 +9,7 @@ import { consola } from 'consola' import { beforeEach, describe, expect, it, vi } from 'vitest' import { currentRequest, isServingRequest, runWithRequest } from '../../src/dev/serving-state' +import { deferShortcutContext } from '../../src/dev/shortcut-context' import { DevEventLog, noteRoute } from '../../src/dev/tui/events' import { HelpOverlay } from '../../src/dev/tui/help-overlay' import { beginDevUI, setupDevUI } from '../../src/dev/tui/index' @@ -32,6 +33,12 @@ import { paint, resolveBackground } from '../../src/utils/terminal-theme' import { releaseNotesUrl } from '../../src/utils/update-check' import { render, screen } from '../utils/terminal' +const opened: string[] = [] +vi.mock('../../src/dev/listen', async importOriginal => ({ + ...await importOriginal(), + openBrowser: (url: string) => void opened.push(url), +})) + const copied: string[] = [] vi.mock('tinyclip', () => ({ writeText: (text: string) => { @@ -260,6 +267,19 @@ describe('dev tui panel', () => { } }) + it('paints an armed hint in the brand colour', () => { + vi.stubEnv('FORCE_COLOR', '3') + try { + const hints = [{ key: 'o', label: 'open', priority: 40, armed: true }] + const armed = renderPanel({ ...READY, hints, background: 'dark' }, 100, 30).at(-1)! + expect(armed).toContain(paint('brand', 'open', 'dark')) + expect(renderPanel({ ...READY, background: 'dark' }, 100, 30).at(-1)!).not.toContain(paint('brand', 'open', 'dark')) + } + finally { + vi.unstubAllEnvs() + } + }) + it('spins on a bound URL until it is confirmed', () => { const pending = renderPanel({ ...READY, status: 'starting', readyMs: undefined, urls: [{ label: 'Local', url: 'http://localhost:3000/', pending: true }], frame: 0 }, 100, 30).map(strip) expect(pending.find(line => line.includes('localhost'))).toContain('\u280B') @@ -2561,7 +2581,8 @@ async function withPanel(run: (ui: ReturnType, settle: () => return true }) const session = beginDevUI({ ci: false, test: false, version: '4.5.2' })! - const ui = setupDevUI({ ...context, ...overrides } as never, { ci: false, test: false, version: '4.5.2' }) + // Passed whole: spreading a deferred context would read its listener getter. + const ui = setupDevUI((overrides.context ?? { ...context, ...overrides }) as never, { ci: false, test: false, version: '4.5.2' }) try { await run(ui, async () => { // The panel repaints on a trailing timer, so nothing is on screen yet. @@ -2859,6 +2880,53 @@ describe('request failures on the panel', () => { }) }) + describe('opening before the server is up', () => { + const listener = { url: 'http://localhost:3000/', getURLs: () => [], showURLs: () => {} } + + async function withStartingPanel(run: (ready: () => void, settle: () => Promise) => Promise) { + opened.length = 0 + const { context: deferred, attach } = deferShortcutContext() + const ready = () => attach({ + listener: listener as never, + close: async () => {}, + onReady: callback => callback(listener.url), + }) + await withPanel(async (_ui, settle) => { + await settle() + await run(ready, settle) + }, { context: deferred }) + } + + it('should open once the server is up when `o` was pressed while starting', async () => { + await withStartingPanel(async (ready, settle) => { + process.stdin.emit('keypress', 'o', { name: 'o', sequence: 'o' }) + expect(opened).toEqual([]) + expect(strip(await settle())).toContain('o open') + + ready() + expect(opened).toEqual(['http://localhost:3000/']) + }) + }) + + it('should disarm the open shortcut when it is pressed again', async () => { + await withStartingPanel(async (ready) => { + process.stdin.emit('keypress', 'o', { name: 'o', sequence: 'o' }) + process.stdin.emit('keypress', 'o', { name: 'o', sequence: 'o' }) + + ready() + expect(opened).toEqual([]) + }) + }) + + it('should open at once when the server is already up', async () => { + await withStartingPanel(async (ready) => { + ready() + process.stdin.emit('keypress', 'o', { name: 'o', sequence: 'o' }) + expect(opened).toEqual(['http://localhost:3000/']) + }) + }) + }) + it('should keep a failed load on the panel across a restart that does not fix it', async () => { let restarts = 0 await withPanel(async (ui, settle) => { diff --git a/packages/nuxt-cli/test/unit/shortcut-context.spec.ts b/packages/nuxt-cli/test/unit/shortcut-context.spec.ts new file mode 100644 index 000000000..c3eceb8b6 --- /dev/null +++ b/packages/nuxt-cli/test/unit/shortcut-context.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { deferShortcutContext } from '../../src/dev/shortcut-context' + +function server(overrides: Record = {}) { + const started = { + listener: { url: 'http://localhost:3000/' }, + close: vi.fn(async () => {}), + onReady: vi.fn(), + ...overrides, + } + return started as typeof started & Parameters['attach']>[0] +} + +describe('deferShortcutContext', () => { + it('should publish the server once it is attached', () => { + const { context, attach } = deferShortcutContext() + expect(context.listener).toBeUndefined() + + attach(server()) + + expect(context.listener).toEqual({ url: 'http://localhost:3000/' }) + }) + + it('should forward ready callbacks registered before there was a server', () => { + const { context, attach } = deferShortcutContext() + const ready = vi.fn() + context.onReady(ready) + + const started = server() + attach(started) + + expect(started.onReady).toHaveBeenCalledWith(ready) + }) + + it('should close a server attached after a shutdown had already begun', async () => { + const { context, attach } = deferShortcutContext() + + const closed = context.close() + const started = server() + attach(started) + await closed + await context.close() + + expect(started.close).toHaveBeenCalledTimes(1) + expect(context.listener).toBeUndefined() + }) + + it('should close an attached server once, however often it is asked', async () => { + const { context, attach } = deferShortcutContext() + const started = server() + attach(started) + + await Promise.all([context.close(), context.close()]) + + expect(started.close).toHaveBeenCalledTimes(1) + }) +}) From 8a19b3f53228e7d29e780da8013117c1d702994c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 11:42:55 +0000 Subject: [PATCH 2/6] fix(dev): stop a fork opening the browser again --- packages/nuxt-cli/src/commands/dev.ts | 3 ++- packages/nuxt-cli/test/unit/commands/dev-run.spec.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index 6b1e261ad..76dafe0af 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -278,7 +278,8 @@ const command = defineCommand({ const pool = new ForkPool({ rawArgs: ctx.rawArgs, poolSize: resolveForkPoolSize(), - listenOverrides, + // This process has already opened the browser; a fork taking over must not. + listenOverrides: { ...listenOverrides, open: false, openURL: undefined }, inspect, pipeOutput: ui, }) diff --git a/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts b/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts index db2166293..a2c94a73c 100644 --- a/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts @@ -43,7 +43,10 @@ vi.mock('../../../src/dev/listen', async importOriginal => ({ isReusePortSupported, })) vi.mock('../../../src/dev/preflight', () => ({ preflight })) -vi.mock('../../../src/dev/shortcuts', () => ({ setupShortcuts })) +vi.mock('../../../src/dev/shortcuts', async importOriginal => ({ + ...await importOriginal(), + setupShortcuts, +})) vi.mock('../../../src/utils/dev-server', () => ({ resolveLockDir: (cwd: string) => Promise.resolve(`${cwd}/.nuxt`) })) vi.mock('../../../src/dev/takeover', async importOriginal => ({ ...await importOriginal(), @@ -230,6 +233,12 @@ describe('dev command fork pool', () => { expect(createFork.mock.calls[0]![0]).toMatchObject({ listenOverrides: expect.objectContaining({ port: '4002' }) }) }) + it('should not let a fork open the browser again', async () => { + await runDev(['--fork', '--open', '--open.url=/about']) + + expect(createFork.mock.calls[0]![0]).toMatchObject({ listenOverrides: expect.objectContaining({ open: false, openURL: undefined }) }) + }) + it('should replace the current server with a fork on a hard restart', async () => { const forkClose = vi.fn(() => Promise.resolve()) getFork.mockResolvedValue({ pid: 999, serving: Promise.resolve(), promote: vi.fn(), close: forkClose }) From ac21208314ad3ad5d2d537d454511567a8be4f95 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 11:43:51 +0000 Subject: [PATCH 3/6] fix(dev): keep terminal replies out of the keys the panel reads --- packages/nuxt-cli/src/dev/tui/background.ts | 66 ++++---- packages/nuxt-cli/src/dev/tui/keys.ts | 70 ++++++--- .../nuxt-cli/src/dev/tui/terminal-replies.ts | 105 +++++++++++++ .../nuxt-cli/test/unit/dev-background.spec.ts | 81 ++++++++-- packages/nuxt-cli/test/unit/dev-keys.spec.ts | 146 ++++++++++++++++++ 5 files changed, 401 insertions(+), 67 deletions(-) create mode 100644 packages/nuxt-cli/src/dev/tui/terminal-replies.ts create mode 100644 packages/nuxt-cli/test/unit/dev-keys.spec.ts diff --git a/packages/nuxt-cli/src/dev/tui/background.ts b/packages/nuxt-cli/src/dev/tui/background.ts index ff369200d..064a3b8f6 100644 --- a/packages/nuxt-cli/src/dev/tui/background.ts +++ b/packages/nuxt-cli/src/dev/tui/background.ts @@ -49,14 +49,15 @@ export interface QueryBackgroundOptions { } let pending: Promise | undefined -let release: (() => void) | undefined +let held: Promise | undefined /** - * Give stdin back to a caller that needs it now, without waiting for a terminal - * that may still be thinking. Returns once stdin is as the query found it. + * Settles when the query has given stdin back, or is undefined if nothing is + * holding it. A key reader must wait for this, or it parses the terminal's + * escape-sequence answer as a run of keystrokes. */ -export function stopBackgroundQuery(): void { - release?.() +export function whenStdinReleased(): Promise | undefined { + return held } /** @@ -85,42 +86,47 @@ async function resolve(options: QueryBackgroundOptions): Promise void + held = new Promise((settle) => { + releaseStdin = () => { + held = undefined + settle() + } + }) + try { - buffer = await listen(stdin, options.timeout ?? REPLY_TIMEOUT_MS, () => options.write(QUERY)) + const buffer = await listen(stdin, options.timeout ?? REPLY_TIMEOUT_MS, () => options.write(QUERY)) + const reply = REPLY_RE.exec(buffer) + // Anything typed while the terminal was thinking is put back for whoever + // reads keys next, rather than being swallowed by the question. + const typed = reply ? buffer.replace(reply[0], '') : buffer + if (typed) { + stdin.unshift(Buffer.from(typed, 'latin1')) + } + // Raw mode suppresses the terminal's own handling of Ctrl-C, so it has to be + // passed on rather than left in the buffer for a handler that may never read. + if (typed.includes('\u0003')) { + process.emit('SIGINT' as 'disconnect') + } + if (!reply) { + debug('The terminal did not report a background colour') + return 'unknown' + } + + return brightness(reply) > LIGHT_THRESHOLD ? 'light' : 'dark' } catch (error) { debug('Could not ask the terminal for its background:', error) return 'unknown' } - - const reply = REPLY_RE.exec(buffer) - // Anything typed while the terminal was thinking is put back for whoever - // reads keys next, rather than being swallowed by the question. - const typed = reply ? buffer.replace(reply[0], '') : buffer - if (typed) { - stdin.unshift(Buffer.from(typed, 'latin1')) - } - // Raw mode suppresses the terminal's own handling of Ctrl-C, so it has to be - // passed on rather than left in the buffer for a handler that may never read. - if (typed.includes('\u0003')) { - process.emit('SIGINT' as 'disconnect') - } - if (!reply) { - debug('The terminal did not report a background colour') - return 'unknown' + finally { + releaseStdin() } - - return brightness(reply) > LIGHT_THRESHOLD ? 'light' : 'dark' } /** * Hold stdin in raw mode until the reply arrives or the wait is over, and give * it back exactly as it was found. - * - * Reading a reply means owning stdin, which whoever reads keys also needs. The - * handover is {@link stopBackgroundQuery}, and it has to be synchronous: a - * caller that asks for stdin back goes on to claim it in the same tick. */ function listen(stdin: Stdin, timeout: number, ask: () => void): Promise { return new Promise((resolve) => { @@ -142,7 +148,6 @@ function listen(stdin: Stdin, timeout: number, ask: () => void): Promise return } listening = false - release = undefined clearTimeout(timer) stdin.off('data', onData) if (!wasRaw) { @@ -153,7 +158,6 @@ function listen(stdin: Stdin, timeout: number, ask: () => void): Promise } resolve(buffer) } - release = finish timer = setTimeout(finish, timeout) timer.unref?.() stdin.setRawMode?.(true) diff --git a/packages/nuxt-cli/src/dev/tui/keys.ts b/packages/nuxt-cli/src/dev/tui/keys.ts index af7d77ee9..25442a185 100644 --- a/packages/nuxt-cli/src/dev/tui/keys.ts +++ b/packages/nuxt-cli/src/dev/tui/keys.ts @@ -1,7 +1,8 @@ import process from 'node:process' import { emitKeypressEvents } from 'node:readline' -import { stopBackgroundQuery } from './background' +import { whenStdinReleased } from './background' +import { filterTerminalReplies } from './terminal-replies' export interface Key { name?: string @@ -14,29 +15,60 @@ export interface Key { * * Raw mode means the terminal no longer turns Ctrl-C into `SIGINT`, so the * handler receives it as a key and is responsible for shutdown. + * + * Replies from the terminal are dropped rather than read as typing, and keys + * wait until the background query has finished with stdin. */ export function attachKeys(onKey: (key: Key) => void): () => void { - // A background query may still be holding stdin, and would otherwise hand it - // back on its own schedule, after this listener had taken it. - stopBackgroundQuery() - const { stdin } = process - emitKeypressEvents(stdin) - const wasRaw = stdin.isRaw - stdin.setRawMode(true) - stdin.resume() - - const handler = (_input: string, key: Key | undefined) => { - if (key) { - onKey(key) + let release: (() => void) | undefined + let detached = false + + function attach(): void { + if (detached) { + return + } + const { stdin } = process + const wasRaw = stdin.isRaw + stdin.setRawMode(true) + + const replies = filterTerminalReplies(stdin) + + emitKeypressEvents(stdin) + stdin.resume() + + const handler = (_input: string, key: Key | undefined) => { + if (replies.isReplying()) { + return + } + if (key) { + onKey(key) + } } + stdin.on('keypress', handler) + + release = () => { + replies.stop() + stdin.off('keypress', handler) + if (stdin.isTTY) { + stdin.setRawMode(wasRaw ?? false) + } + stdin.pause() + } + } + + // Attached synchronously when nothing holds stdin, so a caller can deliver a + // key in the same tick. + const held = whenStdinReleased() + if (held) { + void held.then(attach) + } + else { + attach() } - stdin.on('keypress', handler) return () => { - stdin.off('keypress', handler) - if (stdin.isTTY) { - stdin.setRawMode(wasRaw ?? false) - } - stdin.pause() + detached = true + release?.() + release = undefined } } diff --git a/packages/nuxt-cli/src/dev/tui/terminal-replies.ts b/packages/nuxt-cli/src/dev/tui/terminal-replies.ts new file mode 100644 index 000000000..35ff9f85c --- /dev/null +++ b/packages/nuxt-cli/src/dev/tui/terminal-replies.ts @@ -0,0 +1,105 @@ +import type { Buffer } from 'node:buffer' + +/** Introduces an OSC, DCS, APC or PM string, which runs until its terminator. */ +// eslint-disable-next-line no-control-regex +const STRING_REPLY_RE = /^\u001B[\]P^_X]/ + +/** Ends an OSC, DCS, APC or PM string. */ +// eslint-disable-next-line no-control-regex +const STRING_TERMINATOR_RE = /\u0007|\u001B\\/ + +/** + * A whole CSI report: cursor position, device attributes, window size. Narrow, + * because cursor keys and `alt`ed letters are escape sequences too. + */ +// eslint-disable-next-line no-control-regex +const CSI_REPLY_RE = /^\u001B\[[\d;?]*[Rcnty]/ + +/** + * A CSI report whose final byte has not arrived yet. A lone `ESC` is + * deliberately not one of these: it is also the Escape key, and holding it back + * on the chance that a report follows would cost more than it saves. + */ +// eslint-disable-next-line no-control-regex +const PARTIAL_CSI_RE = /^\u001B\[[\d;?]*$/ + +/** How long an unterminated reply may swallow keys before it is dropped. */ +const REPLY_TIMEOUT_MS = 200 + +/** Longest tail of an unfinished reply held while the rest is awaited. */ +const MAX_PENDING = 64 + +export interface ReplyFilter { + /** Whether the chunk being delivered is the terminal answering, not typing. */ + isReplying: () => boolean + stop: () => void +} + +/** + * Watch a stream for answers from the terminal, so a key reader can tell them + * apart from typing: a background colour reply arrives as `ESC ] 1 1 ; r g b : …`, + * every character of which is also a shortcut. + * + * Attach before the `keypress` listener, so each chunk is classified by the time + * `readline` turns it into keys. + */ +export function filterTerminalReplies(stdin: NodeJS.ReadableStream): ReplyFilter { + let replying = false + let pending = '' + let timer: NodeJS.Timeout | undefined + + function stopReplying(): void { + replying = false + pending = '' + clearTimeout(timer) + timer = undefined + } + + /** This chunk's keys have not been emitted yet; the next chunk is typing. */ + function endAfterThisChunk(): void { + clearTimeout(timer) + timer = undefined + pending = '' + setImmediate(stopReplying) + } + + /** Wait for the rest of a reply, without waiting on it forever. */ + function awaitRest(buffered: string): void { + if (buffered.length > MAX_PENDING) { + return stopReplying() + } + pending = buffered + // Dated from the first piece, so a dribble of them cannot hold the keyboard. + timer ??= setTimeout(stopReplying, REPLY_TIMEOUT_MS) + timer.unref?.() + } + + const onData = (chunk: Buffer) => { + // A reply can be split anywhere, so the unresolved tail is carried over and + // matched together with what follows it. + const buffered = pending + chunk.toString('latin1') + + if (CSI_REPLY_RE.test(buffered)) { + replying = true + return endAfterThisChunk() + } + if (STRING_REPLY_RE.test(buffered)) { + replying = true + return STRING_TERMINATOR_RE.test(buffered) ? endAfterThisChunk() : awaitRest(buffered) + } + if (PARTIAL_CSI_RE.test(buffered)) { + replying = true + return awaitRest(buffered) + } + stopReplying() + } + stdin.on('data', onData) + + return { + isReplying: () => replying, + stop: () => { + stopReplying() + stdin.off('data', onData) + }, + } +} diff --git a/packages/nuxt-cli/test/unit/dev-background.spec.ts b/packages/nuxt-cli/test/unit/dev-background.spec.ts index 016373501..d49c5977f 100644 --- a/packages/nuxt-cli/test/unit/dev-background.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-background.spec.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer' import process from 'node:process' +import { PassThrough } from 'node:stream' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -39,12 +40,12 @@ function stubStdin(reply?: string) { describe('terminal background query', () => { let queryBackground: Background['queryBackground'] - let stopBackgroundQuery: Background['stopBackgroundQuery'] + let whenStdinReleased: Background['whenStdinReleased'] let resolveBackground: Theme['resolveBackground'] beforeEach(async () => { vi.resetModules() - ;({ queryBackground, stopBackgroundQuery } = await import('../../src/dev/tui/background')) + ;({ queryBackground, whenStdinReleased } = await import('../../src/dev/tui/background')) ;({ resolveBackground } = await import('../../src/utils/terminal-theme')) }) @@ -97,24 +98,70 @@ describe('terminal background query', () => { expect(resolveBackground({})).toBe('unknown') }) - it('gives stdin back the moment something else needs it', async () => { - const terminal = stubStdin() - const answer = queryBackground({ - write: () => {}, - stdin: terminal.stdin, - stdout: { isTTY: true }, - env: { TERM: 'xterm-256color' }, - timeout: 10_000, - ci: false, - test: false, + it('holds stdin until the question is answered', async () => { + const { answer, terminal } = ask(undefined, { timeout: 20 }) + const held = whenStdinReleased() + + expect(held).toBeDefined() + let released = false + void held!.then(() => { + released = true }) - expect(terminal.calls.raw).toEqual([true]) + expect(released).toBe(false) - stopBackgroundQuery() + terminal.emit('\u001B]11;rgb:1e1e/1e1e/1e1e\u0007') + await expect(answer).resolves.toBe('dark') + await held + expect(released).toBe(true) + expect(whenStdinReleased()).toBeUndefined() + }) - // Synchronously, because the caller claims stdin in this same tick. - expect(terminal.calls.raw).toEqual([true, false]) - await expect(answer).resolves.toBe('unknown') + it('does not take stdin from a question that is still waiting', async () => { + const { attachKeys } = await import('../../src/dev/tui/keys') + const stdin = new PassThrough() as unknown as typeof process.stdin + const raw: boolean[] = [] + Object.assign(stdin, { isTTY: true, isRaw: false, setRawMode: (mode: boolean) => { + raw.push(mode) + Object.assign(stdin, { isRaw: mode }) + } }) + const original = Object.getOwnPropertyDescriptor(process, 'stdin')! + Object.defineProperty(process, 'stdin', { value: stdin, configurable: true }) + + try { + const answer = queryBackground({ + write: () => {}, + stdout: { isTTY: true }, + env: { TERM: 'xterm-256color' }, + timeout: 50, + ci: false, + test: false, + }) + const keys: Array = [] + const detach = attachKeys(key => keys.push(key.name)) + + // The question owns stdin, so nothing else may change the mode it restores. + expect(raw).toEqual([true]) + + stdin.write('\u001B]11;rgb:1e1e/1e1e/1e1e\u0007') + await expect(answer).resolves.toBe('dark') + await whenStdinReleased() + await new Promise(resolve => setImmediate(resolve)) + + expect(raw).toEqual([true, false, true]) + stdin.write('o') + await new Promise(resolve => setImmediate(resolve)) + detach() + + expect(keys).toEqual(['o']) + } + finally { + Object.defineProperty(process, 'stdin', original) + } + }) + + it('is not holding stdin when there was nothing to ask', async () => { + await expect(ask(undefined, { env: { TERM: 'dumb' } }).answer).resolves.toBe('unknown') + expect(whenStdinReleased()).toBeUndefined() }) it('leaves raw mode alone when it found stdin already in it', async () => { diff --git a/packages/nuxt-cli/test/unit/dev-keys.spec.ts b/packages/nuxt-cli/test/unit/dev-keys.spec.ts new file mode 100644 index 000000000..79474740c --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev-keys.spec.ts @@ -0,0 +1,146 @@ +import { Buffer } from 'node:buffer' +import process from 'node:process' +import { PassThrough } from 'node:stream' + +import { afterEach, describe, expect, it } from 'vitest' + +import { attachKeys } from '../../src/dev/tui/keys' +import { filterTerminalReplies } from '../../src/dev/tui/terminal-replies' + +describe('panel keys', () => { + const restores: Array<() => void> = [] + + afterEach(() => { + for (const restore of restores.splice(0)) { + restore() + } + }) + + function attach() { + const stdin = new PassThrough() as unknown as typeof process.stdin + Object.assign(stdin, { isTTY: true, isRaw: false, setRawMode: (raw: boolean) => Object.assign(stdin, { isRaw: raw }) }) + const original = Object.getOwnPropertyDescriptor(process, 'stdin')! + Object.defineProperty(process, 'stdin', { value: stdin, configurable: true }) + restores.push(() => Object.defineProperty(process, 'stdin', original)) + + const keys: Array = [] + const detach = attachKeys(key => keys.push(key.name)) + restores.push(detach) + + return { + keys, + type: async (text: string) => { + stdin.write(text) + await new Promise(resolve => setImmediate(resolve)) + }, + } + } + + it('should not read the answer to the background query as keys', async () => { + const { keys, type } = attach() + + await type('\u001B]11;rgb:1e1e/1e1e/1e1e\u0007') + + expect(keys).toEqual([]) + }) + + it('should keep dropping a reply that arrives in pieces', async () => { + const { keys, type } = attach() + + await type('\u001B]11;rgb:1e1e/') + await type('1e1e/1e1e\u0007') + + expect(keys).toEqual([]) + }) + + it('should read what is typed after a reply', async () => { + const { keys, type } = attach() + + await type('\u001B]11;rgb:1e1e/1e1e/1e1e\u0007') + await type('o') + + expect(keys).toEqual(['o']) + }) + + it('should read the next key straight after a cursor position report', async () => { + const { keys, type } = attach() + + await type('\u001B[12;34R') + await type('o') + + expect(keys).toEqual(['o']) + }) + + it('should drop a csi report whose final byte arrives in the next chunk', async () => { + const { keys, type } = attach() + + await type('\u001B[12;') + await type('34R') + await type('o') + + expect(keys).toEqual(['o']) + }) + + it('should read the next key when a string terminator is split', async () => { + const { keys, type } = attach() + + await type('\u001B]11;rgb:1e1e/1e1e/1e1e\u001B') + await type('\\\\') + await type('o') + + expect(keys).toEqual(['o']) + }) + + it('should give the keyboard back when a reply is never terminated', async () => { + const { keys, type } = attach() + + await type('\u001B]11;rgb:1e1e') + await new Promise(resolve => setTimeout(resolve, 250)) + await type('o') + + expect(keys).toEqual(['o']) + }) + + it('should pass on keys that are escape sequences of their own', async () => { + const { keys, type } = attach() + + await type('\u001B[A') + await type('\u001BOP') + await type('q') + + expect(keys).toEqual(['up', 'f1', 'q']) + }) +}) + +describe('terminal replies', () => { + function feed(...chunks: string[]): boolean[] { + const stdin = new PassThrough() + const filter = filterTerminalReplies(stdin) + const states = chunks.map((chunk) => { + stdin.emit('data', Buffer.from(chunk, 'latin1')) + return filter.isReplying() + }) + filter.stop() + return states + } + + it('should hold a csi report that is still arriving', () => { + expect(feed('\u001B[12;', '34R')).toEqual([true, true]) + }) + + it('should hold a string reply until its terminator', () => { + expect(feed('\u001B]11;rgb:1e1e', '/1e1e/1e1e\u0007')).toEqual([true, true]) + }) + + it('should not hold a lone escape, which is also a key', () => { + expect(feed('\u001B')).toEqual([false]) + }) + + it('should not hold keys that are escape sequences of their own', () => { + expect(feed('\u001B[A', '\u001BOP', 'q')).toEqual([false, false, false]) + }) + + it('should give up on a tail that never becomes a reply', () => { + expect(feed(`\u001B[${'1'.repeat(70)}`)).toEqual([false]) + }) +}) From 0769e44a6092b18b174500d09f71f7f9aacd2961 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 11:44:38 +0000 Subject: [PATCH 4/6] fix(dev): drop input the terminal buffered before anything was reading --- packages/nuxt-cli/src/dev/shortcuts.ts | 6 +++++- packages/nuxt-cli/src/dev/tui/index.ts | 2 +- packages/nuxt-cli/src/dev/tui/keys.ts | 18 +++++++++++------- packages/nuxt-cli/src/utils/console.ts | 17 +++++++++++++++++ packages/nuxt-cli/test/unit/dev-keys.spec.ts | 17 ++++++++++++++--- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 1 + packages/nuxt-cli/test/unit/shortcuts.spec.ts | 17 +++++++++++++++++ .../nuxt-cli/test/unit/terminal-output.spec.ts | 3 +++ 8 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/nuxt-cli/src/dev/shortcuts.ts b/packages/nuxt-cli/src/dev/shortcuts.ts index 848f31d29..c359c638d 100644 --- a/packages/nuxt-cli/src/dev/shortcuts.ts +++ b/packages/nuxt-cli/src/dev/shortcuts.ts @@ -7,7 +7,7 @@ import { createInterface } from 'node:readline' import { styleText } from 'node:util' import { isCI, isTest } from 'std-env' -import { restoreRawMode, withDirectStdout } from '../utils/console' +import { guardReplayedInput, restoreRawMode, withDirectStdout } from '../utils/console' import { copyURL, openBrowser, printQRCode } from './listen' export type { ShortcutContext } from './shortcut-context' @@ -144,7 +144,11 @@ export function setupShortcuts(context: ShortcutContext): void { restoreRawMode() const rl = createInterface({ input: process.stdin }) + const isReplayedInput = guardReplayedInput() rl.on('line', async (line) => { + if (isReplayedInput()) { + return + } const input = line.trim().toLowerCase() const shortcut = availableShortcuts(context).find(({ keys }) => keys.includes(input)) if (!shortcut) { diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index 4f48324a1..6f57403b4 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -500,7 +500,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) // detached and given the terminal back; re-attaching would put stdin // into raw mode with nothing listening and keep the process alive. if (!torn) { - detach = attachKeys(onKey) + detach = attachKeys(onKey, { ignoreBufferedInput: true }) render() } } diff --git a/packages/nuxt-cli/src/dev/tui/keys.ts b/packages/nuxt-cli/src/dev/tui/keys.ts index 25442a185..c40138458 100644 --- a/packages/nuxt-cli/src/dev/tui/keys.ts +++ b/packages/nuxt-cli/src/dev/tui/keys.ts @@ -1,6 +1,7 @@ import process from 'node:process' import { emitKeypressEvents } from 'node:readline' +import { guardReplayedInput } from '../../utils/console' import { whenStdinReleased } from './background' import { filterTerminalReplies } from './terminal-replies' @@ -14,12 +15,15 @@ export interface Key { * Put stdin into raw mode and deliver single keypresses. * * Raw mode means the terminal no longer turns Ctrl-C into `SIGINT`, so the - * handler receives it as a key and is responsible for shutdown. + * handler receives it as a key and is responsible for shutdown. Replies from + * the terminal are dropped, and keys wait until the background query has + * finished with stdin. * - * Replies from the terminal are dropped rather than read as typing, and keys - * wait until the background query has finished with stdin. + * Pass `ignoreBufferedInput` when taking stdin back after something else held + * it, such as a prompt: what the terminal buffered meanwhile was typed at that, + * not at the panel. */ -export function attachKeys(onKey: (key: Key) => void): () => void { +export function attachKeys(onKey: (key: Key) => void, { ignoreBufferedInput = false }: { ignoreBufferedInput?: boolean } = {}): () => void { let release: (() => void) | undefined let detached = false @@ -32,12 +36,13 @@ export function attachKeys(onKey: (key: Key) => void): () => void { stdin.setRawMode(true) const replies = filterTerminalReplies(stdin) + const isReplayedInput = ignoreBufferedInput ? guardReplayedInput() : () => false emitKeypressEvents(stdin) stdin.resume() const handler = (_input: string, key: Key | undefined) => { - if (replies.isReplying()) { + if (replies.isReplying() || isReplayedInput()) { return } if (key) { @@ -56,8 +61,7 @@ export function attachKeys(onKey: (key: Key) => void): () => void { } } - // Attached synchronously when nothing holds stdin, so a caller can deliver a - // key in the same tick. + // Synchronous when nothing holds stdin, so a caller can deliver a key at once. const held = whenStdinReleased() if (held) { void held.then(attach) diff --git a/packages/nuxt-cli/src/utils/console.ts b/packages/nuxt-cli/src/utils/console.ts index 84d077b3e..186230139 100644 --- a/packages/nuxt-cli/src/utils/console.ts +++ b/packages/nuxt-cli/src/utils/console.ts @@ -91,6 +91,23 @@ export function restoreRawMode(): void { } } +/** + * Guard a newly attached stdin reader against input it did not see typed: a + * terminal buffers keystrokes while nothing is reading and hands the whole run + * over in the first read once a reader resumes stdin. Anything arriving in a + * later read was typed while the reader was listening. + * + * Returns whether the input being handled is part of that replay. + */ +export function guardReplayedInput(): () => boolean { + let replaying = true + const settled = setImmediate(() => { + replaying = false + }) + settled.unref?.() + return () => replaying +} + /** * Give up `process.stdin` after a prompt in a command that is about to finish. * diff --git a/packages/nuxt-cli/test/unit/dev-keys.spec.ts b/packages/nuxt-cli/test/unit/dev-keys.spec.ts index 79474740c..8f88959f4 100644 --- a/packages/nuxt-cli/test/unit/dev-keys.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-keys.spec.ts @@ -11,12 +11,13 @@ describe('panel keys', () => { const restores: Array<() => void> = [] afterEach(() => { - for (const restore of restores.splice(0)) { + // Reverse: the second `attach` saved the descriptor the first installed. + for (const restore of restores.splice(0).reverse()) { restore() } }) - function attach() { + function attach(options?: { ignoreBufferedInput?: boolean }) { const stdin = new PassThrough() as unknown as typeof process.stdin Object.assign(stdin, { isTTY: true, isRaw: false, setRawMode: (raw: boolean) => Object.assign(stdin, { isRaw: raw }) }) const original = Object.getOwnPropertyDescriptor(process, 'stdin')! @@ -24,7 +25,7 @@ describe('panel keys', () => { restores.push(() => Object.defineProperty(process, 'stdin', original)) const keys: Array = [] - const detach = attachKeys(key => keys.push(key.name)) + const detach = attachKeys(key => keys.push(key.name), options) restores.push(detach) return { @@ -110,6 +111,16 @@ describe('panel keys', () => { expect(keys).toEqual(['up', 'f1', 'q']) }) + + it('should drop input buffered before it took stdin, when asked to', async () => { + const buffered = attach({ ignoreBufferedInput: true }) + await buffered.type('ooo') + expect(buffered.keys).toEqual([]) + + const live = attach() + await live.type('ooo') + expect(live.keys).toEqual(['o', 'o', 'o']) + }) }) describe('terminal replies', () => { diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index dc1e7fcec..0d33d7758 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -3110,6 +3110,7 @@ describe('the terminal host on the panel', () => { settled = true }) + await new Promise(resolve => setImmediate(resolve)) process.stdin.emit('keypress', '', { name: 'x', sequence: 'x' }) await vi.waitFor(() => expect(settled).toBe(true)) }) diff --git a/packages/nuxt-cli/test/unit/shortcuts.spec.ts b/packages/nuxt-cli/test/unit/shortcuts.spec.ts index 02ce39df4..cc1842402 100644 --- a/packages/nuxt-cli/test/unit/shortcuts.spec.ts +++ b/packages/nuxt-cli/test/unit/shortcuts.spec.ts @@ -67,12 +67,17 @@ describe('setupShortcuts', () => { setupShortcuts(resolved) + /** Input is ignored until the replay of what the terminal buffered is over. */ + const waitUntilLive = () => new Promise(resolve => setImmediate(resolve)) + return { context: resolved, listener, log, stdin, + write: (input: string) => stdin.write(`${input}\n`), press: async (input: string) => { + await waitUntilLive() stdin.write(`${input}\n`) await new Promise(resolve => setImmediate(resolve)) }, @@ -221,6 +226,18 @@ describe('setupShortcuts', () => { await vi.waitFor(() => expect(error).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }))) }) + it('should ignore input buffered before the shortcuts were listening', async () => { + const { write, listener } = setup() + + for (let i = 0; i < 5; i++) { + write('o') + } + await new Promise(resolve => setImmediate(resolve)) + + expect(openBrowser).not.toHaveBeenCalled() + expect(listener.showURLs).not.toHaveBeenCalled() + }) + it('should ignore unknown input', async () => { const { press, listener } = setup() diff --git a/packages/nuxt-cli/test/unit/terminal-output.spec.ts b/packages/nuxt-cli/test/unit/terminal-output.spec.ts index 0c22ae64f..fb9d49c3e 100644 --- a/packages/nuxt-cli/test/unit/terminal-output.spec.ts +++ b/packages/nuxt-cli/test/unit/terminal-output.spec.ts @@ -82,6 +82,9 @@ describe('dev server terminal output', () => { ...context, }) + // Input is ignored until the replay of what the terminal buffered is over. + await new Promise(resolve => setImmediate(resolve)) + return { listener, press: async (input: string) => { From d8cdf6131bcf6a4bf9fdfe91f07248b79a8b6494 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 18:13:53 +0000 Subject: [PATCH 5/6] fix: hand stdin back to the key reader at first keystroke --- packages/nuxt-cli/src/dev/tui/background.ts | 140 +++++++++++++----- .../nuxt-cli/test/unit/dev-background.spec.ts | 13 ++ 2 files changed, 114 insertions(+), 39 deletions(-) diff --git a/packages/nuxt-cli/src/dev/tui/background.ts b/packages/nuxt-cli/src/dev/tui/background.ts index 064a3b8f6..82a13c142 100644 --- a/packages/nuxt-cli/src/dev/tui/background.ts +++ b/packages/nuxt-cli/src/dev/tui/background.ts @@ -11,6 +11,9 @@ import { rememberBackground, resolveBackground } from '../../utils/terminal-them /** OSC 11: report the background colour. */ const QUERY = '\u001B]11;?\u0007' +/** How far a reply is recognisable before its payload begins. */ +const REPLY_PREFIX = '\u001B]11;' + /** `ESC ] 11 ; rgb:RRRR/GGGG/BBBB` followed by BEL or ST. */ // eslint-disable-next-line no-control-regex const REPLY_RE = /\u001B\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\u0007|\u001B\\)?/i @@ -95,11 +98,12 @@ async function resolve(options: QueryBackgroundOptions): Promise options.write(QUERY)) - const reply = REPLY_RE.exec(buffer) + const session = listen(stdin, options.timeout ?? REPLY_TIMEOUT_MS, () => options.write(QUERY), releaseStdin) + const buffer = await session.typed + const answered = REPLY_RE.exec(buffer) // Anything typed while the terminal was thinking is put back for whoever // reads keys next, rather than being swallowed by the question. - const typed = reply ? buffer.replace(reply[0], '') : buffer + const typed = answered ? buffer.replace(answered[0], '') : buffer if (typed) { stdin.unshift(Buffer.from(typed, 'latin1')) } @@ -108,6 +112,7 @@ async function resolve(options: QueryBackgroundOptions): Promise + /** A reply that arrives after the handover, watched for without holding stdin. */ + reply: Promise +} + /** - * Hold stdin in raw mode until the reply arrives or the wait is over, and give - * it back exactly as it was found. + * Ask the terminal, and hold stdin only for as long as nothing else needs it. + * + * Owning stdin is what makes a reply readable, but it is also what makes the + * keyboard dead, so the moment the first keystroke shows that someone is typing + * the stream is handed back and the rest of the wait is spent observing it: a + * `data` listener alongside the key reader sees the same bytes without taking + * them, and {@link filterTerminalReplies} keeps the reply out of the keys. */ -function listen(stdin: Stdin, timeout: number, ask: () => void): Promise { - return new Promise((resolve) => { - const wasRaw = !!stdin.isRaw - const wasPaused = stdin.isPaused() - let buffer = '' - let timer: NodeJS.Timeout - let listening = true - const onData = (chunk: Buffer) => { - // Latin-1 keeps every byte addressable: a reply is ASCII, and anything - // else here is a keystroke that has to survive being put back. - buffer += chunk.toString('latin1') - if (REPLY_RE.test(buffer) || buffer.includes('\u0003')) { - finish() - } +function listen(stdin: Stdin, timeout: number, ask: () => void, handOver: () => void): QuerySession { + const deadline = Date.now() + timeout + const wasRaw = !!stdin.isRaw + const wasPaused = stdin.isPaused() + let buffer = '' + let timer: NodeJS.Timeout + let listening = true + let settleTyped!: (value: string) => void + let settleReply!: (value: string) => void + const typed = new Promise((resolve) => { + settleTyped = resolve + }) + const reply = new Promise((resolve) => { + settleReply = resolve + }) + + const onData = (chunk: Buffer) => { + // Latin-1 keeps every byte addressable: a reply is ASCII, and anything + // else here is a keystroke that has to survive being put back. + buffer += chunk.toString('latin1') + if (REPLY_RE.test(buffer) || buffer.includes('\u0003') || !isReplyPrefix(buffer)) { + finish() } - function finish(): void { - if (!listening) { - return - } - listening = false - clearTimeout(timer) - stdin.off('data', onData) - if (!wasRaw) { - stdin.setRawMode?.(false) - } - if (wasPaused) { - stdin.pause() + } + + function observe(): void { + let observed = '' + let expiry: NodeJS.Timeout + function stop(): void { + clearTimeout(expiry) + stdin.off('data', onObserved) + settleReply(observed) + } + function onObserved(chunk: Buffer): void { + observed += chunk.toString('latin1') + if (REPLY_RE.test(observed)) { + stop() } - resolve(buffer) } - timer = setTimeout(finish, timeout) - timer.unref?.() - stdin.setRawMode?.(true) - stdin.resume() - stdin.on('data', onData) - ask() - }) + expiry = setTimeout(stop, Math.max(0, deadline - Date.now())) + expiry.unref?.() + stdin.on('data', onObserved) + } + + function finish(): void { + if (!listening) { + return + } + listening = false + clearTimeout(timer) + stdin.off('data', onData) + if (!wasRaw) { + stdin.setRawMode?.(false) + } + if (wasPaused) { + stdin.pause() + } + handOver() + if (REPLY_RE.test(buffer)) { + settleReply('') + } + else { + observe() + } + settleTyped(buffer) + } + + timer = setTimeout(finish, timeout) + timer.unref?.() + stdin.setRawMode?.(true) + stdin.resume() + stdin.on('data', onData) + ask() + + return { typed, reply } +} + +/** Whether everything read so far could still be the terminal beginning to answer. */ +function isReplyPrefix(buffer: string): boolean { + return buffer.length < REPLY_PREFIX.length + ? REPLY_PREFIX.startsWith(buffer) + : buffer.startsWith(REPLY_PREFIX) } /** diff --git a/packages/nuxt-cli/test/unit/dev-background.spec.ts b/packages/nuxt-cli/test/unit/dev-background.spec.ts index d49c5977f..080703aca 100644 --- a/packages/nuxt-cli/test/unit/dev-background.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-background.spec.ts @@ -116,6 +116,19 @@ describe('terminal background query', () => { expect(whenStdinReleased()).toBeUndefined() }) + it('hands stdin back as soon as someone types, and still reads a reply that follows', async () => { + const { answer, terminal } = ask(undefined, { timeout: 5000 }) + const held = whenStdinReleased() + + terminal.emit('r') + await held + expect(whenStdinReleased()).toBeUndefined() + expect(terminal.calls.unshifted.join('')).toBe('r') + + terminal.emit('\u001B]11;rgb:1e1e/1e1e/1e1e\u0007') + await expect(answer).resolves.toBe('dark') + }) + it('does not take stdin from a question that is still waiting', async () => { const { attachKeys } = await import('../../src/dev/tui/keys') const stdin = new PassThrough() as unknown as typeof process.stdin From 1a021da9bb592f20dcc680cb06d0c630a3e8aae1 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 16:56:35 +0000 Subject: [PATCH 6/6] chore: keep a right-aligned tag in place when scrubbing --- capture/lib/frames.spec.ts | 61 +++++++++++++++++++++++++++ capture/lib/scrub.ts | 36 +++++++++++++++- capture/output/nuxt-module-search.txt | 3 +- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/capture/lib/frames.spec.ts b/capture/lib/frames.spec.ts index 5a662e7cf..b90bcb565 100644 --- a/capture/lib/frames.spec.ts +++ b/capture/lib/frames.spec.ts @@ -2,6 +2,7 @@ import type { Chunk } from './pty.ts' import { describe, expect, it } from 'vitest' import { buildFingerprint } from './frames.ts' +import { resolveRules, scrubLine } from './scrub.ts' /** * How each progress display we record repaints. They disagree, and reading only @@ -57,3 +58,63 @@ describe('capture fingerprint', () => { }) } }) + +describe('scrubbing a right-aligned tag', () => { + const TAG = 'nitro' + const WIDTH = 96 + + /** One consola line as it would be rendered for a given real duration. */ + function rendered(duration: string): string { + const message = `✔ Nuxt Nitro server built in ${duration}` + return message + ' '.repeat(WIDTH - message.length - TAG.length) + TAG + } + + function scrub(duration: string): string { + const line = rendered(duration) + const styles = Array.from({ length: line.length }).fill(undefined) as never + return scrubLine(line, styles, resolveRules(['timings'])).line + } + + it('should put the tag in the same column however long the duration was', () => { + const durations = ['1085 ms', '986 ms', '9 ms', '42 ms', '1.2 s'] + const scrubbed = durations.map(scrub) + + for (const line of scrubbed) { + expect(line).toBe(scrubbed[0]) + expect(line).toHaveLength(WIDTH) + } + }) + + it('should leave indentation and gaps inside a message alone', () => { + const untagged = [ + ' config 1085 ms · modules 42 ms', + '● Nuxt 1085 ms and more', + ' ➜ DevTools: 1085 ms', + ' Ready in 1085 ms → http://localhost:3000/', + ] + + for (const content of untagged) { + const line = content.padEnd(WIDTH) + const styles = Array.from({ length: line.length }).fill(undefined) as never + + expect(scrubLine(line, styles, resolveRules(['timings'])).line.trimEnd()) + .toBe(content.replaceAll('1085 ms', '42 ms')) + } + }) + + it('should keep the styles aligned with the re-padded line', () => { + const line = rendered('1085 ms') + const styles = Array.from({ length: line.length }, (_, index) => index) as never + const result = scrubLine(line, styles, resolveRules(['timings'])) + + expect(result.styles).toHaveLength(result.line.length) + }) + + it('should leave a line without padding alone', () => { + expect(scrub('42 ms').trimEnd()).not.toBe('') + const plain = '✔ Vite client built in 1085 ms' + const styles = Array.from({ length: plain.length }).fill(undefined) as never + + expect(scrubLine(plain, styles, resolveRules(['timings'])).line).toBe('✔ Vite client built in 42 ms') + }) +}) diff --git a/capture/lib/scrub.ts b/capture/lib/scrub.ts index c871dae09..6c4038b85 100644 --- a/capture/lib/scrub.ts +++ b/capture/lib/scrub.ts @@ -121,7 +121,41 @@ export function scrubLine(line: string, styles: Style[], rules: ScrubRule[]): { currentStyles = next.styles } } - return { line: currentLine, styles: currentStyles as Style[] } + const realigned = realign(currentLine, currentStyles, line.length) + return { line: realigned.line, styles: realigned.styles as Style[] } +} + +/** + * Spaces holding a right-aligned tag against the end of the line. Anchoring to + * the end is what tells tag padding apart from indentation and from ordinary + * gaps inside a message, neither of which may be resized. + */ +const TAG_PADDING_RE = / {2,}(?=\S+$)/ + +/** + * Restore a line to the width it was rendered at, by resizing the padding that + * holds a trailing tag against the right edge. Consola sizes that padding for + * the unscrubbed message, so without this the tag moves whenever a substitution + * changes the length of what precedes it. + */ +function realign(line: string, styles: (Style | undefined)[], width: number): { line: string, styles: (Style | undefined)[] } { + const delta = width - line.length + if (delta === 0) { + return { line, styles } + } + const padding = TAG_PADDING_RE.exec(line) + if (!padding || padding[0].length + delta < 2) { + return { line, styles } + } + const at = padding.index + return { + line: line.slice(0, at) + ' '.repeat(padding[0].length + delta) + line.slice(at + padding[0].length), + styles: [ + ...styles.slice(0, at), + ...Array.from