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.
1773
packages/cli/src/commands/dashboard-tui/app.tsx
Normal file
1773
packages/cli/src/commands/dashboard-tui/app.tsx
Normal file
File diff suppressed because it is too large
Load Diff
308
packages/cli/src/commands/dashboard-tui/controller.ts
Normal file
308
packages/cli/src/commands/dashboard-tui/controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
20
packages/cli/src/commands/dashboard-tui/index.ts
Normal file
20
packages/cli/src/commands/dashboard-tui/index.ts
Normal 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";
|
||||
48
packages/cli/src/commands/dashboard-tui/log-ring-buffer.ts
Normal file
48
packages/cli/src/commands/dashboard-tui/log-ring-buffer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
161
packages/cli/src/commands/dashboard-tui/log-sink.ts
Normal file
161
packages/cli/src/commands/dashboard-tui/log-sink.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
packages/cli/src/commands/dashboard-tui/logo.ts
Normal file
14
packages/cli/src/commands/dashboard-tui/logo.ts
Normal 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";
|
||||
164
packages/cli/src/commands/dashboard-tui/state.ts
Normal file
164
packages/cli/src/commands/dashboard-tui/state.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
3
packages/cli/src/commands/dashboard-tui/utils.ts
Normal file
3
packages/cli/src/commands/dashboard-tui/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isTTYAvailable(): boolean {
|
||||
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
||||
}
|
||||
Reference in New Issue
Block a user