From ef98e7cf34ae13ab3b0070c110923fcb48de5072 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 10:09:30 +0000 Subject: [PATCH 1/2] fix(dev): repaint the panel on a clean screen after a resize --- packages/nuxt-cli/src/dev/tui/surface.ts | 61 +++++++++ packages/nuxt-cli/test/unit/dev-tui.spec.ts | 139 ++++++++++++++++++-- 2 files changed, 187 insertions(+), 13 deletions(-) diff --git a/packages/nuxt-cli/src/dev/tui/surface.ts b/packages/nuxt-cli/src/dev/tui/surface.ts index cc6499885..bdb83cecd 100644 --- a/packages/nuxt-cli/src/dev/tui/surface.ts +++ b/packages/nuxt-cli/src/dev/tui/surface.ts @@ -8,6 +8,10 @@ interface PatchableStream extends NodeJS.WriteStream { } const REPAINT_DELAY_MS = 16 + +/** How long after the last resize event a drag is taken to be over. */ +const RESIZE_SETTLE_MS = 120 + const PENDING_CHUNK_LIMIT = 2000 /** @@ -58,10 +62,19 @@ export class PanelSurface { #pending: PendingOutput[] = [] #rowsWritten = 0 #rows = process.stdout.rows || 24 + #columns = process.stdout.columns || 80 #onResize = () => { const rows = process.stdout.rows || 24 + const columns = process.stdout.columns || 80 const grew = rows > this.#rows + const rewrapped = columns !== this.#columns this.#rows = rows + this.#columns = columns + if (this.#screen === 'alternate-screen') { + this.#resizedWhileHidden = true + this.#resized?.() + return + } // The owner re-renders; the cached lines were laid out for the old width. this.#erase() this.#resized?.() @@ -71,8 +84,15 @@ export class PanelSurface { if (grew) { this.padToBottom() } + if (rewrapped) { + this.#scheduleRecovery() + } } + #resizedWhileHidden = false + #recoverTimer?: NodeJS.Timeout + /** Paint against the last row rather than wherever the cursor is. */ + #reseat = false #resized?: () => void constructor(options: { onResize?: () => void } = {}) { @@ -102,6 +122,11 @@ export class PanelSurface { return } this.#flush() + if (this.#resizedWhileHidden) { + this.#resizedWhileHidden = false + this.padToBottom() + return + } this.#paint() } @@ -183,6 +208,33 @@ export class PanelSurface { this.#paint() } + #scheduleRecovery(): void { + clearTimeout(this.#recoverTimer) + this.#recoverTimer = setTimeout(() => { + this.#recoverTimer = undefined + this.#recover() + }, RESIZE_SETTLE_MS) + this.#recoverTimer.unref?.() + } + + /** + * Scroll the screen into the scrollback and paint the panel on what is left. + * + * A terminal re-wraps the screen when its width changes, so afterwards + * neither the rows the panel occupies nor the row the cursor is on follow + * from what was painted, and there is nothing to erase against. + */ + #recover(): void { + if (this.#closed || this.#suspended || this.#screen !== 'split-footer' || !this.#lines.length) { + return + } + this.#painted = 0 + this.#raw('\n'.repeat(process.stdout.rows || 24)) + this.#atLineStart = true + this.#reseat = true + this.#paint() + } + /** Forget the rows counted so far, after the screen has been cleared. */ resetRows(): void { this.#rowsWritten = 0 @@ -220,6 +272,8 @@ export class PanelSurface { } this.#externalOutput = 'passthrough' this.#sink = undefined + this.#reseat = false + clearTimeout(this.#recoverTimer) clearTimeout(this.#repaintTimer) this.#erase() // Whatever a view was holding is the session's last word on what happened, @@ -343,6 +397,13 @@ export class PanelSurface { if (!this.#lines.length || this.#closed || this.#suspended || this.#screen === 'alternate-screen') { return } + if (this.#reseat) { + this.#reseat = false + const rows = process.stdout.rows || 24 + this.#rowsWritten = Math.max(0, rows - this.#lines.length) + this.#raw(`\u001B[${Math.max(1, rows - this.#lines.length + 1)};1H\u001B[J`) + this.#atLineStart = true + } const leading = this.#atLineStart ? '' : '\n' this.#raw(`${leading}${this.#lines.join('\n')}`) this.#painted = this.#lines.length diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index eccb92a3c..94a0fc18f 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -1913,20 +1913,12 @@ describe('panel surface', () => { expect(frame.indexOf('a log line')).toBeLessThan(frame.indexOf('--- footer ---')) }) - function withStubbedTerminal(rows: number, run: (written: () => string) => void): void { - const chunks: string[] = [] - const descriptors = (['rows', 'isTTY'] as const).map(key => [key, Object.getOwnPropertyDescriptor(process.stdout, key)] as const) - Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true }) - Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }) - const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { - chunks.push(String(chunk)) - return true - }) - try { - run(() => chunks.join('')) + function stub(values: Array<[key: 'rows' | 'columns' | 'isTTY', value: number | boolean]>): () => void { + const descriptors = values.map(([key]) => [key, Object.getOwnPropertyDescriptor(process.stdout, key)] as const) + for (const [key, value] of values) { + Object.defineProperty(process.stdout, key, { value, configurable: true }) } - finally { - write.mockRestore() + return () => { for (const [key, descriptor] of descriptors) { if (descriptor) { Object.defineProperty(process.stdout, key, descriptor) @@ -1938,6 +1930,62 @@ describe('panel surface', () => { } } + function stubTerminal(rows: number): { written: () => string, restore: () => void } { + const chunks: string[] = [] + const restore = stub([['rows', rows], ['isTTY', true]]) + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + chunks.push(String(chunk)) + return true + }) + return { + written: () => chunks.join(''), + restore: () => { + write.mockRestore() + restore() + }, + } + } + + function withStubbedTerminal(rows: number, run: (written: () => string) => void): void { + const { written, restore } = stubTerminal(rows) + try { + run(written) + } + finally { + restore() + } + } + + async function withStubbedTerminalAsync(rows: number, run: (written: () => string) => Promise): Promise { + const { written, restore } = stubTerminal(rows) + try { + await run(written) + } + finally { + restore() + } + } + + function withStubbedColumns(columns: number, run: () => void): void { + const restore = stub([['columns', columns]]) + try { + run() + } + finally { + restore() + } + } + + async function withStubbedColumnsAsync(columns: number, run: () => Promise): Promise { + const restore = stub([['columns', columns]]) + try { + await run() + } + finally { + restore() + } + } + it('pads the screen so the footer starts at the bottom', () => { withStubbedTerminal(10, (written) => { const surface = new PanelSurface() @@ -2006,6 +2054,71 @@ describe('panel surface', () => { }) }) + it('starts a clean screen once a change of width has settled', async () => { + let written = '' + await withStubbedColumnsAsync(40, () => withStubbedTerminalAsync(24, async (read) => { + const surface = new PanelSurface() + surface.render(['--- footer ---']) + surface.padToBottom() + const before = read().length + await withStubbedColumnsAsync(30, async () => { + process.stdout.emit('resize') + await new Promise(resolve => setTimeout(resolve, 200)) + }) + written = read().slice(before) + surface.close() + })) + + expect(written).toContain('\n'.repeat(24)) + expect(written).toContain('\u001B[24;1H\u001B[J--- footer ---') + }) + + it('does not scroll the screen away when only the height changes', async () => { + let written = '' + await withStubbedColumnsAsync(40, () => withStubbedTerminalAsync(24, async (read) => { + const surface = new PanelSurface() + surface.render(['--- footer ---']) + surface.padToBottom() + const before = read().length + Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true }) + process.stdout.emit('resize') + await new Promise(resolve => setTimeout(resolve, 200)) + written = read().slice(before) + surface.close() + })) + + expect(written).not.toContain('\n'.repeat(20)) + expect(written).not.toContain('\u001B[J--- footer ---') + }) + + it('erases no more rows than it painted when the width changes', () => { + withStubbedColumns(40, () => { + withStubbedTerminal(24, (written) => { + const surface = new PanelSurface() + surface.render(['x'.repeat(30)]) + const before = written().length + withStubbedColumns(10, () => process.stdout.emit('resize')) + expect(written().slice(before)).toContain('\r\u001B[J') + expect(written().slice(before)).not.toContain('A\u001B[J') + surface.close() + }) + }) + }) + + it('re-seats the panel at the bottom after a resize while a view owned the screen', () => { + withStubbedTerminal(10, (written) => { + const surface = new PanelSurface() + surface.render(['--- footer ---']) + surface.screenMode = 'alternate-screen' + Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true }) + process.stdout.emit('resize') + const before = written().length + surface.screenMode = 'split-footer' + surface.close() + expect(written().slice(before)).toContain('\n'.repeat(18)) + }) + }) + it('writes nothing on resize while a view owns the screen', () => { withStubbedTerminal(24, (written) => { const surface = new PanelSurface() From a2e55e951572de99fbb6dc6305dfdd5abaaf90e7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 10:49:47 +0000 Subject: [PATCH 2/2] fix(dev): recover a clean screen after a hidden width change --- packages/nuxt-cli/src/dev/tui/surface.ts | 17 +++++++++++++---- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/nuxt-cli/src/dev/tui/surface.ts b/packages/nuxt-cli/src/dev/tui/surface.ts index bdb83cecd..141f47473 100644 --- a/packages/nuxt-cli/src/dev/tui/surface.ts +++ b/packages/nuxt-cli/src/dev/tui/surface.ts @@ -72,6 +72,7 @@ export class PanelSurface { this.#columns = columns if (this.#screen === 'alternate-screen') { this.#resizedWhileHidden = true + this.#rewrappedWhileHidden ||= rewrapped this.#resized?.() return } @@ -90,6 +91,7 @@ export class PanelSurface { } #resizedWhileHidden = false + #rewrappedWhileHidden = false #recoverTimer?: NodeJS.Timeout /** Paint against the last row rather than wherever the cursor is. */ #reseat = false @@ -122,12 +124,19 @@ export class PanelSurface { return } this.#flush() - if (this.#resizedWhileHidden) { - this.#resizedWhileHidden = false + const resized = this.#resizedWhileHidden + const rewrapped = this.#rewrappedWhileHidden + this.#resizedWhileHidden = false + this.#rewrappedWhileHidden = false + if (rewrapped) { + this.#recover() + } + else if (resized) { this.padToBottom() - return } - this.#paint() + else { + this.#paint() + } } /** diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 94a0fc18f..5557286b8 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -2119,6 +2119,23 @@ describe('panel surface', () => { }) }) + it('starts a clean screen when the width changed while a view owned the screen', () => { + withStubbedColumns(40, () => { + withStubbedTerminal(24, (written) => { + const surface = new PanelSurface() + surface.render(['--- footer ---']) + surface.padToBottom() + surface.screenMode = 'alternate-screen' + withStubbedColumns(30, () => process.stdout.emit('resize')) + const before = written().length + surface.screenMode = 'split-footer' + expect(written().slice(before)).toContain('\n'.repeat(24)) + expect(written().slice(before)).toContain('\u001B[24;1H\u001B[J--- footer ---') + surface.close() + }) + }) + }) + it('writes nothing on resize while a view owns the screen', () => { withStubbedTerminal(24, (written) => { const surface = new PanelSurface()