fix(tui): redesign Main layout, robust header, panel reshuffle

Header was disappearing in tmux at narrow widths under Logs/Utilities
panels because Yoga's flex-column placed the panel body at y=0 instead
of y=1, overdrawing the header. Synthetic tests at every width show the
layout as correct, but real-tmux log-update tracking drifts over many
state updates and the header gets scrolled off.

- Paint header as an ANSI overlay after every Ink frame write (DECSC/
  DECRC cursor save/restore) — guaranteed at terminal row 1 regardless
  of any layout drift. Skipped during splash so the loading screen is
  uncluttered.
- StatusModeGrid: full-width System on top (4-row pinned, chips wrap if
  needed) + Logs filling the middle + Stats/Utilities/Settings as
  equal-width bottom row. Stats now shows just Process/System rows.
- StatusModeSingle: 1-row spacer only at cols<68 (Yoga edge case at
  very narrow widths shifts content up); 68+ has no spacer per UX.
- Panel/LogsPanel/UtilitiesPanel inner content boxes pinned with
  flexShrink=1 + overflow=hidden so panel intrinsic height can't push
  the frame past terminal rows.
- Each Logs entry wrapped in height={1} Box (when wrap is off) — Yoga
  was sometimes measuring nested Text as taller than 1 row at narrow
  widths.
- Tab "Explorer" → "Files" with shortcut "e" → "f" (preserves
  filter-cycle on Logs panel; switches to Files view elsewhere).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 17:00:43 -07:00
parent b055c81ec0
commit e598d60f01
2 changed files with 136 additions and 37 deletions

View File

