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:
307
packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx
Normal file
307
packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user