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:
gsxdsm
2026-04-25 01:07:59 -07:00
parent 178736958b
commit cb0f6f3c02
2 changed files with 83 additions and 10 deletions

View File

@@ -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 ? (

View File

@@ -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 ────────────────────────────────────────────────────────