@@ -705,22 +705,27 @@ function StatusModeGrid({
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
const cols = stdout?.columns ?? 80;
// Middle area = rows - header(1) - body marginTop(1) - statusbar(1) = rows-3.
// Top of middle: System (short, intrinsic) + Logs (fills).
// Bottom of middle: Stats + Utilities + Settings, equal-width.
const middleHeight = Math.max(1, rows - 3);
// Middle area = rows - header(1) - statusbar(1) = rows-2 (no top spacer).
// System fixed at 4 rows. Bottom row scales with available space.
// Logs fills what remains.
const middleHeight = Math.max(1, rows - 2);
const SYSTEM_HEIGHT = 4;
const bottomShare = Math.min(10, Math.max(6, Math.floor(middleHeight * 0.35)));
const topShare = Math.max(1, middleHeight - bottomShare);
const logsShare = Math.max(1, middleHeight - SYSTEM_HEIGHT - bottomShare);
// LogsPanel chrome: border 2 + title 1 + filter 1 = 4.
const logsAvailableRows = Math.max(1, topShare - 4);
tuiDebug("StatusModeGrid", { cols, rows, middleHeight, topShare, bottomShare, focused });
const logsAvailableRows = Math.max(1, logsShare - 4);
tuiDebug("StatusModeGrid", { cols, rows, middleHeight, logsShare, bottomShare, focused });
return (
<Box flexDirection="column" flexGrow={1}>
{/* No top spacer — saves a row. System panel's top border sits at the
same terminal row as the header overlay (covered by it), same
tradeoff as StatusModeSingle. */}
<Box flexDirection="column" flexGrow={1} overflow="hidden">
{/* System: full width, short height. flexShrink=2 so it collapses
faster than Logs when vertical space is tight. */}
<Box flexShrink={2} overflow="hidden">
{/* System: full width, pinned to 4 rows tall (border 2 + 2 content
rows so the chips always have room to wrap to a second line if
needed). flexShrink=0 so it never shrinks below this height. */}
<Box height={4} flexShrink={0} overflow="hidden">
<SystemPanel state={state} isFocused={focused === "system"} />
</Box>
{/* Logs: fills remaining vertical space. flexShrink=0 so System and
@@ -765,12 +770,12 @@ function StatusModeSingle({
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
const cols = stdout?.columns ?? 80;
// LogsPanel's row budget — an explicit cap so it doesn't try to render
// more entries than will fit. Chrome accounting:
// header(1) + body marginTop(1) + statusbar(1) +
// LogsPanel's row budget — explicit cap so it doesn't render more
// entries than fit. Chrome accounting (chrome=6 base, +1 if narrow spacer):
// header(1) + statusbar(1) +
// panel border top(1) + panel title(1) + panel border bottom(1) +
// filter row(1) = 7.
const logsAvailableRows = Math.max(1, rows - 7);
// filter row(1) = 6, plus 1 if cols<68 spacer is present.
const logsAvailableRows = Math.max(1, rows - (cols < 68 ? 7 : 6));
tuiDebug("StatusModeSingle", { cols, rows, logsAvailableRows, focused });
const activePanel = () => {
@@ -783,8 +788,15 @@ function StatusModeSingle({
}
};
// Below cols=68 the layout shifts up by 1 (Yoga edge case at very narrow
// widths) and the panel's top line ends up under the header overlay. A
// 1-row spacer compensates ONLY for those widths. From 68 onwards the
// panel sits in the right place naturally and the spacer is just dead
// space the user explicitly asked us to remove.
const needsSpacer = cols < 68;
return (
<Box flexDirection="column" flexGrow={1}>
{needsSpacer && <Box height={1} flexShrink={0} />}
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{activePanel()}
</Box>
@@ -831,6 +843,64 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
// ── Unified main header — used by both status and interactive modes ──────────
// Build a one-row ANSI string for the header, exactly `cols` printable chars
// wide. Used by the controller as a stdout-overlay failsafe so the header
// is always present at terminal row 1 regardless of any log-update tracking
// drift in real terminals (notably tmux). This is independent of the Ink
// flow layout — the React-rendered <MainHeader> is the primary rendering;
// the overlay is just a defensive write-after-Ink-frame.
const HEADER_TABS_DEF: Array<{ key: string; label: string; kind: "main" | "interactive"; view?: InteractiveView }> = [
{ key: "m", label: "Main", kind: "main" },
{ key: "b", label: "Board", kind: "interactive", view: "board" },
{ key: "a", label: "Agents", kind: "interactive", view: "agents" },
{ key: "g", label: "Settings", kind: "interactive", view: "settings" },
{ key: "t", label: "Git", kind: "interactive", view: "git" },
{ key: "f", label: "Files", kind: "interactive", view: "files" },
];
export function buildHeaderAnsiLine(state: DashboardState, cols: number): string {
const inInteractive = state.mode === "interactive";
const interactiveView = state.interactiveView;
const isActive = (t: typeof HEADER_TABS_DEF[number]) =>
t.kind === "main" ? !inInteractive : inInteractive && t.view === interactiveView;
const tiny = cols < 50;
const fullLabels = cols >= 90;
const compact = !fullLabels && !tiny;
const FG_CYAN_BRIGHT = "\x1b[1;96m";
const DIM = "\x1b[2m";
const RESET = "\x1b[0m";
const PILL_ON = "\x1b[1;30;46m";
const SEP_TXT = "\x1b[2m│\x1b[0m";
let visibleLen = 0;
const out: string[] = [];
const push = (visible: string, ansi: string) => {
out.push(ansi);
visibleLen += visible.length;
};
push(" ", " ");
push("FUSION", `${FG_CYAN_BRIGHT}FUSION${RESET}`);
if (tiny) {
const active = HEADER_TABS_DEF.find(isActive);
if (active) {
push(" ", " ");
const txt = ` ${active.key} ${active.label} `;
push(txt, `${PILL_ON}${txt}${RESET}`);
}
} else {
push(" ", " ");
push("│", SEP_TXT);
for (const t of HEADER_TABS_DEF) {
push(" ", " ");
const active = isActive(t);
const label = compact
? (active ? ` ${t.key} ` : `[${t.key}]`)
: (active ? ` [${t.key}] ${t.label} ` : `[${t.key}] ${t.label}`);
push(label, active ? `${PILL_ON}${label}${RESET}` : `${DIM}${label}${RESET}`);
}
}
if (visibleLen < cols) out.push(" ".repeat(cols - visibleLen));
return out.join("") + "\x1b[K";
}
function MainHeader({ state }: { state: DashboardState }) {
const inInteractive = state.mode === "interactive";
const interactiveView = state.interactiveView;
@@ -4054,32 +4124,13 @@ export function DashboardApp({ controller }: DashboardAppProps) {
hasSystemInfo: Boolean(state.systemInfo),
});
// Use flex-column natural placement (matches what Board/InteractiveMode
// does — that layout has always worked). Header takes its intrinsic 1
// row; body fills the rest via flexGrow=1.
//
// CRITICAL: marginTop={1} on the body. At narrow widths with Logs or
// Utilities active, Yoga's flex-column was placing the panel border at
// y=0 (same row as the header), causing the body to overdraw the header.
// The 1-row top margin guarantees panel content starts on row 1
// regardless of any Yoga edge-case at certain widths. Net cost: 1 row
// of vertical space (so the panel area is rows-2 instead of rows-1),
// but the header is always visible.
return (
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
{/* Header: explicit height={1} so the wrapper always reserves row 0,
even at narrow widths where MainHeader's intrinsic height could
(in some Yoga edge cases) collapse to 0. */}
{/* Header: explicit height={1} so the wrapper always reserves row 0. */}
<Box height={1} width={cols} flexShrink={0} flexGrow={0} flexDirection="row" overflow="hidden">
<MainHeader state={state} />
</Box>
<Box
flexGrow={1}
flexShrink={1}
marginTop={1}
flexDirection="column"
overflow="hidden"
>
<Box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
{state.mode === "interactive" ? (
<InteractiveMode state={state} controller={controller} />
) : isNarrow ? (

View File

@@ -93,6 +93,9 @@ export class DashboardTUI {
} & Record<string, unknown> | null = null;
// Resize listener attached at start(), detached at stop().
private resizeListener: (() => void) | null = null;
// Original process.stdout.write before we patch it for the header overlay.
// Stored so stop() can restore it.
private originalStdoutWrite: typeof process.stdout.write | 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
@@ -536,7 +539,7 @@ export class DashboardTUI {
// that only exercise pure logic).
const { render } = await import("ink");
const { createElement } = await import("react");
const { DashboardApp } = await import("./app.js");
const { DashboardApp, buildHeaderAnsiLine } = 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
@@ -550,6 +553,45 @@ export class DashboardTUI {
process.stdout.write("\x1b[?1049h\x1b[H");
}
// Header overlay failsafe — paint the header at terminal row 1 after
// every Ink frame write. The Ink/Yoga layout is correct in synthetic
// tests, but in real tmux at narrow widths the header still goes
// missing — likely log-update line-tracking drift over many state
// updates. The overlay is a guaranteed-paint-on-top write that's
// independent of Ink's output, with cursor save/restore so log-update
// tracking is preserved.
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
const original = process.stdout.write.bind(process.stdout) as
((...args: unknown[]) => boolean);
this.originalStdoutWrite = process.stdout.write;
let inOverlay = false;
const overlay = (text: string): void => {
if (inOverlay) return;
if (typeof text !== "string" || text.length < 200) return;
const snapshot = this.getSnapshot();
// Skip overlay during splash/loading (before systemInfo arrives) so
// the splash screen has clean unobstructed real estate.
if (!snapshot.systemInfo) return;
const cols = process.stdout.columns ?? 0;
if (cols <= 0) return;
try {
inOverlay = true;
const headerLine = buildHeaderAnsiLine(snapshot, cols);
// \x1b7 = DECSC (save cursor + attrs), \x1b[1;1H = move to terminal
// row 1 col 1, header, \x1b8 = DECRC (restore cursor + attrs).
original(`\x1b7\x1b[1;1H${headerLine}\x1b8`);
} finally {
inOverlay = false;
}
};
const patched = (...args: unknown[]): boolean => {
const result = original(...args);
overlay(args[0] as string);
return result;
};
(process.stdout as unknown as { write: unknown }).write = patched;
}
this.inkInstance = render(
createElement(DashboardApp, { controller: this }),
);
@@ -708,6 +750,12 @@ export class DashboardTUI {
this.inkInstance.unmount();
this.inkInstance = null;
}
// Restore original stdout.write (undo the header-overlay patch).
if (this.originalStdoutWrite) {
(process.stdout as unknown as { write: typeof process.stdout.write }).write =
this.originalStdoutWrite;
this.originalStdoutWrite = 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") {