fix(tui): recover frame on dim-poll divergence under tmux+ssh

The 2s dim-poll fallback relied on _refreshSize() emitting a 'resize'
event to trigger recovery, which doesn't always propagate under tmux+ssh
— leaving Ink's frame buffer pinned to the old layout until the user
resized again. Extract the recovery body and call it directly whenever
OS-reported dims differ from the last recovered size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 00:00:26 -07:00
parent 19171bc98e
commit 6e336ffccc

View File

@@ -550,28 +550,7 @@ export class DashboardTUI {
const rows = process.stdout?.rows ?? 0; const rows = process.stdout?.rows ?? 0;
const cols = process.stdout?.columns ?? 0; const cols = process.stdout?.columns ?? 0;
if (rows <= 0 || cols <= 0) return; if (rows <= 0 || cols <= 0) return;
// Full alt-screen wipe + cursor home before Ink redraws. Ink's this.recoverFrame(cols, rows);
// clear() only resets log-update's tracked line count; if the
// previous frame painted more rows than the new terminal height
// (or content shrunk past a layout tier), those rows linger in the
// alt-screen buffer and the new frame paints on top, leaving
// garbage visible at the bottom. Writing \x1b[2J\x1b[H wipes the
// buffer so log-update's next render starts from a known-empty
// surface. Order matters: wipe first, then reset Ink's tracking,
// then notify so React reads fresh dims and rerenders cleanly.
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
try {
process.stdout.write("\x1b[2J\x1b[H");
} catch {
// Ignore — wipe is best-effort.
}
}
try {
this.inkInstance?.clear?.();
} catch {
// Ignore — clear is best-effort.
}
this.notify();
}, 50); }, 50);
}; };
if (process.stdout && typeof process.stdout.on === "function") { if (process.stdout && typeof process.stdout.on === "function") {
@@ -611,27 +590,21 @@ export class DashboardTUI {
}; };
try { try {
const [trueCols, trueRows] = stdout.getWindowSize?.() ?? [0, 0]; const [trueCols, trueRows] = stdout.getWindowSize?.() ?? [0, 0];
if (trueCols <= 0 || trueRows <= 0) return;
const cachedCols = stdout.columns ?? 0; const cachedCols = stdout.columns ?? 0;
const cachedRows = stdout.rows ?? 0; const cachedRows = stdout.rows ?? 0;
if ( if (trueCols !== cachedCols || trueRows !== cachedRows) {
trueCols > 0 && // Node's cache is stale — poke it to re-read so React reads the
trueRows > 0 && // new dims on the next render.
(trueCols !== cachedCols || trueRows !== cachedRows)
) {
// Node's cache is stale — force a refresh, which also emits
// 'resize' so the existing listener handles cleanup.
stdout._refreshSize?.(); stdout._refreshSize?.();
this.lastObservedCols = trueCols; }
this.lastObservedRows = trueRows; if (trueCols !== this.lastObservedCols || trueRows !== this.lastObservedRows) {
} else if ( // We haven't recovered to this size yet. Run the full recovery
trueCols > 0 && // path directly rather than relying on a 'resize' event from
trueRows > 0 && // _refreshSize, which doesn't always propagate under tmux+ssh
(trueCols !== this.lastObservedCols || trueRows !== this.lastObservedRows) // (or when Node's cache already happens to match the OS but
) { // Ink's frame buffer is still pinned to the previous layout).
// Cache and OS agree but we never recorded this size — likely this.recoverFrame(trueCols, trueRows);
// a resize event we already handled; just sync the baseline.
this.lastObservedCols = trueCols;
this.lastObservedRows = trueRows;
} }
} catch { } catch {
// ioctl can fail in edge cases (detached pty, etc.) — ignore. // ioctl can fail in edge cases (detached pty, etc.) — ignore.
@@ -639,6 +612,37 @@ export class DashboardTUI {
}, 2000); }, 2000);
} }
// Reset Ink's internal frame buffer (log-update line tracking) and wipe
// the alt-screen so a fresh render lands on a known-empty surface. Used
// by both the resize listener (SIGWINCH path) and the dim-poll fallback
// (tmux+ssh path where SIGWINCH is dropped). Idempotent: a redundant
// call is at worst a 1-frame flicker.
//
// Why: Ink's clear() only resets log-update's tracked line count; if the
// previous frame painted more rows than the new terminal height (or
// content shrunk past a layout tier), those rows linger in the
// alt-screen buffer and the new frame paints on top, leaving garbage
// visible at the bottom. \x1b[2J\x1b[H wipes the buffer first.
// Order: wipe → reset Ink's tracking → record dims → notify so React
// reads fresh dims and rerenders cleanly.
private recoverFrame(cols: number, rows: number): void {
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
try {
process.stdout.write("\x1b[2J\x1b[H");
} catch {
// Ignore — wipe is best-effort.
}
}
try {
this.inkInstance?.clear?.();
} catch {
// Ignore — clear is best-effort.
}
this.lastObservedCols = cols;
this.lastObservedRows = rows;
this.notify();
}
async stop(): Promise<void> { async stop(): Promise<void> {
if (!this.isRunning) return; if (!this.isRunning) return;
this.isRunning = false; this.isRunning = false;