fix(tui): partial fixes for resize / wrong-height layout
Several layered defenses against the layout occasionally rendering
at the wrong height (header pushed off-screen, content too tall).
Still not fully resolved — flag for further debug — but this
combination materially reduces frequency, especially under tmux/ssh.
* Enter alt-screen on start (\x1b[?1049h), leave on stop. Gives the
TUI a dedicated fullscreen surface that doesn't share scrollback,
so frames taller than the viewport clip cleanly instead of
scrolling the header into shell history.
* Controller subscribes to process.stdout 'resize' and calls
inkInstance.clear() — resets log-update's previous-frame line
count so the next render isn't skewed by a stale clear region.
* App-level resize listener bumps a state counter and the root Box
uses key={`${cols}x${rows}`} so React unmounts and remounts the
whole tree on dimension change, bypassing any Yoga layout cache.
* Root Box gets explicit width={cols} + overflow="hidden" so
overflow gets clipped instead of pushing siblings out.
* StatusBar: Text children now wrap="truncate-end" with
flexShrink={0} on the outer Box. Default wrap="wrap" was letting
long hotkey+URL strings wrap to 2 rows, throwing the row budget
off by 1 — the proximate cause of the 151x46 wedge.
* MainHeader outer Box: flexShrink={0} + overflow="hidden" as
belt-and-suspenders.
* Settings + Utilities side-by-side now match heights: UtilitiesPanel
uses flexGrow={1} (was flexShrink={0}), so cross-axis stretch
applies equally.
* StatusModeGrid receives rows as a prop again (single source of
truth from App's render, instead of each child reading its own
useStdout snapshot).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -598,7 +598,7 @@ function UtilitiesPanel({ isFocused }: { isFocused: boolean }) {
|
||||
{ key: "?", label: "Help" },
|
||||
];
|
||||
return (
|
||||
<Panel title="Utilities" isFocused={isFocused} flexShrink={0}>
|
||||
<Panel title="Utilities" isFocused={isFocused} flexGrow={1}>
|
||||
<Box flexDirection="column">
|
||||
{actions.map((action) => (
|
||||
<Box key={action.key} flexDirection="row" gap={1}>
|
||||
@@ -776,10 +776,16 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
|
||||
statusParts.push(formatUptime(Date.now() - systemInfo.startTimeMs));
|
||||
}
|
||||
|
||||
// Truncate both halves so the StatusBar always fits in a single row.
|
||||
// Without this, default wrap="wrap" lets long hotkey strings or URLs
|
||||
// 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}>
|
||||
<Text dimColor>{hotkeys.join(" · ")}</Text>
|
||||
{statusParts.length > 0 && <Text dimColor>{statusParts.join(" | ")}</Text>}
|
||||
<Box justifyContent="space-between" paddingX={1} flexShrink={0}>
|
||||
<Text dimColor wrap="truncate-end">{hotkeys.join(" · ")}</Text>
|
||||
{statusParts.length > 0 && (
|
||||
<Text dimColor wrap="truncate-end">{statusParts.join(" | ")}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -826,7 +832,7 @@ 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}>
|
||||
<Box flexDirection="row" gap={1} paddingX={1} flexShrink={0} overflow="hidden">
|
||||
<MiniLogo />
|
||||
{active && (
|
||||
<Box flexShrink={0}>
|
||||
@@ -837,7 +843,7 @@ function MainHeader({ state }: { state: DashboardState }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} paddingX={1} paddingY={0}>
|
||||
<Box flexDirection="row" gap={1} paddingX={1} paddingY={0} flexShrink={0} overflow="hidden">
|
||||
<MiniLogo />
|
||||
<Box flexShrink={0}><Text dimColor>│</Text></Box>
|
||||
{tabs.map((t) => {
|
||||
@@ -3412,6 +3418,20 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
const { exit } = useApp();
|
||||
const { stdout } = useStdout();
|
||||
|
||||
// Bump a state counter on resize so React re-renders with the latest
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
if (!stdout) return;
|
||||
const onResize = () => setResizeTick((t) => t + 1);
|
||||
stdout.on("resize", onResize);
|
||||
return () => {
|
||||
stdout.off("resize", onResize);
|
||||
};
|
||||
}, [stdout]);
|
||||
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const rows = stdout?.rows ?? 24;
|
||||
|
||||
@@ -3610,10 +3630,18 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Splash: show while systemInfo is not yet set
|
||||
// The `key` keyed off live dimensions forces React to unmount and
|
||||
// remount the entire tree whenever the terminal resizes. This is the
|
||||
// hammer fix for Ink's stale-layout-on-resize: instead of trying to
|
||||
// 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}`;
|
||||
|
||||
// Splash: show while systemInfo is not yet set.
|
||||
if (!state.systemInfo) {
|
||||
return (
|
||||
<Box flexDirection="column" height={rows}>
|
||||
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
|
||||
<SplashScreen loadingStatus={state.loadingStatus} />
|
||||
</Box>
|
||||
);
|
||||
@@ -3622,7 +3650,7 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
const isNarrow = cols < 80 || rows < 20;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={rows}>
|
||||
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
|
||||
{state.mode === "interactive" ? (
|
||||
<InteractiveMode state={state} controller={controller} />
|
||||
) : isNarrow ? (
|
||||
|
||||
@@ -61,7 +61,15 @@ export class DashboardTUI {
|
||||
private cachedSnapshot: DashboardState | null = null;
|
||||
|
||||
// Ink instance — set when start() is called.
|
||||
private inkInstance: { unmount: () => void; waitUntilExit: () => Promise<unknown> } | null = null;
|
||||
// Loose type — the real Ink Instance has additional methods (clear,
|
||||
// rerender, etc.) that we use defensively below.
|
||||
private inkInstance: {
|
||||
unmount: () => void;
|
||||
waitUntilExit: () => Promise<unknown>;
|
||||
clear?: () => void;
|
||||
} & Record<string, unknown> | null = null;
|
||||
// Resize listener attached at start(), detached at stop().
|
||||
private resizeListener: (() => void) | null = null;
|
||||
|
||||
// Uptime ticker to keep footer time live.
|
||||
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -341,10 +349,37 @@ export class DashboardTUI {
|
||||
const { createElement } = await import("react");
|
||||
const { DashboardApp } = await import("./app.js");
|
||||
|
||||
// Enter the terminal's alternate-screen buffer before mounting Ink so
|
||||
// the TUI gets a dedicated fullscreen surface that doesn't share
|
||||
// scrollback with the user's shell history. Without this, Ink writes
|
||||
// top-down and any frame taller than the terminal pushes the top
|
||||
// (header) into scrollback. Especially noticeable under tmux/ssh
|
||||
// where dimension reporting and status bars can leave the rendered
|
||||
// frame a row or two too tall.
|
||||
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||
// \x1b[?1049h = enter alt-screen, \x1b[H = home cursor.
|
||||
process.stdout.write("\x1b[?1049h\x1b[H");
|
||||
}
|
||||
|
||||
this.inkInstance = render(
|
||||
createElement(DashboardApp, { controller: this }),
|
||||
);
|
||||
|
||||
// Reset Ink's internal frame buffer (log-update line tracking) on every
|
||||
// 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.
|
||||
this.resizeListener = () => {
|
||||
try {
|
||||
this.inkInstance?.clear?.();
|
||||
} catch {
|
||||
// Ignore — clear is best-effort.
|
||||
}
|
||||
};
|
||||
if (process.stdout && typeof process.stdout.on === "function") {
|
||||
process.stdout.on("resize", this.resizeListener);
|
||||
}
|
||||
|
||||
this.uptimeTimer = setInterval(() => {
|
||||
if (this.isRunning) this.notify();
|
||||
}, 5000);
|
||||
@@ -372,10 +407,20 @@ export class DashboardTUI {
|
||||
this.systemStatsTimer = null;
|
||||
}
|
||||
|
||||
if (this.resizeListener && process.stdout && typeof process.stdout.off === "function") {
|
||||
process.stdout.off("resize", this.resizeListener);
|
||||
this.resizeListener = null;
|
||||
}
|
||||
|
||||
if (this.inkInstance) {
|
||||
this.inkInstance.unmount();
|
||||
this.inkInstance = null;
|
||||
}
|
||||
// Leave the alt-screen buffer last so the user's shell scrollback
|
||||
// is restored cleanly. \x1b[?1049l = leave alt-screen.
|
||||
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||
process.stdout.write("\x1b[?1049l");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user