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