feat(FN-2408): merge fusion/fn-2408

This commit is contained in:
Fusion
2026-04-24 22:40:53 -07:00
committed by gsxdsm
parent dbe34bc141
commit c039af066c
18 changed files with 341 additions and 263 deletions

View File

@@ -75,9 +75,8 @@ describe("DashboardApp smoke", () => {
const controller = newController();
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
const frame = lastFrame() ?? "";
// Block-letter "F" opens with this run on wide terminals; on narrow
// terminals the compact layout shows plain "FUSION".
expect(frame).toMatch(/███████╗|FUSION/);
// Splash can render either the compact text mark or the expanded block-art logo.
expect(frame).toMatch(/FUSION|███████╗/);
expect(frame).toContain("AI coding agent dashboard");
unmount();
});

View File

@@ -75,9 +75,10 @@ function formatRelativeTime(iso: string): string {
return `${h}h ago`;
}
// All-blue vertical gradient — top: brightest white, bottom: deep blue.
// Strictly white + blue shades, no cyan/purple.
const LOGO_COLORS = ["whiteBright", "white", "blueBright", "blueBright", "blue", "blue", "blue", "blue"] as const;
// All-blue vertical gradient — top: brightest white, fading through plain
// blue. blueBright is avoided because some terminal themes render it with
// a purple cast; we want the gradient to read as strictly white→blue.
const LOGO_COLORS = ["whiteBright", "white", "white", "blue", "blue", "blue", "blue", "blue"] as const;
type InkColor = typeof LOGO_COLORS[number];
function logoColor(index: number, total: number): InkColor {
@@ -119,15 +120,15 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
return (
<Box flexDirection="column" paddingX={1} paddingY={1}>
{compact ? (
<Text bold color="blueBright">FUSION</Text>
<Text bold color="blue">FUSION</Text>
) : (
<AnimatedFusionLogo lines={large ? FUSION_LOGO_LARGE_LINES : FUSION_LOGO_LINES} />
)}
<Text color="blueBright" dimColor>{FUSION_TAGLINE}</Text>
<Text color="blue" dimColor>{FUSION_TAGLINE}</Text>
<Box height={1} />
<Box flexDirection="row" gap={1}>
<Text color="blueBright"><Spinner type="dots" /></Text>
<Text color="blueBright" dimColor>{loadingStatus}</Text>
<Text color="blue"><Spinner type="dots" /></Text>
<Text color="blue" dimColor>{loadingStatus}</Text>
</Box>
</Box>
);
@@ -138,7 +139,7 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
function MiniLogo() {
return (
<Box flexDirection="row" gap={0}>
<Text color="blueBright" bold>FUSION</Text>
<Text color="blue" bold>FUSION</Text>
</Box>
);
}
@@ -248,39 +249,159 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
// ── Stats panel ───────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "—";
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb.toFixed(0)}MB`;
return `${(mb / 1024).toFixed(2)}GB`;
}
function heapColor(used: number, limit: number): "red" | "yellow" | "green" {
if (limit <= 0) return "green";
const pct = used / limit;
if (pct >= 0.85) return "red";
if (pct >= 0.65) return "yellow";
return "green";
}
function rssColor(rss: number, totalSystemMem: number): "red" | "yellow" | undefined {
if (totalSystemMem <= 0) return undefined;
const pct = rss / totalSystemMem;
if (pct >= 0.5) return "red";
if (pct >= 0.25) return "yellow";
return undefined;
}
function sysMemColor(used: number, total: number): "red" | "yellow" | undefined {
if (total <= 0) return undefined;
const pct = used / total;
if (pct >= 0.9) return "red";
if (pct >= 0.75) return "yellow";
return undefined;
}
function cpuColor(percent: number, cores: number): "red" | "yellow" | undefined {
// Per-core normalized — >100% means oversubscribed.
const norm = cores > 0 ? percent / cores : percent;
if (norm >= 80) return "red";
if (norm >= 50) return "yellow";
return undefined;
}
function StatRow({ label, children }: { label: string; children: React.ReactNode }) {
// Fixed-width label column produces a clean two-column layout.
return (
<Box flexDirection="row" marginBottom={0}>
<Box width={11}>
<Text dimColor>{label}</Text>
</Box>
<Box flexDirection="row" gap={1}>{children}</Box>
</Box>
);
}
function StatsPanel({ state, isFocused }: { state: DashboardState; isFocused: boolean }) {
const stats = state.taskStats;
const sys = state.systemStats;
return (
<Panel title="Stats" isFocused={isFocused} flexGrow={1}>
{!stats ? (
<Text dimColor>Statistics not available.</Text>
) : (
<Box flexDirection="column">
<Box flexDirection="row" gap={1}>
<Text dimColor>Total:</Text>
<Text>{stats.total}</Text>
</Box>
{Object.entries(stats.byColumn).map(([col, count]) => {
const name = col.replace(/-/g, " ");
const isActive = (col === "in-progress" || col === "in-review") && count > 0;
return (
<Box key={col} flexDirection="row" gap={1} marginLeft={1}>
<Text dimColor>{name}:</Text>
<Text color={isActive ? "green" : undefined}>{count}</Text>
</Box>
);
})}
<Box height={1} />
<Text dimColor>Agents:</Text>
<Box marginLeft={1} flexDirection="column">
<Text dimColor>idle: <Text color="white">{stats.agents.idle}</Text></Text>
<Text dimColor>active: <Text color="green">{stats.agents.active}</Text></Text>
<Text color={stats.agents.error > 0 ? "red" : undefined} dimColor={stats.agents.error === 0}>
error: {stats.agents.error}
</Text>
</Box>
</Box>
)}
<Box flexDirection="column">
{sys && (
<>
<Text bold>Process</Text>
<Box marginLeft={1} flexDirection="column" marginTop={0}>
<StatRow label="RSS">
<Text color={rssColor(sys.rss, sys.systemTotalMem)}>
{formatBytes(sys.rss)}
</Text>
{sys.systemTotalMem > 0 && (
<Text dimColor>
({((sys.rss / sys.systemTotalMem) * 100).toFixed(1)}%)
</Text>
)}
</StatRow>
<StatRow label="Heap">
<Text color={heapColor(sys.heapUsed, sys.heapLimit)}>
{formatBytes(sys.heapUsed)}
</Text>
<Text dimColor>/ {formatBytes(sys.heapTotal)}</Text>
<Text dimColor>· limit {formatBytes(sys.heapLimit)}</Text>
</StatRow>
<StatRow label="External">
<Text>{formatBytes(sys.external)}</Text>
<Text dimColor>· buffers {formatBytes(sys.arrayBuffers)}</Text>
</StatRow>
<StatRow label="CPU">
<Text color={cpuColor(sys.cpuPercent, sys.cpuCount)}>
{sys.cpuPercent.toFixed(1)}%
</Text>
<Text dimColor>· load {sys.loadAvg.map((n) => n.toFixed(2)).join(" ")}</Text>
</StatRow>
</Box>
<Box height={1} />
<Text bold>System</Text>
<Box marginLeft={1} flexDirection="column">
<StatRow label="Memory">
<Text color={sysMemColor(sys.systemTotalMem - sys.systemFreeMem, sys.systemTotalMem)}>
{formatBytes(sys.systemTotalMem - sys.systemFreeMem)}
</Text>
<Text dimColor>used ·</Text>
<Text>{formatBytes(sys.systemFreeMem)}</Text>
<Text dimColor>free</Text>
</StatRow>
<StatRow label="Total">
<Text>{formatBytes(sys.systemTotalMem)}</Text>
</StatRow>
<StatRow label="Cores">
<Text>{sys.cpuCount}</Text>
</StatRow>
<StatRow label="Platform">
<Text>{sys.platform}</Text>
</StatRow>
<StatRow label="Node">
<Text>{sys.nodeVersion}</Text>
</StatRow>
<StatRow label="PID">
<Text>{sys.pid}</Text>
</StatRow>
</Box>
<Box height={1} />
</>
)}
{!stats ? (
<Text dimColor>Tasks not available.</Text>
) : (
<>
<Text bold>Tasks</Text>
<Box marginLeft={1} flexDirection="column">
{Object.entries(stats.byColumn).map(([col, count]) => {
const name = col.replace(/-/g, " ");
const isActive = (col === "in-progress" || col === "in-review") && count > 0;
return (
<StatRow key={col} label={name}>
<Text color={isActive ? "green" : undefined}>{count}</Text>
</StatRow>
);
})}
</Box>
<Box height={1} />
<Text bold>Agents</Text>
<Box marginLeft={1} flexDirection="column">
<StatRow label="idle">
<Text>{stats.agents.idle}</Text>
</StatRow>
<StatRow label="active">
<Text color="green">{stats.agents.active}</Text>
</StatRow>
<StatRow label="error">
<Text color={stats.agents.error > 0 ? "red" : undefined}>
{stats.agents.error}
</Text>
</StatRow>
</Box>
</>
)}
</Box>
</Panel>
);
}
@@ -570,7 +691,6 @@ function StatusModeGrid({
<Box flexDirection="column" flexGrow={1} overflow="hidden">
<SystemPanel state={state} isFocused={focused === "system"} />
<StatsPanel state={state} isFocused={focused === "stats"} />
<SettingsPanel state={state} isFocused={focused === "settings"} />
</Box>
<Box flexDirection="column" flexGrow={2} overflow="hidden">
<LogsPanel
@@ -578,7 +698,14 @@ function StatusModeGrid({
isFocused={focused === "logs"}
availableRows={logsAvailableRows}
/>
<UtilitiesPanel isFocused={focused === "utilities"} />
<Box flexDirection="row" overflow="hidden">
<Box flexDirection="column" flexGrow={1} overflow="hidden">
<UtilitiesPanel isFocused={focused === "utilities"} />
</Box>
<Box flexDirection="column" flexGrow={1} overflow="hidden">
<SettingsPanel state={state} isFocused={focused === "settings"} />
</Box>
</Box>
</Box>
</Box>

View File

@@ -1,7 +1,10 @@
import os from "node:os";
import v8 from "node:v8";
import { LogRingBuffer } from "./log-ring-buffer.js";
import type { LogEntry } from "./log-ring-buffer.js";
import type {
SystemInfo,
SystemStats,
TaskStats,
SettingsValues,
TUICallbacks,
@@ -30,6 +33,7 @@ export class DashboardTUI {
logBuffer: LogRingBuffer;
systemInfo: SystemInfo | null = null;
taskStats: TaskStats | null = null;
systemStats: SystemStats | null = null;
settings: SettingsValues | null = null;
callbacks: TUICallbacks | null = null;
isRunning = false;
@@ -57,6 +61,10 @@ export class DashboardTUI {
// Uptime ticker to keep footer time live.
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
// System stats sampler — process memory + CPU%.
private systemStatsTimer: ReturnType<typeof setInterval> | null = null;
private lastCpuUsage: NodeJS.CpuUsage | null = null;
private lastCpuSampleAt = 0;
constructor() {
this.logBuffer = new LogRingBuffer();
@@ -76,6 +84,7 @@ export class DashboardTUI {
logEntries: this.logBuffer.getAll(),
systemInfo: this.systemInfo,
taskStats: this.taskStats,
systemStats: this.systemStats,
settings: this.settings,
callbacks: this.callbacks,
showHelp: this.showHelp,
@@ -118,6 +127,49 @@ export class DashboardTUI {
this.notify();
}
setSystemStats(stats: SystemStats): void {
this.systemStats = stats;
this.notify();
}
/** Sample process memory + CPU% in-place. Called from the sampler timer. */
sampleSystemStats(): void {
const mem = process.memoryUsage();
const heapStats = v8.getHeapStatistics();
const now = Date.now();
const cpu = process.cpuUsage();
let cpuPercent = 0;
if (this.lastCpuUsage && this.lastCpuSampleAt > 0) {
const elapsedMicros = (now - this.lastCpuSampleAt) * 1000;
if (elapsedMicros > 0) {
const usedMicros =
(cpu.user - this.lastCpuUsage.user) +
(cpu.system - this.lastCpuUsage.system);
cpuPercent = (usedMicros / elapsedMicros) * 100;
}
}
this.lastCpuUsage = cpu;
this.lastCpuSampleAt = now;
const load = os.loadavg();
this.setSystemStats({
rss: mem.rss,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
heapLimit: heapStats.heap_size_limit,
external: mem.external,
arrayBuffers: mem.arrayBuffers,
cpuPercent,
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
cpuCount: os.cpus().length,
systemTotalMem: os.totalmem(),
systemFreeMem: os.freemem(),
pid: process.pid,
nodeVersion: process.version,
platform: `${process.platform}/${process.arch}`,
});
}
setSettings(settings: SettingsValues): void {
this.settings = settings;
this.notify();
@@ -278,6 +330,14 @@ export class DashboardTUI {
this.uptimeTimer = setInterval(() => {
if (this.isRunning) this.notify();
}, 5000);
// Prime CPU baseline, then sample every 2s.
this.lastCpuUsage = process.cpuUsage();
this.lastCpuSampleAt = Date.now();
this.sampleSystemStats();
this.systemStatsTimer = setInterval(() => {
if (this.isRunning) this.sampleSystemStats();
}, 2000);
}
async stop(): Promise<void> {
@@ -289,6 +349,11 @@ export class DashboardTUI {
this.uptimeTimer = null;
}
if (this.systemStatsTimer) {
clearInterval(this.systemStatsTimer);
this.systemStatsTimer = null;
}
if (this.inkInstance) {
this.inkInstance.unmount();
this.inkInstance = null;

View File

@@ -12,16 +12,18 @@ export const FUSION_LOGO_LINES = [
"╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝",
];
// Colossal font — 62 cols × 8 rows. Used when the terminal has room for it.
// ANSI Shadow (extended) — ~70 cols × 10 rows. Same block-letter aesthetic
// as the small variant, scaled up by extending vertical bodies and widening
// letter cells. Used when the terminal has room for it.
export const FUSION_LOGO_LARGE_LINES = [
"8888888888 888 888 .d8888b. 8888888 .d88888b. 888b 888 ",
"888 888 888 d88P Y88b 888 d88P\" \"Y88b 8888b 888 ",
"888 888 888 Y88b. 888 888 888 88888b 888 ",
"8888888 888 888 \"Y888b. 888 888 888 888Y88b 888 ",
"888 888 888 \"Y88b. 888 888 888 888 Y88b888 ",
"888 888 888 \"888 888 888 888 888 Y88888 ",
"888 Y88b. .d88P Y88b d88P 888 Y88b. .d88P 888 Y8888 ",
"888 \"Y88888P\" \"Y8888P\" 8888888 \"Y88888P\" 888 Y888 ",
"███████╗ ██╗ ██╗ ███████╗ ██╗ ██████╗ ███╗ ██╗",
"██╔════╝ ██║ ██║ ██╔════╝ ██║ ██╔═══██╗ ████╗ ██║",
"██║ ██║ ██║ ██║ ██║ ██║ ██║ ██╔██╗ ██║",
"█████╗ ██║ ██║ ███████╗ ██║ ██║ ██║ ██║╚██╗██║",
"██╔══╝ ██║ ██║ ╚════██║ ██║ ██║ ██║ ██║ ╚████║",
"██║ ██║ ██║ ██║ ██║ ██║ ██║ ██║ ╚███║",
"██║ ╚██████╔═╝ ███████║ ██║ ╚██████╔╝ ██║ ╚██║",
"╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝",
];
export const FUSION_TAGLINE = "AI coding agent dashboard";

View File

@@ -34,6 +34,23 @@ export interface TaskStats {
};
}
export interface SystemStats {
rss: number;
heapUsed: number;
heapTotal: number;
heapLimit: number;
external: number;
arrayBuffers: number;
cpuPercent: number;
loadAvg: [number, number, number];
cpuCount: number;
systemTotalMem: number;
systemFreeMem: number;
pid: number;
nodeVersion: string;
platform: string;
}
export interface SettingsValues {
maxConcurrent: number;
maxWorktrees: number;
@@ -127,6 +144,7 @@ export interface DashboardState {
logEntries: LogEntry[];
systemInfo: SystemInfo | null;
taskStats: TaskStats | null;
systemStats: SystemStats | null;
settings: SettingsValues | null;
callbacks: TUICallbacks | null;
showHelp: boolean;
@@ -149,6 +167,7 @@ export function createInitialState(): DashboardState {
logEntries: [],
systemInfo: null,
taskStats: null,
systemStats: null,
settings: null,
callbacks: null,
showHelp: false,