feat(cli): merge @fusion/tui into fn dashboard with Ink TUI

Replace the legacy ANSI-based DashboardTUI with an Ink/React rewrite
under packages/cli/src/commands/dashboard-tui/, delete the standalone
@fusion/tui package, and make `fn` (no args) launch the dashboard.

The new TUI keeps the existing 5-panel status mode (system, logs,
utilities, stats, settings) but adds an interactive mode (b/a/g) with
three views: a kanban board with project picker and per-task detail,
an agents list+detail with state management, and a settings editor.
Bordered focus-aware panels, solid-background help overlay, static
all-blue FUSION splash that adapts to small terminals. DashboardTUI
and DashboardLogSink public API are unchanged so dashboard.ts only
needed import-path updates plus interactiveData/loadingStatus wiring.

Also adds zod to @fusion/dashboard to satisfy a peer dep introduced
by pi-coding-agent 0.70.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 18:52:03 -07:00
parent bce7dbd96f
commit b890c57a29
46 changed files with 5578 additions and 6239 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,307 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import { DashboardApp } from "../app.js";
import { DashboardTUI } from "../controller.js";
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues } from "../state.js";
function newController(): DashboardTUI {
return new DashboardTUI();
}
function makeSystemInfo() {
return {
host: "localhost",
port: 4040,
baseUrl: "http://localhost:4040",
authEnabled: false,
engineMode: "active" as const,
fileWatcher: true,
startTimeMs: Date.now(),
};
}
function makeInteractiveData(opts: {
projects?: ProjectItem[];
tasks?: TaskItem[];
agents?: AgentItem[];
detail?: AgentDetailItem | null;
settings?: SettingsValues;
models?: ModelItem[];
} = {}) {
const projects = opts.projects ?? [];
const tasks = opts.tasks ?? [];
const agents = opts.agents ?? [];
const detail = opts.detail ?? null;
const settings: SettingsValues = opts.settings ?? {
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
mergeStrategy: "direct",
pollIntervalMs: 60000,
enginePaused: false,
globalPause: false,
};
const models = opts.models ?? [];
return {
listProjects: async () => projects,
listTasks: async () => tasks,
listAgents: async () => agents,
getAgentDetail: async (_id: string) => detail,
updateAgentState: async (_id: string, _state: string) => {},
deleteAgent: async (_id: string) => {},
getSettings: async () => settings,
updateSettings: async (_partial: Partial<SettingsValues>) => {},
listModels: () => models,
};
}
afterEach(() => {
vi.useRealTimers();
});
describe("DashboardApp smoke", () => {
it("renders the splash brand mark and tagline before systemInfo arrives", () => {
const controller = newController();
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
const frame = lastFrame() ?? "";
expect(frame).toContain("╭─────╮");
expect(frame).toContain("AI coding agent dashboard");
unmount();
});
it("reveals the FUSION block letters after the wipe-in animation runs", async () => {
const controller = newController();
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 800));
expect(lastFrame() ?? "").toContain("███████╗");
unmount();
});
it("renders system panel content once setSystemInfo fires", () => {
const controller = newController();
const { lastFrame, unmount, rerender } = render(<DashboardApp controller={controller} />);
controller.setSystemInfo(makeSystemInfo());
rerender(<DashboardApp controller={controller} />);
const frame = lastFrame() ?? "";
expect(frame).toContain("http://localhost:4040");
expect(frame).not.toContain("███████╗");
unmount();
});
it("shows interactive empty-state when no data source is wired", () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setMode("interactive");
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
expect(lastFrame() ?? "").toContain("Interactive mode unavailable");
unmount();
});
it("renders the selected project name in board view header", async () => {
const controller = newController();
const projects: ProjectItem[] = [
{ id: "p1", name: "alpha", path: "/tmp/alpha" },
{ id: "p2", name: "beta", path: "/tmp/beta" },
];
const tasks: TaskItem[] = [
{ id: "t1", title: "first", description: "", column: "todo" },
];
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({ projects, tasks }));
controller.setMode("interactive");
controller.setInteractiveView("board");
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 30));
const frame = lastFrame() ?? "";
// Board shows the currently selected project; first project "alpha" is selected by default
expect(frame).toContain("alpha");
unmount();
});
});
describe("DashboardTUI snapshot stability", () => {
it("returns the same snapshot reference across reads when state has not changed", () => {
const controller = newController();
const a = controller.getSnapshot();
const b = controller.getSnapshot();
expect(a).toBe(b);
});
it("invalidates the cached snapshot when state changes", () => {
const controller = newController();
const a = controller.getSnapshot();
controller.setLoadingStatus("Working…");
const b = controller.getSnapshot();
expect(b).not.toBe(a);
expect(b.loadingStatus).toBe("Working…");
});
it("notifies subscribers on state change", () => {
const controller = newController();
const cb = vi.fn();
const unsub = controller.subscribe(cb);
controller.setLoadingStatus("Tick");
expect(cb).toHaveBeenCalledTimes(1);
unsub();
controller.setLoadingStatus("Tock");
expect(cb).toHaveBeenCalledTimes(1);
});
it("toggles mode and reflects it in the snapshot", () => {
const controller = newController();
expect(controller.getSnapshot().mode).toBe("status");
controller.setMode("interactive");
expect(controller.getSnapshot().mode).toBe("interactive");
controller.setMode("status");
expect(controller.getSnapshot().mode).toBe("status");
});
it("appends log entries and exposes them in the snapshot", () => {
const controller = newController();
controller.log("hello", "scope");
const entries = controller.getSnapshot().logEntries;
expect(entries).toHaveLength(1);
expect(entries[0].message).toBe("hello");
expect(entries[0].prefix).toBe("scope");
});
it("setInteractiveView updates interactiveView in snapshot", () => {
const controller = newController();
expect(controller.getSnapshot().interactiveView).toBe("board");
controller.setInteractiveView("agents");
expect(controller.getSnapshot().interactiveView).toBe("agents");
controller.setInteractiveView("settings");
expect(controller.getSnapshot().interactiveView).toBe("settings");
});
});
describe("Agents view", () => {
it("renders agents list when setInteractiveView('agents') is set", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const agents: AgentItem[] = [
{ id: "a1", name: "worker-1", state: "active", role: "executor" },
{ id: "a2", name: "worker-2", state: "idle", role: "executor" },
];
controller.setInteractiveData(makeInteractiveData({ agents }));
controller.setMode("interactive");
controller.setInteractiveView("agents");
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 30));
const frame = lastFrame() ?? "";
expect(frame).toContain("worker-1");
expect(frame).toContain("worker-2");
expect(frame).toContain("Agents");
unmount();
});
it("shows Agent Detail panel label", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData());
controller.setMode("interactive");
controller.setInteractiveView("agents");
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 30));
expect(lastFrame() ?? "").toContain("Agent Detail");
unmount();
});
});
describe("Settings view", () => {
it("renders settings list when setInteractiveView('settings') is set", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const settings: SettingsValues = {
maxConcurrent: 3,
maxWorktrees: 4,
autoMerge: true,
mergeStrategy: "direct",
pollIntervalMs: 60000,
enginePaused: false,
globalPause: false,
};
controller.setInteractiveData(makeInteractiveData({ settings }));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 30));
const frame = lastFrame() ?? "";
expect(frame).toContain("Settings");
expect(frame).toContain("Max Concurrent");
expect(frame).toContain("Auto Merge");
unmount();
});
it("renders models subsection when models are provided", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const models: ModelItem[] = [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", provider: "anthropic", contextWindow: 200000 },
];
controller.setInteractiveData(makeInteractiveData({ models }));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 30));
const frame = lastFrame() ?? "";
expect(frame).toContain("Available Models");
expect(frame).toContain("Claude 3.5 Sonnet");
unmount();
});
});
describe("Board view", () => {
it("renders kanban columns in board view", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const tasks: TaskItem[] = [
{ id: "t1", title: "Task One", description: "", column: "todo" },
{ id: "t2", title: "Task Two", description: "", column: "in-progress" },
];
controller.setInteractiveData(makeInteractiveData({
projects: [{ id: "p1", name: "my-project", path: "/tmp/p" }],
tasks,
}));
controller.setMode("interactive");
controller.setInteractiveView("board");
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 30));
const frame = lastFrame() ?? "";
expect(frame).toContain("TODO");
expect(frame).toContain("IN PROGRESS");
unmount();
});
});
describe("LogsPanel indicator", () => {
it("renders the selection arrow on the highlighted log row", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setActiveSection("logs");
controller.log("first message", "test");
controller.log("second message", "test");
controller.log("third message", "test");
// Select index 1 (middle entry)
controller.setSelectedLogIndex(1);
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 10));
const frame = lastFrame() ?? "";
expect(frame).toContain("▶");
unmount();
});
it("shows no selection arrow on non-focused log entries", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setActiveSection("logs");
controller.log("only message", "test");
controller.setSelectedLogIndex(0);
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
await new Promise((r) => setTimeout(r, 10));
const frame = lastFrame() ?? "";
// The selected entry shows the arrow; it should appear at least once
expect(frame).toContain("▶");
unmount();
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from "vitest";
import { LogRingBuffer } from "../log-ring-buffer.js";
describe("LogRingBuffer", () => {
let buffer: LogRingBuffer;
beforeEach(() => {
buffer = new LogRingBuffer();
});
it("stores entries and reports total count", () => {
buffer.push({ timestamp: new Date(), level: "info", message: "test1" });
buffer.push({ timestamp: new Date(), level: "warn", message: "test2" });
expect(buffer.total).toBe(2);
});
it("returns all entries in chronological order", () => {
buffer.push({ timestamp: new Date("2026-01-01T10:00:00"), level: "info", message: "first" });
buffer.push({ timestamp: new Date("2026-01-01T11:00:00"), level: "info", message: "second" });
const entries = buffer.getAll();
expect(entries.length).toBe(2);
expect(entries[0].message).toBe("first");
expect(entries[1].message).toBe("second");
});
it("caps at MAX_LOG_ENTRIES (1000)", () => {
for (let i = 0; i < 1500; i++) {
buffer.push({ timestamp: new Date(), level: "info", message: `entry-${i}` });
}
const entries = buffer.getAll();
expect(entries.length).toBe(1000);
expect(buffer.total).toBe(1500);
});
it("maintains chronological order when overwriting", () => {
for (let i = 0; i < 1500; i++) {
buffer.push({ timestamp: new Date(2026, 0, 1, 0, i), level: "info", message: `entry-${i}` });
}
const entries = buffer.getAll();
expect(entries.length).toBe(1000);
expect(entries[0].message).toBe("entry-500");
expect(entries[entries.length - 1].message).toBe("entry-1499");
});
it("clears all entries", () => {
buffer.push({ timestamp: new Date(), level: "info", message: "test" });
buffer.clear();
expect(buffer.getAll().length).toBe(0);
expect(buffer.total).toBe(0);
});
it("stores entries with different levels", () => {
buffer.push({ timestamp: new Date(), level: "info", message: "info msg" });
buffer.push({ timestamp: new Date(), level: "warn", message: "warn msg" });
buffer.push({ timestamp: new Date(), level: "error", message: "error msg" });
const entries = buffer.getAll();
expect(entries[0].level).toBe("info");
expect(entries[1].level).toBe("warn");
expect(entries[2].level).toBe("error");
});
it("stores entries with prefix", () => {
buffer.push({ timestamp: new Date(), level: "info", message: "msg", prefix: "engine" });
const entries = buffer.getAll();
expect(entries[0].prefix).toBe("engine");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
import { LogRingBuffer } from "./log-ring-buffer.js";
import type { LogEntry } from "./log-ring-buffer.js";
import type {
SystemInfo,
TaskStats,
SettingsValues,
TUICallbacks,
SectionId,
DashboardState,
InteractiveData,
InteractiveView,
} from "./state.js";
import { SECTION_ORDER } from "./state.js";
// ── DashboardTUI ─────────────────────────────────────────────────────────────
//
// Public API is identical to the old imperative class so dashboard.ts requires
// no changes other than the import path. State fields are kept as direct class
// properties (matching the old names) so the test suite can reach them via
// `(tui as any).activeSection` etc. without modification.
//
// The Ink App component subscribes via `subscribe()` / `getSnapshot()` — the
// same pattern as `useSyncExternalStore`.
export class DashboardTUI {
// State fields mirror the original private layout so tests can access them.
activeSection: SectionId = "system";
// Named `logBuffer` to match what captureConsole tests access via
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
logBuffer: LogRingBuffer;
systemInfo: SystemInfo | null = null;
taskStats: TaskStats | null = null;
settings: SettingsValues | null = null;
callbacks: TUICallbacks | null = null;
isRunning = false;
showHelp = false;
logsSeverityFilter: "all" | LogEntry["level"] = "all";
logsWrapEnabled = false;
logsExpandedMode = false;
selectedLogIndex = 0;
logsViewportStart = 0;
loadingStatus = "Starting…";
mode: "status" | "interactive" = "status";
interactiveData: InteractiveData | null = null;
interactiveView: InteractiveView = "board";
// Subscribers registered by the Ink App component.
private subscribers: Set<() => void> = new Set();
// Cached snapshot — useSyncExternalStore compares by Object.is, so we must
// return the same reference between renders unless state actually changed.
// notify() invalidates this; getSnapshot() rebuilds on demand.
private cachedSnapshot: DashboardState | null = null;
// Ink instance — set when start() is called.
private inkInstance: { unmount: () => void; waitUntilExit: () => Promise<unknown> } | null = null;
// Uptime ticker to keep footer time live.
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.logBuffer = new LogRingBuffer();
}
// ── Subscription API (for Ink App) ────────────────────────────────────────
subscribe(callback: () => void): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
getSnapshot(): DashboardState {
if (this.cachedSnapshot) return this.cachedSnapshot;
this.cachedSnapshot = {
activeSection: this.activeSection,
logEntries: this.logBuffer.getAll(),
systemInfo: this.systemInfo,
taskStats: this.taskStats,
settings: this.settings,
callbacks: this.callbacks,
showHelp: this.showHelp,
logsSeverityFilter: this.logsSeverityFilter,
logsWrapEnabled: this.logsWrapEnabled,
logsExpandedMode: this.logsExpandedMode,
selectedLogIndex: this.selectedLogIndex,
logsViewportStart: this.logsViewportStart,
loadingStatus: this.loadingStatus,
mode: this.mode,
interactiveData: this.interactiveData,
interactiveView: this.interactiveView,
};
return this.cachedSnapshot;
}
private notify(): void {
this.cachedSnapshot = null;
for (const cb of this.subscribers) cb();
}
// ── Public API (unchanged from original DashboardTUI) ─────────────────────
get running(): boolean {
return this.isRunning;
}
setCallbacks(callbacks: TUICallbacks): void {
this.callbacks = callbacks;
this.notify();
}
setSystemInfo(info: SystemInfo): void {
this.systemInfo = info;
this.notify();
}
setTaskStats(stats: TaskStats): void {
this.taskStats = stats;
this.notify();
}
setSettings(settings: SettingsValues): void {
this.settings = settings;
this.notify();
}
setLoadingStatus(text: string): void {
this.loadingStatus = text;
this.notify();
}
setInteractiveData(data: InteractiveData): void {
this.interactiveData = data;
this.notify();
}
setInteractiveView(view: InteractiveView): void {
this.interactiveView = view;
this.notify();
}
addLog(entry: Omit<LogEntry, "timestamp">): void {
this.logBuffer.push({ ...entry, timestamp: new Date() });
this.clampSelectedLogIndex(this.getFilteredLogEntries());
this.notify();
}
clearLogs(): void {
this.logBuffer.clear();
this.selectedLogIndex = 0;
this.logsViewportStart = 0;
this.logsExpandedMode = false;
this.notify();
}
log(message: string, prefix?: string): void {
this.addLog({ level: "info", message, prefix });
}
warn(message: string, prefix?: string): void {
this.addLog({ level: "warn", message, prefix });
}
error(message: string, prefix?: string): void {
this.addLog({ level: "error", message, prefix });
}
// ── State helpers called from Ink App ────────────────────────────────────
setActiveSection(section: SectionId): void {
this.activeSection = section;
this.showHelp = false;
this.notify();
}
setShowHelp(show: boolean): void {
this.showHelp = show;
this.notify();
}
setLogsWrapEnabled(enabled: boolean): void {
this.logsWrapEnabled = enabled;
this.notify();
}
setLogsExpandedMode(expanded: boolean): void {
this.logsExpandedMode = expanded;
this.notify();
}
setSelectedLogIndex(index: number): void {
const entries = this.getFilteredLogEntries();
this.selectedLogIndex = this.clampIndex(index, entries.length);
this.notify();
}
setLogsViewportStart(start: number): void {
this.logsViewportStart = start;
this.notify();
}
setMode(mode: "status" | "interactive"): void {
this.mode = mode;
this.notify();
}
cycleSection(direction: 1 | -1): void {
const idx = SECTION_ORDER.indexOf(this.activeSection);
this.activeSection = SECTION_ORDER[(idx + direction + SECTION_ORDER.length) % SECTION_ORDER.length];
this.showHelp = false;
this.notify();
}
cycleSeverityFilter(): void {
const order: Array<"all" | LogEntry["level"]> = ["all", "info", "warn", "error"];
const idx = order.indexOf(this.logsSeverityFilter);
this.logsSeverityFilter = order[(idx + 1) % order.length];
this.clampSelectedLogIndex(this.getFilteredLogEntries());
this.logsViewportStart = 0;
this.notify();
}
getFilteredLogEntries(): LogEntry[] {
const all = this.logBuffer.getAll();
return this.logsSeverityFilter === "all"
? all
: all.filter((e) => e.level === this.logsSeverityFilter);
}
async handleUtilityAction(key: string): Promise<void> {
if (!this.callbacks) return;
switch (key.toLowerCase()) {
case "r":
await this.callbacks.onRefreshStats();
break;
case "c":
this.callbacks.onClearLogs();
this.clearLogs();
break;
case "t":
if (this.systemInfo) {
const newPaused = this.systemInfo.engineMode !== "paused";
const newSettings = await this.callbacks.onTogglePause(newPaused);
const newEngineMode = newSettings.enginePaused ? "paused" : "active";
this.setSystemInfo({ ...this.systemInfo, engineMode: newEngineMode });
this.setSettings(newSettings);
}
break;
}
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
async start(): Promise<void> {
if (this.isRunning) return;
this.isRunning = true;
// Dynamic import avoids pulling Ink into non-TTY paths (CI, tests
// that only exercise pure logic).
const { render } = await import("ink");
const { createElement } = await import("react");
const { DashboardApp } = await import("./app.js");
this.inkInstance = render(
createElement(DashboardApp, { controller: this }),
);
this.uptimeTimer = setInterval(() => {
if (this.isRunning) this.notify();
}, 5000);
}
async stop(): Promise<void> {
if (!this.isRunning) return;
this.isRunning = false;
if (this.uptimeTimer) {
clearInterval(this.uptimeTimer);
this.uptimeTimer = null;
}
if (this.inkInstance) {
this.inkInstance.unmount();
this.inkInstance = null;
}
}
// ── Private helpers ────────────────────────────────────────────────────────
private clampSelectedLogIndex(entries: LogEntry[]): void {
if (entries.length === 0) {
this.selectedLogIndex = 0;
this.logsExpandedMode = false;
return;
}
if (this.selectedLogIndex >= entries.length) {
this.selectedLogIndex = entries.length - 1;
}
if (this.selectedLogIndex < 0) {
this.selectedLogIndex = 0;
}
}
private clampIndex(index: number, length: number): number {
if (length === 0) return 0;
return Math.max(0, Math.min(index, length - 1));
}
}

View File

@@ -0,0 +1,54 @@
import { useState, useEffect } from "react";
import type { ProjectItem, TaskItem, InteractiveData } from "../state.js";
export interface ProjectsState {
projects: ProjectItem[];
loading: boolean;
error: string | null;
}
export interface TasksState {
tasks: TaskItem[];
loading: boolean;
error: string | null;
}
export function useProjects(interactiveData: InteractiveData | null): ProjectsState {
const [state, setState] = useState<ProjectsState>({ projects: [], loading: false, error: null });
useEffect(() => {
if (!interactiveData) return;
setState({ projects: [], loading: true, error: null });
interactiveData.listProjects().then((projects) => {
setState({ projects, loading: false, error: null });
}).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
setState({ projects: [], loading: false, error: message });
});
}, [interactiveData]);
return state;
}
export function useTasks(
interactiveData: InteractiveData | null,
selectedProject: ProjectItem | null,
): TasksState {
const [state, setState] = useState<TasksState>({ tasks: [], loading: false, error: null });
useEffect(() => {
if (!interactiveData || !selectedProject) {
setState({ tasks: [], loading: false, error: null });
return;
}
setState({ tasks: [], loading: true, error: null });
interactiveData.listTasks(selectedProject.path).then((tasks) => {
setState({ tasks, loading: false, error: null });
}).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
setState({ tasks: [], loading: false, error: message });
});
}, [interactiveData, selectedProject]);
return state;
}

View File

@@ -0,0 +1,20 @@
// Re-exports that preserve the public API surface of the old dashboard-tui.ts
// so that dashboard.ts (and any tests importing from dashboard-tui.js) need
// only update their import path.
export { DashboardTUI } from "./controller.js";
export { DashboardLogSink, formatConsoleArgs } from "./log-sink.js";
export { LogRingBuffer } from "./log-ring-buffer.js";
export { isTTYAvailable } from "./utils.js";
export type {
LogEntry,
SectionId,
SystemInfo,
TaskStats,
SettingsValues,
UtilityAction,
TUICallbacks,
InteractiveData,
ProjectItem,
TaskItem,
} from "./state.js";

View File

@@ -0,0 +1,48 @@
// ── Types ────────────────────────────────────────────────────────────────────
export interface LogEntry {
timestamp: Date;
level: "info" | "warn" | "error";
message: string;
prefix?: string;
}
// ── Ring Buffer ───────────────────────────────────────────────────────────────
const MAX_LOG_ENTRIES = 1000;
export class LogRingBuffer {
private entries: LogEntry[] = [];
private count = 0;
push(entry: LogEntry): void {
if (this.entries.length < MAX_LOG_ENTRIES) {
this.entries.push(entry);
} else {
// Overwrite oldest entry in circular fashion
this.entries[this.count % MAX_LOG_ENTRIES] = entry;
}
this.count++;
}
getAll(): LogEntry[] {
if (this.count <= MAX_LOG_ENTRIES) {
return this.entries.slice();
}
// Return entries in chronological order (oldest to newest)
const start = this.count % MAX_LOG_ENTRIES;
return [
...this.entries.slice(start),
...this.entries.slice(0, start),
];
}
clear(): void {
this.entries = [];
this.count = 0;
}
get total(): number {
return this.count;
}
}

View File

@@ -0,0 +1,161 @@
import type { LogEntry } from "./log-ring-buffer.js";
// ── formatConsoleArgs ─────────────────────────────────────────────────────────
// The engine's createLogger() prefixes messages with a null-byte-delimited
// severity marker so we can recover the original intent when routed via
// console.error (which is the transport it uses).
const LOG_LEVEL_MARKER_REGEX = /^\u0000fnlvl=(info|warn|error)\u0000\s*/;
/**
* Format heterogeneous console args into a single string, extracting a
* leading internal severity marker and `[prefix]` tag when present.
* Mirrors `util.format` loosely — objects are JSON-stringified (defensively,
* falling back to String()), everything else is coerced via String().
*/
export function formatConsoleArgs(
args: unknown[],
fallbackLevel: LogEntry["level"] = "info",
): { message: string; prefix?: string; level: LogEntry["level"] } {
const stringified = args.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return arg.stack ?? arg.message;
if (arg === null || arg === undefined) return String(arg);
if (typeof arg === "object") {
try { return JSON.stringify(arg); } catch { return String(arg); }
}
return String(arg);
}).join(" ");
const markerMatch = stringified.match(LOG_LEVEL_MARKER_REGEX);
const level = markerMatch?.[1] as LogEntry["level"] | undefined;
const withoutMarker = markerMatch ? stringified.replace(LOG_LEVEL_MARKER_REGEX, "") : stringified;
const match = withoutMarker.match(/^\[([^\]]+)\]\s*(.*)$/s);
if (match) {
return { prefix: match[1], message: match[2], level: level ?? fallbackLevel };
}
return { message: withoutMarker, level: level ?? fallbackLevel };
}
// ── DashboardLogSink ──────────────────────────────────────────────────────────
/**
* Interface that DashboardTUI exposes to the sink.
* Using an interface rather than importing the class directly
* prevents a circular dependency between sink and controller.
*/
export interface LogSinkTarget {
log(message: string, prefix?: string): void;
warn(message: string, prefix?: string): void;
error(message: string, prefix?: string): void;
readonly running: boolean;
}
/**
* A log sink that routes messages to the TUI in TTY mode,
* or to console in non-TTY mode.
*
* `captureConsole()` monkey-patches `console.log/warn/error` so everything
* (including the engine's createLogger() output, which writes directly to
* console.error) surfaces in the TUI's log ring buffer. Without capture,
* most runtime logs render beneath the alt-screen TUI and are immediately
* overwritten on the next render, leaving the Logs tab nearly empty.
*
* Messages that start with `[prefix] rest` are unpacked so the TUI stores
* `prefix="prefix"` and `message="rest"`. Idempotent; call `releaseConsole()`
* on TUI shutdown to restore the originals.
*/
export class DashboardLogSink {
private tui: LogSinkTarget | null = null;
private isTTY: boolean;
private originalConsole: {
log: typeof console.log;
warn: typeof console.warn;
error: typeof console.error;
} | null = null;
constructor(tui?: LogSinkTarget) {
this.tui = tui ?? null;
this.isTTY = tui?.running ?? false;
}
setTUI(tui: LogSinkTarget): void {
this.tui = tui;
this.isTTY = true;
}
log(message: string, prefix?: string): void {
const line = prefix ? `[${prefix}] ${message}` : message;
if (this.tui && this.isTTY) {
this.tui.log(message, prefix);
} else if (this.originalConsole) {
this.originalConsole.log.call(console, line);
} else {
console.log(line);
}
}
warn(message: string, prefix?: string): void {
const line = prefix ? `[${prefix}] ${message}` : message;
if (this.tui && this.isTTY) {
this.tui.warn(message, prefix);
} else if (this.originalConsole) {
this.originalConsole.warn.call(console, line);
} else {
console.warn(line);
}
}
error(message: string, prefix?: string): void {
const line = prefix ? `[${prefix}] ${message}` : message;
if (this.tui && this.isTTY) {
this.tui.error(message, prefix);
} else if (this.originalConsole) {
this.originalConsole.error.call(console, line);
} else {
console.error(line);
}
}
captureConsole(): void {
if (this.originalConsole) return;
this.originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
};
console.log = (...args: unknown[]) => {
const { message, prefix, level } = formatConsoleArgs(args, "info");
this.writeCapturedConsoleLog(level, message, prefix);
};
console.warn = (...args: unknown[]) => {
const { message, prefix, level } = formatConsoleArgs(args, "warn");
this.writeCapturedConsoleLog(level, message, prefix);
};
console.error = (...args: unknown[]) => {
const { message, prefix, level } = formatConsoleArgs(args, "error");
this.writeCapturedConsoleLog(level, message, prefix);
};
}
releaseConsole(): void {
if (!this.originalConsole) return;
console.log = this.originalConsole.log;
console.warn = this.originalConsole.warn;
console.error = this.originalConsole.error;
this.originalConsole = null;
}
private writeCapturedConsoleLog(level: LogEntry["level"], message: string, prefix?: string): void {
if (level === "error") {
this.error(message, prefix);
return;
}
if (level === "warn") {
this.warn(message, prefix);
return;
}
this.log(message, prefix);
}
}

