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 bdcb048e20
commit a283ef2b79
46 changed files with 5578 additions and 6239 deletions

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);
}