feat(FN-2408): merge fusion/fn-2408
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user