View File

@@ -0,0 +1,14 @@
// FUSION block-letter logo using Unicode box-drawing + full blocks.
// Font: ANSI Shadow (figlet-style). Hardcoded so we have no runtime dep.
// The caller applies a cyan→whiteBright vertical gradient.
export const FUSION_LOGO_LINES = [
"███████╗██╗ ██╗███████╗██╗ ██████╗ ███╗ ██╗",
"██╔════╝██║ ██║██╔════╝██║██╔═══██╗████╗ ██║",
"█████╗ ██║ ██║███████╗██║██║ ██║██╔██╗ ██║",
"██╔══╝ ██║ ██║╚════██║██║██║ ██║██║╚██╗██║",
"██║ ╚██████╔╝███████║██║╚██████╔╝██║ ╚████║",
"╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝",
];
export const FUSION_TAGLINE = "AI coding agent dashboard";

View File

@@ -0,0 +1,164 @@
import type { LogEntry } from "./log-ring-buffer.js";
// ── Public types shared across the whole dashboard-tui module ─────────────────
export type { LogEntry };
export type SectionId = "logs" | "system" | "utilities" | "stats" | "settings";
export type AppMode = "status" | "interactive";
export type InteractiveView = "board" | "agents" | "settings";
export interface SystemInfo {
host: string;
port: number;
baseUrl: string;
authEnabled: boolean;
authToken?: string;
tokenizedUrl?: string;
engineMode: "dev" | "active" | "paused";
fileWatcher: boolean;
startTimeMs: number;
}
export interface TaskStats {
total: number;
byColumn: Record<string, number>;
active: number;
agents: {
idle: number;
active: number;
running: number;
error: number;
};
}
export interface SettingsValues {
maxConcurrent: number;
maxWorktrees: number;
autoMerge: boolean;
mergeStrategy: string;
pollIntervalMs: number;
enginePaused: boolean;
globalPause: boolean;
}
export interface UtilityAction {
id: string;
label: string;
key: string;
description: string;
}
export interface TUICallbacks {
onRefreshStats: () => Promise<void>;
onClearLogs: () => void;
onTogglePause: (paused: boolean) => Promise<SettingsValues>;
}
// Slim project shape used by interactive mode
export interface ProjectItem {
id: string;
name: string;
path: string;
}
// Slim task shape used by interactive mode
export interface TaskItem {
id: string;
title?: string;
description: string;
column: string;
agentState?: string;
}
// Slim agent shape for Agents view list
export interface AgentItem {
id: string;
name: string;
state: string;
role: string;
taskId?: string;
lastHeartbeatAt?: string;
}
// Slim heartbeat run for agent detail
export interface AgentRunItem {
id: string;
startedAt: string;
endedAt: string | null;
status: string;
triggerDetail?: string;
}
// Slim agent detail shape for Agents view detail panel
export interface AgentDetailItem extends AgentItem {
title?: string;
capabilities: string[];
recentRuns: AgentRunItem[];
}
// Slim model shape for Settings view models subsection
export interface ModelItem {
id: string;
name: string;
provider: string;
contextWindow: number;
}
export interface InteractiveData {
listProjects: () => Promise<ProjectItem[]>;
listTasks: (projectPath: string) => Promise<TaskItem[]>;
listAgents: () => Promise<AgentItem[]>;
getAgentDetail: (id: string) => Promise<AgentDetailItem | null>;
updateAgentState: (id: string, state: string) => Promise<void>;
deleteAgent: (id: string) => Promise<void>;
getSettings: () => Promise<SettingsValues>;
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
listModels: () => ModelItem[];
}
// ── Dashboard state (mutable, shared between controller and App) ───────────────
export interface DashboardState {
activeSection: SectionId;
logEntries: LogEntry[];
systemInfo: SystemInfo | null;
taskStats: TaskStats | null;
settings: SettingsValues | null;
callbacks: TUICallbacks | null;
showHelp: boolean;
logsSeverityFilter: "all" | LogEntry["level"];
logsWrapEnabled: boolean;
logsExpandedMode: boolean;
selectedLogIndex: number;
logsViewportStart: number;
loadingStatus: string;
mode: AppMode;
interactiveData: InteractiveData | null;
interactiveView: InteractiveView;
}
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
export function createInitialState(): DashboardState {
return {
activeSection: "system",
logEntries: [],
systemInfo: null,
taskStats: null,
settings: null,
callbacks: null,
showHelp: false,
logsSeverityFilter: "all",
logsWrapEnabled: false,
logsExpandedMode: false,
selectedLogIndex: 0,
logsViewportStart: 0,
loadingStatus: "Starting…",
mode: "status",
interactiveData: null,
interactiveView: "board",
};
}

