fix(FN-2722): simplify dashboard TUI header and panel switching behavior

- Remove stdout header overlay patching and related frame recovery hooks from the dashboard TUI controller
- Keep panel switching logic lightweight by relying on normal state updates without forced recover cycles
- Adjust status footer and utility/system panel rendering to maintain a single-line, truncation-friendly layout
- Add explicit number-key panel mapping while preserving existing tab/cycle panel order
This commit is contained in:
Fusion
2026-04-28 08:24:24 -07:00
committed by gsxdsm
parent ff58623c32
commit 0975e24512
2 changed files with 43 additions and 186 deletions

View File

@@ -62,7 +62,6 @@ import type {
TaskEvent,
UpdateStatus,
} from "./state.js";
import { SECTION_ORDER } from "./state.js";
import type { LogEntry } from "./log-ring-buffer.js";
import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js";
import { useProjects, useTasks } from "./hooks/use-projects.js";
@@ -219,6 +218,10 @@ function Panel({ title, isFocused, children, flexGrow, flexShrink, width }: Pane
function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: boolean }) {
const info = state.systemInfo;
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
// Watcher is the lowest-signal chip — drop it first when chips would wrap.
const showWatcher = cols >= 100;
return (
<Panel title="System" isFocused={isFocused} flexGrow={1}>
{!info ? (
@@ -239,20 +242,22 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
{info.engineMode === "paused" && <Text color="yellow">paused</Text>}
{info.engineMode === "active" && <Text color="green">active</Text>}
</Box>
<Box flexDirection="row" gap={1} flexShrink={0}>
<Text dimColor>Watcher</Text>
{info.fileWatcher ? <Text color="green">active</Text> : <Text color="red">inactive</Text>}
</Box>
<Box flexDirection="row" gap={1} flexShrink={0}>
<Text dimColor>Uptime</Text>
<Text>{formatUptime(Date.now() - info.startTimeMs)}</Text>
</Box>
<Box flexDirection="row" gap={1} flexShrink={0}>
<Text dimColor>Auth</Text>
{info.authEnabled
? <Text color="yellow">bearer</Text>
: <Text color="yellow">none</Text>}
</Box>
{showWatcher && (
<Box flexDirection="row" gap={1} flexShrink={0}>
<Text dimColor>Watcher</Text>
{info.fileWatcher ? <Text color="green">active</Text> : <Text color="red">inactive</Text>}
</Box>
)}
<Box flexDirection="row" gap={1} flexShrink={0}>
<Text dimColor>Uptime</Text>
<Text>{formatUptime(Date.now() - info.startTimeMs)}</Text>
</Box>
{info.authToken && (
<Box flexDirection="row" gap={1} flexShrink={1}>
<Text dimColor>Token</Text>
@@ -620,8 +625,8 @@ function UtilitiesPanel({ state, isFocused }: { state: DashboardState; isFocused
<Panel title="Utilities" isFocused={isFocused} flexGrow={1}>
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
{actions.map((action) => (
<Box key={action.key} flexDirection="row" gap={1} flexShrink={0}>
<Text color="yellow">[{action.key}]</Text>
<Box key={action.key} flexDirection="row" flexShrink={0}>
<Text color="yellow">{`[${action.key}] `}</Text>
<Text wrap="truncate-end">{action.label}</Text>
</Box>
))}
@@ -642,7 +647,7 @@ function HelpOverlay() {
["[f]", "Files (when not on Logs); cycles log severity filter on Logs"],
["[Tab]", "Cycle focused panel / pane forward"],
["[Shift+Tab]", "Cycle focused panel / pane backward"],
["[1-5]", "Jump to panel (Main: System/Logs/Utilities/Stats/Settings)"],
["[1-5]", "Jump to panel (Main: System/Logs/Stats/Utilities/Settings)"],
["[← / →]", "Switch pane (Agents, Settings, Files, Git)"],
["[→] / [n]", "Next panel (Main)"],
["[←] / [p]", "Previous panel (Main)"],
@@ -698,6 +703,11 @@ function HelpOverlay() {
const PANEL_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
// Number-key mapping (1-5) — kept distinct from PANEL_ORDER/SECTION_ORDER so
// the visual/cycle order stays unchanged while [3]/[4] address Stats/Utilities
// in the swapped order requested.
const NUMBER_KEY_ORDER: SectionId[] = ["system", "logs", "stats", "utilities", "settings"];
function StatusModeGrid({
state,
controller,
@@ -807,11 +817,11 @@ function StatusModeSingle({
const rows = stdout?.rows ?? 24;
const cols = stdout?.columns ?? 80;
// LogsPanel's row budget — explicit cap so it doesn't render more
// entries than fit. Chrome accounting (chrome=6 base, +1 if narrow spacer):
// entries than fit. Chrome=6:
// header(1) + statusbar(1) +
// panel border top(1) + panel title(1) + panel border bottom(1) +
// filter row(1) = 6, plus 1 if cols<72 spacer is present.
const logsAvailableRows = Math.max(1, rows - (cols < 71 ? 7 : 6));
// filter row(1) = 6.
const logsAvailableRows = Math.max(1, rows - 6);
tuiDebug("StatusModeSingle", { cols, rows, logsAvailableRows, focused });
const activePanel = () => {
@@ -824,15 +834,8 @@ function StatusModeSingle({
}
};
// Below cols=72 the layout shifts up by 1 (Yoga edge case at narrow
// widths) and the panel's top line ends up under the header overlay. A
// 1-row spacer compensates ONLY for those widths. From 72 onwards the
// panel sits in the right place naturally and the spacer is just dead
// space.
const needsSpacer = cols < 71;
return (
<Box flexDirection="column" flexGrow={1}>
{needsSpacer && <Box height={1} flexShrink={0} />}
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{activePanel()}
</Box>
@@ -844,45 +847,25 @@ function StatusModeSingle({
}
function StatusBar({ state, controller: _controller }: { state: DashboardState; controller: DashboardTUI }) {
const { systemInfo, activeSection } = state;
const hotkeys: string[] = [];
if (activeSection === "logs") {
hotkeys.push("↑↓ navigate", "w wrap", "f filter");
} else if (activeSection === "utilities") {
hotkeys.push("r refresh", "c clear logs", "t toggle pause", "k kill vitest", "v auto-kill", "+/- threshold");
} else {
hotkeys.push("Tab cycle panel", "1-5 jump");
}
const { updateStatus } = state;
const { systemInfo, updateStatus } = state;
const hasUpdate = updateStatus?.updateAvailable === true;
const uptime = systemInfo ? formatUptime(Date.now() - systemInfo.startTimeMs) : null;
const url = systemInfo?.baseUrl ?? null;
const help = "Tab cycle panel · 1-5 jump";
const uptimePart = systemInfo ? formatUptime(Date.now() - systemInfo.startTimeMs) : null;
const versionPart = systemInfo ? `${systemInfo.baseUrl} v${FUSION_VERSION}` : null;
// Both halves must collapse into a single row regardless of width.
// The right side is rendered as a single Text (not a Box of separate
// Texts joined with gap+flexShrink=0) so Yoga can truncate it when
// the natural width of hotkeys + version exceeds `cols`. With the
// previous structure, the row's natural width could exceed `cols`
// (~137 in the worst case: utilities hotkeys + localhost URL + uptime
// + version + ●), causing the row to wrap to 2 lines; height={1} +
// overflow=hidden then clipped the top line, making the hotkeys row
// disappear and only the version row show through.
const rightParts: string[] = [];
if (uptimePart) rightParts.push(uptimePart, "|");
if (versionPart) rightParts.push(versionPart);
const rightText = rightParts.join(" ");
// Single Text so Yoga truncates the tail (help text) when natural width
// exceeds cols — guarantees a one-row footer with version/url preserved.
const leftSegments = [`v${FUSION_VERSION}`];
if (url) leftSegments.push(url);
if (uptime) leftSegments.push(uptime);
const left = leftSegments.join(" · ");
return (
<Box height={1} justifyContent="space-between" paddingX={1} flexShrink={0} overflow="hidden">
<Text dimColor wrap="truncate-end">{hotkeys.join(" · ")}</Text>
{rightText && (
<Text wrap="truncate-end">
<Text dimColor>{rightText}</Text>
{hasUpdate && <Text color="yellow">{" ●"}</Text>}
</Text>
)}
<Box height={1} paddingX={1} flexShrink={0} overflow="hidden">
<Text wrap="truncate-end">
<Text dimColor>{left}</Text>
{hasUpdate && <Text color="yellow">{" ●"}</Text>}
<Text dimColor>{`${help}`}</Text>
</Text>
</Box>
);
}
@@ -891,64 +874,6 @@ 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;
@@ -4027,7 +3952,7 @@ export function DashboardApp({ controller }: DashboardAppProps) {
// Number keys switch panels (status mode)
if (input >= "1" && input <= "5") {
const section = SECTION_ORDER[parseInt(input, 10) - 1];
const section = NUMBER_KEY_ORDER[parseInt(input, 10) - 1];
if (section) {
controller.setActiveSection(section);
}

View File

@@ -96,9 +96,6 @@ 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
@@ -429,17 +426,8 @@ export class DashboardTUI {
// ── State helpers called from Ink App ────────────────────────────────────
setActiveSection(section: SectionId): void {
const changed = this.activeSection !== section;
this.activeSection = section;
this.showHelp = false;
if (changed) {
// Brute-force: wipe alt-screen + reset Ink's log-update tracking before
// rendering the new section. Some sections (Logs, Utilities) appear to
// leave residual state in tmux that scrolls the header off the top on
// the first render after switching. A full recover here is overkill on
// every switch but eliminates the artifact.
this.recoverFrameNow();
}
this.notify();
}
@@ -478,7 +466,6 @@ export class DashboardTUI {
const idx = SECTION_ORDER.indexOf(this.activeSection);
this.activeSection = SECTION_ORDER[(idx + direction + SECTION_ORDER.length) % SECTION_ORDER.length];
this.showHelp = false;
this.recoverFrameNow();
this.notify();
}
@@ -563,7 +550,7 @@ export class DashboardTUI {
// that only exercise pure logic).
const { render } = await import("ink");
const { createElement } = await import("react");
const { DashboardApp, buildHeaderAnsiLine } = await import("./app.js");
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
@@ -577,45 +564,6 @@ 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 }),
);
@@ -713,16 +661,6 @@ export class DashboardTUI {
// visible at the bottom. \x1b[2J\x1b[H wipes the buffer first.
// Order: wipe → reset Ink's tracking → record dims → notify so React
// reads fresh dims and rerenders cleanly.
// Public version: callable from state-changing methods (e.g. switching
// panels). Reads current dims from process.stdout and runs the same
// wipe + ink-clear sequence as the resize-driven path.
recoverFrameNow(): void {
const cols = process.stdout?.columns ?? 0;
const rows = process.stdout?.rows ?? 0;
if (cols <= 0 || rows <= 0) return;
this.recoverFrame(cols, rows);
}
private recoverFrame(cols: number, rows: number): void {
tuiDebug("recoverFrame", {
cols,
@@ -774,12 +712,6 @@ 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") {