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

@@ -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") {