feat(tui): unified header strip + Settings ←/→ pane nav

Drop the separate section + interactive tab strips for a single
unified strip: [m] Main [b] Board [a] Agents [g] Settings [t] Git
[e] Explorer. Width tiers tightened to fit every terminal size:
full labels at cols ≥ 90, glyph-only at 50–89, FUSION + active
pill only below 50.

New 'm' shortcut switches to status mode (Main); 's' kept as alias.
Number keys 1–5 still jump to status sections under the hood.

Settings view: ←/→ now switch between list and detail panes,
matching Agents view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-25 00:32:58 -07:00
parent f7d6e9f8fc
commit fb8b3e4a28
2 changed files with 59 additions and 64 deletions

View File

@@ -777,53 +777,45 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
function MainHeader({ state }: { state: DashboardState }) {
const inInteractive = state.mode === "interactive";
const focused = state.activeSection;
const interactiveView = state.interactiveView;
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const rows = stdout?.rows ?? 24;
const interactiveTabs: Array<{ key: string; label: string; view: InteractiveView }> = [
{ key: "b", label: "Board", view: "board" },
{ key: "a", label: "Agents", view: "agents" },
{ key: "g", label: "Settings", view: "settings" },
{ key: "t", label: "Git", view: "git" },
{ key: "e", label: "Explorer", view: "files" },
// Single unified tab strip. "Main" is the status mode; the rest are
// interactive views. Active key matches the current mode/view.
type Tab =
| { key: string; label: string; kind: "main" }
| { key: string; label: string; kind: "interactive"; view: InteractiveView };
const tabs: Tab[] = [
{ 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: "e", label: "Explorer", kind: "interactive", view: "files" },
];
// Keep FUSION on a single line by progressively dropping decoration as the
// terminal shrinks. Thresholds tuned to the natural widths of each segment:
// * Help/quit (~20 chars) drops first.
// * Inactive interactive tabs collapse to a `[k]` glyph.
// * Inactive section tabs collapse to `[N]`.
// * Active-only mode hides every inactive tab.
// * Tiny mode drops the whole header — the row is too precious to spend.
if (rows < 10) return null;
// Width tiers, measured against actual rendered content with 6 tabs:
// * Full + help (cols >= 110): "[k] Label" tabs + help/quit hint.
// * Full (90-109): "[k] Label" tabs, no help hint (~85 chars).
// * Compact (50-89): "[k]" glyphs only — every shortcut still
// visible, just no labels (~45 chars).
// * Tiny (< 50): just FUSION + the active tab pill.
const showHelpHint = cols >= 110;
const compactInteractive = cols < 100;
const compactSections = cols < 90;
const minimal = cols < 70;
const fullLabels = cols >= 90;
const tiny = cols < 50;
const compact = !fullLabels && !tiny;
const isActive = (t: Tab) =>
t.kind === "main" ? !inInteractive : inInteractive && t.view === interactiveView;
if (tiny) {
// Just FUSION + the single active tab. No dividers, no other tabs.
const activeSectionIdx = inInteractive
? -1
: SECTION_ORDER.indexOf(focused);
const activeSectionLabel = activeSectionIdx >= 0
? SECTION_ORDER[activeSectionIdx].charAt(0).toUpperCase() + SECTION_ORDER[activeSectionIdx].slice(1)
: null;
const activeInteractive = inInteractive
? interactiveTabs.find((t) => t.view === interactiveView) ?? null
: null;
// Just FUSION + the active tab pill. Inactive shortcuts dropped here.
const active = tabs.find(isActive);
return (
<Box flexDirection="row" gap={1} paddingX={1}>
<MiniLogo />
{activeSectionLabel && (
{active && (
<Box flexShrink={0}>
<Text backgroundColor="cyan" color="black" bold>{` ${activeSectionIdx + 1} ${activeSectionLabel} `}</Text>
</Box>
)}
{activeInteractive && (
<Box flexShrink={0}>
<Text backgroundColor="cyan" color="black" bold>{` ${activeInteractive.key} ${activeInteractive.label} `}</Text>
<Text backgroundColor="cyan" color="black" bold>{` ${active.key} ${active.label} `}</Text>
</Box>
)}
</Box>
@@ -832,39 +824,19 @@ function MainHeader({ state }: { state: DashboardState }) {
return (
<Box flexDirection="row" gap={1} paddingX={1} paddingY={0}>
<MiniLogo />
{!minimal && <Box flexShrink={0}><Text dimColor></Text></Box>}
{SECTION_ORDER.map((section, i) => {
const isActive = !inInteractive && section === focused;
const label = section.charAt(0).toUpperCase() + section.slice(1);
if (minimal && !isActive) return null;
<Box flexShrink={0}><Text dimColor></Text></Box>
{tabs.map((t) => {
const active = isActive(t);
return (
<Box key={section} marginRight={1} flexShrink={0}>
{isActive ? (
<Box key={t.key} marginRight={1} flexShrink={0}>
{active ? (
<Text backgroundColor="cyan" color="black" bold>
{compactSections ? ` ${i + 1} ${label} ` : ` [${i + 1}] ${label} `}
{compact ? ` ${t.key} ` : ` [${t.key}] ${t.label} `}
</Text>
) : compactSections ? (
<Text dimColor>{`[${i + 1}]`}</Text>
) : compact ? (
<Text dimColor>{`[${t.key}]`}</Text>
) : (
<Text dimColor>{`[${i + 1}] ${label}`}</Text>
)}
</Box>
);
})}
{!minimal && <Box flexShrink={0}><Text dimColor></Text></Box>}
{interactiveTabs.map(({ key, label, view }) => {
const isActive = inInteractive && view === interactiveView;
if (minimal && !isActive) return null;
return (
<Box key={view} marginRight={1} flexShrink={0}>
{isActive ? (
<Text backgroundColor="cyan" color="black" bold>
{compactInteractive ? ` ${key} ${label} ` : ` [${key}] ${label} `}
</Text>
) : compactInteractive ? (
<Text dimColor>{`[${key}]`}</Text>
) : (
<Text dimColor>{`[${key}] ${label}`}</Text>
<Text dimColor>{`[${t.key}] ${t.label}`}</Text>
)}
</Box>
);
@@ -2048,10 +2020,20 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
}
useInput((input, key) => {
// Tab cycles list ↔ detail. Left/right also switch — list = left,
// detail = right, matching the visual layout (consistent with AgentsView).
if (key.tab) {
setDetailFocused((f) => !f);
return;
}
if (key.leftArrow) {
setDetailFocused(false);
return;
}
if (key.rightArrow) {
setDetailFocused(true);
return;
}
if (!detailFocused) {
if (key.upArrow || input === "k") {
@@ -3471,7 +3453,9 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return;
}
if (input === "s" || input === "S") {
// 'm' / 's' (alias) — switch to Main (status mode). Lowercase only;
// capital S/M are reserved for vim-style "jump to end" semantics.
if (input === "m" || input === "s") {
if (state.mode === "interactive") {
controller.setMode("status");
return;