View File

@@ -0,0 +1,3 @@
export function isTTYAvailable(): boolean {
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
}

View File

@@ -42,7 +42,7 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui/index.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -472,6 +472,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
});
// Start the TUI
await tui.start();
tui.setLoadingStatus("Initializing task store…");
// Wire the TUI into the log sink so all console output routes through TUI
logSink.setTUI(tui);
@@ -652,8 +653,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// and are properly managed throughout their lifecycle (creation, state
// transitions, termination). Passed to TaskExecutor for agent spawning.
//
if (tui) tui.setLoadingStatus("Initializing agent store…");
agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
if (tui) tui.setLoadingStatus("Starting engine…");
// ── Reactive TUI Updates ─────────────────────────────────────────────
//
@@ -1516,6 +1519,107 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
agents: agentStats,
});
// Wire interactive-mode data source. CentralCore is shared across
// dev/non-dev branches via centralCoreForMesh. Per-project TaskStores
// are cached so repeated panel switches don't re-init SQLite.
if (centralCoreForMesh) {
const centralCore = centralCoreForMesh;
const projectStores = new Map<string, TaskStore>();
tui.setInteractiveData({
listProjects: async () => {
const projects = await centralCore.listProjects();
return projects.map((p) => ({ id: p.id, name: p.name, path: p.path }));
},
listTasks: async (projectPath: string) => {
let projectStore = projectStores.get(projectPath);
if (!projectStore) {
projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
if (projectPath !== cwd) await projectStore.init();
projectStores.set(projectPath, projectStore);
}
const tasks = await projectStore.listTasks({ slim: true, includeArchived: false });
return tasks.map((t) => ({
id: t.id,
title: t.title,
description: t.description ?? "",
column: t.column,
agentState: (t as { agentState?: string }).agentState,
}));
},
listAgents: async () => {
const list = await agentStore!.listAgents();
return list.map((a) => ({
id: a.id,
name: a.name,
state: a.state,
role: a.role,
taskId: a.taskId,
lastHeartbeatAt: a.lastHeartbeatAt,
}));
},
getAgentDetail: async (id: string) => {
const d = await agentStore!.getAgentDetail(id, 10);
if (!d) return null;
return {
id: d.id,
name: d.name,
state: d.state,
role: d.role,
taskId: d.taskId,
lastHeartbeatAt: d.lastHeartbeatAt,
title: d.title,
capabilities: [d.role],
recentRuns: d.completedRuns.slice(0, 10).map((r) => ({
id: r.id,
startedAt: r.startedAt,
endedAt: r.endedAt,
status: r.status,
triggerDetail: r.triggerDetail,
})),
};
},
updateAgentState: async (id: string, state: string) => {
await agentStore!.updateAgentState(id, state as Parameters<typeof agentStore.updateAgentState>[1]);
},
deleteAgent: async (id: string) => {
await agentStore!.deleteAgent(id);
},
getSettings: async () => {
const s = await store.getSettings();
return {
maxConcurrent: s.maxConcurrent ?? 1,
maxWorktrees: s.maxWorktrees ?? 2,
autoMerge: s.autoMerge ?? false,
mergeStrategy: s.mergeStrategy ?? "direct",
pollIntervalMs: s.pollIntervalMs ?? 60_000,
enginePaused: s.enginePaused ?? false,
globalPause: s.globalPause ?? false,
};
},
updateSettings: async (partial) => {
// Map SettingsValues subset to the store's Settings type (avoid string->MergeStrategy mismatch).
const mapped: Record<string, unknown> = {};
if (partial.maxConcurrent !== undefined) mapped.maxConcurrent = partial.maxConcurrent;
if (partial.maxWorktrees !== undefined) mapped.maxWorktrees = partial.maxWorktrees;
if (partial.autoMerge !== undefined) mapped.autoMerge = partial.autoMerge;
if (partial.mergeStrategy !== undefined) mapped.mergeStrategy = partial.mergeStrategy;
if (partial.pollIntervalMs !== undefined) mapped.pollIntervalMs = partial.pollIntervalMs;
if (partial.enginePaused !== undefined) mapped.enginePaused = partial.enginePaused;
if (partial.globalPause !== undefined) mapped.globalPause = partial.globalPause;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await store.updateSettings(mapped as any);
},
listModels: () => {
return modelRegistry.getAll().map((m) => ({
id: m.id,
name: m.name,
provider: (m as { provider?: string }).provider ?? "unknown",
contextWindow: m.contextWindow ?? 0,
}));
},
});
}
// Log startup messages to TUI
tui.log(`Dashboard started at ${baseUrl}`);
if (engineMode === "active") {