fix(dashboard-tui): harden resize handling against tmux/ssh races

Debounce SIGWINCH bursts (50ms trailing edge), wipe the alt-screen before
Ink redraws so shrunk frames don't leave stale rows, and add a 2s dim-poll
fallback for environments that drop SIGWINCH. LogsPanel now reads stdout
rows itself so timer-driven renders always see live dimensions, and the
layoutKey includes a resizeTick so Yoga's cached layout is invalidated
even when cols×rows lands back on the same string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-26 17:23:54 -07:00
parent 58e3933b50
commit f540faac9d
2 changed files with 144 additions and 27 deletions

View File

@@ -487,9 +487,19 @@ function LogsPanel({
}: {
state: DashboardState;
isFocused: boolean;
availableRows: number;
// Optional override; when omitted LogsPanel reads stdout.rows itself so
// the windowing budget always reflects live terminal dimensions, even on
// timer-driven re-renders that race tmux/ssh resize bursts.
availableRows?: number;
}) {
const { logsSeverityFilter, logsWrapEnabled, logsExpandedMode, selectedLogIndex } = state;
const { stdout } = useStdout();
// Subtract the chrome (header ~2, status bar 1, panel borders/title ~3,
// utilities/settings sub-row ~5) from live rows. Same heuristic the parent
// grid used; keeping it co-located ensures we always read the freshest
// stdout.rows on every render.
const liveRows = stdout?.rows ?? 24;
const rowBudget = Math.max(1, availableRows ?? Math.max(4, liveRows - 11));
const entries = logsSeverityFilter === "all"
? state.logEntries
@@ -504,7 +514,6 @@ function LogsPanel({
// Slide the viewport so the cursor is always visible. Newest entries sit at
// the bottom; oldest at the top — matching `tail`/`less` and how every
// human reads a log file.
const rowBudget = Math.max(1, availableRows);
const visibleStart = Math.max(0, Math.min(
cursor - Math.floor(rowBudget / 2),
entries.length - rowBudget,
@@ -693,16 +702,12 @@ const PANEL_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "setti
function StatusModeGrid({
state,
rows,
controller,
}: {
state: DashboardState;
rows: number;
controller: DashboardTUI;
}) {
const focused = state.activeSection;
const bodyRows = Math.max(8, rows - 7);
const logsAvailableRows = Math.max(4, bodyRows - 4);
return (
<Box flexDirection="column" flexGrow={1}>
@@ -719,7 +724,6 @@ function StatusModeGrid({
<LogsPanel
state={state}
isFocused={focused === "logs"}
availableRows={logsAvailableRows}
/>
<Box flexDirection="row" overflow="hidden">
<Box flexDirection="column" flexGrow={1} overflow="hidden">
@@ -751,7 +755,7 @@ function StatusModeSingle({
const activePanel = () => {
switch (focused) {
case "system": return <SystemPanel state={state} isFocused />;
case "logs": return <LogsPanel state={state} isFocused availableRows={Math.max(4, (process.stdout.rows ?? 24) - 8)} />;
case "logs": return <LogsPanel state={state} isFocused />;
case "utilities": return <UtilitiesPanel state={state} isFocused />;
case "stats": return <StatsPanel state={state} isFocused />;
case "settings": return <SettingsPanel state={state} isFocused />;
@@ -796,7 +800,7 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
// wrap to 2+ rows, throwing the layout's row budget off by 1-2 rows
// and pushing the header off the top of the alt-screen.
return (
<Box justifyContent="space-between" paddingX={1} flexShrink={0}>
<Box height={1} justifyContent="space-between" paddingX={1} flexShrink={0} overflow="hidden">
<Text dimColor wrap="truncate-end">{hotkeys.join(" · ")}</Text>
{statusParts.length > 0 && (
<Text dimColor wrap="truncate-end">{statusParts.join(" | ")}</Text>
@@ -847,38 +851,45 @@ function MainHeader({ state }: { state: DashboardState }) {
// Just FUSION + the active tab pill. Inactive shortcuts dropped here.
const active = tabs.find(isActive);
return (
<Box flexDirection="row" gap={1} paddingX={1} flexShrink={0} overflow="hidden">
<Box height={1} flexDirection="row" gap={1} paddingX={1} flexShrink={0} overflow="hidden">
<MiniLogo />
{active && (
<Box flexShrink={0}>
<Text backgroundColor="cyan" color="black" bold>{` ${active.key} ${active.label} `}</Text>
<Text wrap="truncate-end" backgroundColor="cyan" color="black" bold>{` ${active.key} ${active.label} `}</Text>
</Box>
)}
</Box>
);
}
// height={1} hard-caps the header at a single row. Without this, at
// certain boundary widths the default Text wrap="wrap" on a tab whose
// content lands one column past the parent width would push a second
// row, making the header 2 rows tall — which in turn makes the whole
// frame exceed terminal rows, so Ink pushes the top of the layout
// off-screen. Combined with wrap="truncate-end" on every tab Text
// below, both axes are protected against single-column overflow.
return (
<Box flexDirection="row" gap={1} paddingX={1} paddingY={0} flexShrink={0} overflow="hidden">
<Box height={1} flexDirection="row" gap={1} paddingX={1} paddingY={0} flexShrink={0} overflow="hidden">
<MiniLogo />
<Box flexShrink={0}><Text dimColor></Text></Box>
<Box flexShrink={0}><Text wrap="truncate-end" dimColor></Text></Box>
{tabs.map((t) => {
const active = isActive(t);
return (
<Box key={t.key} marginRight={1} flexShrink={0}>
{active ? (
<Text backgroundColor="cyan" color="black" bold>
<Text wrap="truncate-end" backgroundColor="cyan" color="black" bold>
{compact ? ` ${t.key} ` : ` [${t.key}] ${t.label} `}
</Text>
) : compact ? (
<Text dimColor>{`[${t.key}]`}</Text>
<Text wrap="truncate-end" dimColor>{`[${t.key}]`}</Text>
) : (
<Text dimColor>{`[${t.key}] ${t.label}`}</Text>
<Text wrap="truncate-end" dimColor>{`[${t.key}] ${t.label}`}</Text>
)}
</Box>
);
})}
<Box flexGrow={1} />
{showHelpHint && <Box flexShrink={0}><Text dimColor>[?] help [q] quit</Text></Box>}
{showHelpHint && <Box flexShrink={0}><Text wrap="truncate-end" dimColor>[?] help [q] quit</Text></Box>}
</Box>
);
}
@@ -3756,13 +3767,26 @@ export function DashboardApp({ controller }: DashboardAppProps) {
// dimensions. (The controller separately calls inkInstance.clear() to
// reset Ink's log-update line tracking — manually writing clear escape
// codes here would desync that tracking and break subsequent renders.)
const [, setResizeTick] = useState(0);
const [resizeTick, setResizeTick] = useState(0);
useEffect(() => {
if (!stdout) return;
const onResize = () => setResizeTick((t) => t + 1);
let followup: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
// Bump immediately so React re-reads dims on the next render. Then
// schedule a follow-up bump ~60ms later (past the controller's 50ms
// resize debounce) to cover the case where stdout.columns/rows had
// not yet settled at the first render — common under tmux/ssh.
setResizeTick((t) => t + 1);
if (followup) clearTimeout(followup);
followup = setTimeout(() => {
followup = null;
setResizeTick((t) => t + 1);
}, 60);
};
stdout.on("resize", onResize);
return () => {
stdout.off("resize", onResize);
if (followup) clearTimeout(followup);
};
}, [stdout]);
@@ -3978,7 +4002,11 @@ export function DashboardApp({ controller }: DashboardAppProps) {
// diff a layout that's still bound to old dimensions, we throw the
// tree away and rebuild from scratch with the new bounds. Cheap on
// every keystroke (resize is rare), avoids subtle Yoga caching bugs.
const layoutKey = `${cols}x${rows}`;
// Include resizeTick so every resize event remounts even if stdout reports
// the same cols×rows string (stale-read race, or two consecutive resizes
// back to the same width). Without this, Yoga keeps the cached layout from
// the prior render and the screen stays broken until the next dim change.
const layoutKey = `${cols}x${rows}#${resizeTick}`;
// Splash: show while systemInfo is not yet set.
if (!state.systemInfo) {
@@ -3998,7 +4026,7 @@ export function DashboardApp({ controller }: DashboardAppProps) {
) : isNarrow ? (
<StatusModeSingle state={state} controller={controller} />
) : (
<StatusModeGrid state={state} rows={rows} controller={controller} />
<StatusModeGrid state={state} controller={controller} />
)}
{state.showHelp && (
<Box position="absolute" marginTop={3} marginLeft={4}>

View File

@@ -81,6 +81,12 @@ export class DashboardTUI {
} & Record<string, unknown> | null = null;
// Resize listener attached at start(), detached at stop().
private resizeListener: (() => void) | null = null;
// Debounce timer for resize handling — coalesces tmux/ssh resize bursts.
private resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Last observed terminal dims, used by the dim-poll fallback to detect
// resizes that didn't deliver a SIGWINCH (common under tmux/ssh).
private lastObservedCols: number = 0;
private lastObservedRows: number = 0;
// Uptime ticker to keep footer time live.
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
@@ -517,17 +523,56 @@ export class DashboardTUI {
// terminal resize. Without this Ink keeps treating the previous frame's
// line count as the clear region, leaving stale rows above/below the
// new render until another unrelated rerender happens.
//
// Debounced: tmux and mosh fire resize bursts during pane negotiation,
// and `process.stdout.rows` can briefly read stale/zero values mid-burst.
// Coalescing to a single trailing edge lets dimensions settle before we
// clear+rerender, then a follow-up notify() forces React to re-read
// stdout dims one more time so any timer-driven render that landed
// mid-burst with stale rows is corrected.
this.resizeListener = () => {
try {
this.inkInstance?.clear?.();
} catch {
// Ignore — clear is best-effort.
}
if (this.resizeDebounceTimer) clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = setTimeout(() => {
this.resizeDebounceTimer = null;
const rows = process.stdout?.rows ?? 0;
const cols = process.stdout?.columns ?? 0;
if (rows <= 0 || cols <= 0) return;
// Full alt-screen wipe + cursor home before Ink redraws. 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. 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);
};
if (process.stdout && typeof process.stdout.on === "function") {
process.stdout.on("resize", this.resizeListener);
}
// Prime the observed-dims baseline so the systemStats poll below can
// detect when stdout dims change without a SIGWINCH (tmux/ssh
// sometimes drop the signal — the dims still update on the stream
// object, but no resize event fires, so the user sees a stuck
// layout). Polling every 2s catches that case at minor cost.
this.lastObservedCols = process.stdout?.columns ?? 0;
this.lastObservedRows = process.stdout?.rows ?? 0;
this.uptimeTimer = setInterval(() => {
if (this.isRunning) this.notify();
}, 5000);
@@ -537,7 +582,47 @@ export class DashboardTUI {
this.lastCpuSampleAt = Date.now();
this.sampleSystemStats();
this.systemStatsTimer = setInterval(() => {
if (this.isRunning) this.sampleSystemStats();
if (!this.isRunning) return;
this.sampleSystemStats();
// Dim-poll fallback: tmux/ssh sometimes drop SIGWINCH entirely, and
// Node only refreshes process.stdout.columns/rows when SIGWINCH
// arrives — so reading those properties returns stale values that
// never recover on their own. Force-query the OS via getWindowSize
// (ioctl-backed) and compare against Node's cached dims; if they
// diverge, SIGWINCH was lost. Calling _refreshSize() pokes Node to
// re-read and emit 'resize', which routes through our existing
// resize listener and triggers the full recovery path.
const stdout = process.stdout as (typeof process.stdout) & {
getWindowSize?: () => [number, number];
_refreshSize?: () => void;
};
try {
const [trueCols, trueRows] = stdout.getWindowSize?.() ?? [0, 0];
const cachedCols = stdout.columns ?? 0;
const cachedRows = stdout.rows ?? 0;
if (
trueCols > 0 &&
trueRows > 0 &&
(trueCols !== cachedCols || trueRows !== cachedRows)
) {
// Node's cache is stale — force a refresh, which also emits
// 'resize' so the existing listener handles cleanup.
stdout._refreshSize?.();
this.lastObservedCols = trueCols;
this.lastObservedRows = trueRows;
} else if (
trueCols > 0 &&
trueRows > 0 &&
(trueCols !== this.lastObservedCols || trueRows !== this.lastObservedRows)
) {
// Cache and OS agree but we never recorded this size — likely
// a resize event we already handled; just sync the baseline.
this.lastObservedCols = trueCols;
this.lastObservedRows = trueRows;
}
} catch {
// ioctl can fail in edge cases (detached pty, etc.) — ignore.
}
}, 2000);
}
@@ -559,6 +644,10 @@ export class DashboardTUI {
process.stdout.off("resize", this.resizeListener);
this.resizeListener = null;
}
if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = null;
}
if (this.inkInstance) {
this.inkInstance.unmount();