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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user