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

@@ -432,7 +432,6 @@ fn skills install firebase/agent-skills # Install agent skills
| `@fusion/core` | Domain model — tasks, board columns, SQLite store | | `@fusion/core` | Domain model — tasks, board columns, SQLite store |
| `@fusion/dashboard` | Web UI — Express server + kanban board with SSE | | `@fusion/dashboard` | Web UI — Express server + kanban board with SSE |
| `@fusion/engine` | AI engine — triage, execution, scheduling, workflow steps | | `@fusion/engine` | AI engine — triage, execution, scheduling, workflow steps |
| `@fusion/tui` | Terminal UI — Ink-based CLI components |
| `@runfusion/fusion` | CLI + pi extension — published to npm | | `@runfusion/fusion` | CLI + pi extension — published to npm |
--- ---

View File

@@ -103,7 +103,6 @@ The following packages are **internal** and are **not published to npm**:
- `@fusion/core` — Core domain model and task store - `@fusion/core` — Core domain model and task store
- `@fusion/dashboard` — Web UI and API server - `@fusion/dashboard` — Web UI and API server
- `@fusion/engine` — AI agents and orchestration - `@fusion/engine` — AI agents and orchestration
- `@fusion/tui` — Terminal UI components
- `@fusion/plugin-sdk` — Plugin development SDK - `@fusion/plugin-sdk` — Plugin development SDK
- `@fusion-plugin-examples/*` — Example plugins - `@fusion-plugin-examples/*` — Example plugins

View File

@@ -16,7 +16,7 @@ At a high level, Fusion is split into:
- **Dashboard API + SPA** (`@fusion/dashboard`) - **Dashboard API + SPA** (`@fusion/dashboard`)
- **CLI + Pi extension** (`@runfusion/fusion`) - **CLI + Pi extension** (`@runfusion/fusion`)
- **Desktop shell** (`@fusion/desktop`) - **Desktop shell** (`@fusion/desktop`)
- **TUI** (`@fusion/tui`) - **Terminal dashboard** (part of `@runfusion/fusion` — see `packages/cli/src/commands/dashboard-tui/`)
### High-level runtime diagram ### High-level runtime diagram
@@ -64,7 +64,6 @@ At a high level, Fusion is split into:
| `@fusion/dashboard` | Private | Express API server + React app | `packages/dashboard/src/server.ts`, `routes.ts`, `sse.ts`, `websocket.ts`, `packages/dashboard/app/App.tsx` | | `@fusion/dashboard` | Private | Express API server + React app | `packages/dashboard/src/server.ts`, `routes.ts`, `sse.ts`, `websocket.ts`, `packages/dashboard/app/App.tsx` |
| `@runfusion/fusion` | **Published** | CLI binary (`fn`) + Pi extension | `packages/cli/src/bin.ts`, `commands/*`, `project-resolver.ts`, `extension.ts` | | `@runfusion/fusion` | **Published** | CLI binary (`fn`) + Pi extension | `packages/cli/src/bin.ts`, `commands/*`, `project-resolver.ts`, `extension.ts` |
| `@fusion/desktop` | Private | Electron shell around Fusion dashboard/client | `packages/desktop/src/main.ts`, `ipc.ts`, `preload.ts`, `scripts/build.ts` | | `@fusion/desktop` | Private | Electron shell around Fusion dashboard/client | `packages/desktop/src/main.ts`, `ipc.ts`, `preload.ts`, `scripts/build.ts` |
| `@fusion/tui` | Private | Ink-based terminal package with ScreenRouter and tab navigation | `packages/tui/src/index.tsx`, `packages/tui/src/components/screen-router.tsx` |
| `@fusion/mobile` | Private | Capacitor + PWA mobile packaging of dashboard assets | `packages/mobile/capacitor.config.ts`, `packages/mobile/src/*` | | `@fusion/mobile` | Private | Capacitor + PWA mobile packaging of dashboard assets | `packages/mobile/capacitor.config.ts`, `packages/mobile/src/*` |
| `@fusion/plugin-sdk` | Private | Plugin SDK for building Fusion extensions | `packages/plugin-sdk/src/*` | | `@fusion/plugin-sdk` | Private | Plugin SDK for building Fusion extensions | `packages/plugin-sdk/src/*` |
@@ -83,7 +82,6 @@ At a high level, Fusion is split into:
@runfusion/fusion (CLI) ─────────▶ @fusion/core @runfusion/fusion (CLI) ─────────▶ @fusion/core
@runfusion/fusion (CLI) ─────────▶ @fusion/engine @runfusion/fusion (CLI) ─────────▶ @fusion/engine
@runfusion/fusion (CLI) ─────────▶ @fusion/dashboard @runfusion/fusion (CLI) ─────────▶ @fusion/dashboard
@fusion/tui ──────────────────▶ @fusion/core
@fusion/plugin-sdk (peerDep) ─▶ @fusion/core @fusion/plugin-sdk (peerDep) ─▶ @fusion/core
@fusion/desktop: no workspace package dependencies @fusion/desktop: no workspace package dependencies
@@ -94,7 +92,6 @@ Concrete references:
- `@fusion/engine` has a workspace dependency on `@fusion/core` (`packages/engine/package.json`) - `@fusion/engine` has a workspace dependency on `@fusion/core` (`packages/engine/package.json`)
- `@fusion/dashboard` has workspace dependencies on `@fusion/core` and `@fusion/engine` (`packages/dashboard/package.json`) - `@fusion/dashboard` has workspace dependencies on `@fusion/core` and `@fusion/engine` (`packages/dashboard/package.json`)
- `@runfusion/fusion` has workspace development dependencies on `@fusion/core`, `@fusion/engine`, and `@fusion/dashboard` for composition/build packaging (`packages/cli/package.json`) - `@runfusion/fusion` has workspace development dependencies on `@fusion/core`, `@fusion/engine`, and `@fusion/dashboard` for composition/build packaging (`packages/cli/package.json`)
- `@fusion/tui` depends on `@fusion/core` (`packages/tui/package.json`)
- `@fusion/plugin-sdk` declares a peer dependency on `@fusion/core` (`packages/plugin-sdk/package.json`) - `@fusion/plugin-sdk` declares a peer dependency on `@fusion/core` (`packages/plugin-sdk/package.json`)
- `@fusion/desktop` embeds dashboard assets at build time via script (`packages/desktop/scripts/build.ts`) but does not declare workspace deps in `package.json` - `@fusion/desktop` embeds dashboard assets at build time via script (`packages/desktop/scripts/build.ts`) but does not declare workspace deps in `package.json`
- `@fusion/mobile` triggers dashboard build/sync via scripts (`packages/mobile/package.json`) but does not declare workspace deps in `package.json` - `@fusion/mobile` triggers dashboard build/sync via scripts (`packages/mobile/package.json`) but does not declare workspace deps in `package.json`

View File

@@ -32,7 +32,6 @@ pnpm build
| `@fusion/core` | Shared domain types, stores, persistence, and core utilities | | `@fusion/core` | Shared domain types, stores, persistence, and core utilities |
| `@fusion/dashboard` | Express API + React UI | | `@fusion/dashboard` | Express API + React UI |
| `@fusion/engine` | Scheduling, triage, execution, merge orchestration | | `@fusion/engine` | Scheduling, triage, execution, merge orchestration |
| `@fusion/tui` | Ink-based terminal UI components |
| `@fusion/desktop` | Electron shell around Fusion dashboard/client | | `@fusion/desktop` | Electron shell around Fusion dashboard/client |
| `@fusion/mobile` | Capacitor + PWA mobile packaging | | `@fusion/mobile` | Capacitor + PWA mobile packaging |
| `@fusion/plugin-sdk` | Plugin SDK for building Fusion extensions | | `@fusion/plugin-sdk` | Plugin SDK for building Fusion extensions |

View File

@@ -1,14 +1,12 @@
# FN-1205 System Gap Analysis # FN-1205 System Gap Analysis
Date: 2026-04-08 Date: 2026-04-08
Scope: `packages/core`, `packages/engine`, `packages/dashboard`, `packages/cli`, `packages/tui`, `packages/desktop` Scope: `packages/core`, `packages/engine`, `packages/dashboard`, `packages/cli`, `packages/desktop`
## 1) Incomplete & Stub Packages ## 1) Incomplete & Stub Packages
### Finding 1.1 — `@fusion/tui` has basic screen navigation (**Medium**) ### Finding 1.1 — Terminal dashboard is now part of `@runfusion/fusion` (**Resolved**)
- Evidence: `packages/tui/src/index.tsx` renders `DemoApp` with `FusionProvider` and `ScreenRouter`. The `ScreenRouter` component (`packages/tui/src/components/screen-router.tsx`) provides keyboard-navigable tab switching with support for five screens (board, detail, activity, agents, settings). Number keys 1-5 and Tab/Shift+Tab navigate between tabs. However, the actual screen content is still placeholder text. - The standalone `@fusion/tui` package has been removed. Terminal UI is implemented in `packages/cli/src/commands/dashboard-tui/` using Ink (React for terminals). The dashboard-tui module provides a fully working 5-panel TUI (system, logs, utilities, stats, settings) integrated into the `fn dashboard` command and launched by default when running `fn` with no arguments.
- Impact: The TUI package now has a foundation for real screen implementations, but full task views and integration points remain to be built.
- Existing tracking: **FN-1055** and **FN-1470** track TUI development.
### Finding 1.2 — `packages/desktop` is implemented, not a placeholder (**Info / correction to preflight assumption**) ### Finding 1.2 — `packages/desktop` is implemented, not a placeholder (**Info / correction to preflight assumption**)
- Evidence: `packages/desktop` contains `package.json`, `tsconfig.json`, `vitest.config.ts`, `README.md`, build scripts, and substantial source files (`src/main.ts`, `src/ipc.ts`, `src/menu.ts`, `src/tray.ts`, `src/preload.ts`, renderer components/hooks, etc.). - Evidence: `packages/desktop` contains `package.json`, `tsconfig.json`, `vitest.config.ts`, `README.md`, build scripts, and substantial source files (`src/main.ts`, `src/ipc.ts`, `src/menu.ts`, `src/tray.ts`, `src/preload.ts`, renderer components/hooks, etc.).
@@ -228,7 +226,7 @@ Impact:
| Dimension | Gap count | Notes | | Dimension | Gap count | Notes |
|---|---:|---| |---|---:|---|
| 1. Incomplete & Stub Packages | 1 | `@fusion/tui` remains stub (desktop/runtime/ipc are implemented) | | 1. Incomplete & Stub Packages | 0 | `@fusion/tui` merged into `@runfusion/fusion`; desktop/runtime/ipc are implemented |
| 2. Missing Test Coverage | 16 (direct file-level gaps) | Concentrated in dashboard/cli utility layers | | 2. Missing Test Coverage | 16 (direct file-level gaps) | Concentrated in dashboard/cli utility layers |
| 3. Naming & Branding | 4 | User-facing `kb` strings remain across CLI, extension, dashboard | | 3. Naming & Branding | 4 | User-facing `kb` strings remain across CLI, extension, dashboard |
| 4. Error Handling & Silent Failures | 4 | Includes two high-severity runtime reliability issues | | 4. Error Handling & Silent Failures | 4 | Includes two high-severity runtime reliability issues |

View File

@@ -50,8 +50,12 @@
"@mariozechner/pi-ai": "^0.70.0", "@mariozechner/pi-ai": "^0.70.0",
"@mariozechner/pi-coding-agent": "^0.70.0", "@mariozechner/pi-coding-agent": "^0.70.0",
"express": "^5.1.0", "express": "^5.1.0",
"ink": "^6.8.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.6.0", "ioredis": "^5.6.0",
"multer": "^2.1.1" "multer": "^2.1.1",
"react": "^19.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@mariozechner/pi-ai": "*", "@mariozechner/pi-ai": "*",
@@ -74,11 +78,13 @@
"@fusion/dashboard": "workspace:*", "@fusion/dashboard": "workspace:*",
"@fusion/engine": "workspace:*", "@fusion/engine": "workspace:*",
"@fusion/pi-claude-cli": "workspace:*", "@fusion/pi-claude-cli": "workspace:*",
"typebox": "^1.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.1.0", "@vitest/coverage-v8": "^3.1.0",
"ink-testing-library": "^4.0.0",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typebox": "^1.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^3.1.0", "vitest": "^3.1.0",
"yaml": "^2.8.3" "yaml": "^2.8.3"

View File

@@ -210,6 +210,7 @@ const HELP = `
fn — AI-orchestrated task board fn — AI-orchestrated task board
Usage: Usage:
fn Launch the dashboard (same as fn dashboard)
fn init [opts] Initialize a new fn project in the current directory fn init [opts] Initialize a new fn project in the current directory
fn dashboard Start the board web UI fn dashboard Start the board web UI
fn dashboard --paused Start with automation paused fn dashboard --paused Start with automation paused
@@ -383,11 +384,18 @@ function getFlagValueNumber(args: string[], flag: string): number | undefined {
async function main() { async function main() {
const { cleanedArgs: args, projectName } = extractGlobalProjectFlag(process.argv.slice(2)); const { cleanedArgs: args, projectName } = extractGlobalProjectFlag(process.argv.slice(2));
if (args.length === 0 || args.includes("--help") || args.includes("-h")) { if (args.includes("--help") || args.includes("-h")) {
console.log(HELP); console.log(HELP);
process.exit(0); process.exit(0);
} }
if (args.length === 0) {
// No subcommand — launch dashboard on the default port.
const { runDashboard } = await import("./commands/dashboard.js");
await runDashboard(4040);
return;
}
const command = args[0]; const command = args[0];
const { const {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ import {
resolveClaudeCliExtensionPaths, resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution, setCachedClaudeCliResolution,
} from "./claude-cli-extension.js"; } 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 // Re-export for backward compatibility with tests
export { promptForPort }; export { promptForPort };
@@ -472,6 +472,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}); });
// Start the TUI // Start the TUI
await tui.start(); await tui.start();
tui.setLoadingStatus("Initializing task store…");
// Wire the TUI into the log sink so all console output routes through TUI // Wire the TUI into the log sink so all console output routes through TUI
logSink.setTUI(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 // and are properly managed throughout their lifecycle (creation, state
// transitions, termination). Passed to TaskExecutor for agent spawning. // transitions, termination). Passed to TaskExecutor for agent spawning.
// //
if (tui) tui.setLoadingStatus("Initializing agent store…");
agentStore = new AgentStore({ rootDir: store.getFusionDir() }); agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init(); await agentStore.init();
if (tui) tui.setLoadingStatus("Starting engine…");
// ── Reactive TUI Updates ───────────────────────────────────────────── // ── Reactive TUI Updates ─────────────────────────────────────────────
// //
@@ -1516,6 +1519,107 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
agents: agentStats, 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 // Log startup messages to TUI
tui.log(`Dashboard started at ${baseUrl}`); tui.log(`Dashboard started at ${baseUrl}`);
if (engineMode === "active") { if (engineMode === "active") {

View File

@@ -4,6 +4,7 @@
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"types": ["node", "vitest/globals"], "types": ["node", "vitest/globals"],
"jsx": "react-jsx",
"paths": { "paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"] "@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
} }

View File

@@ -25,7 +25,7 @@ export default defineConfig({
], ],
}, },
test: { test: {
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
// build-exe + build-exe-cross live in their own vitest project // build-exe + build-exe-cross live in their own vitest project
// (see vitest.build-exe.config.ts) so the rest of the CLI suite can // (see vitest.build-exe.config.ts) so the rest of the CLI suite can
// run with file parallelism enabled. // run with file parallelism enabled.

View File

@@ -69,7 +69,8 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"ws": "^8.18.0" "ws": "^8.18.0",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",

View File

@@ -1,487 +0,0 @@
# @fusion/tui
Terminal UI components for fn, built with [Ink](https://github.com/vadimdemedes/ink) (React for the command line).
## Status
This package is under active development and not yet published.
## Terminal Compatibility
This package is designed for terminals with a minimum size of **80×24** characters:
- **Minimum width**: 80 columns
- **Minimum height**: 24 rows
When the terminal is smaller than these minimums, the UI enforces these bounds for layout calculations, ensuring consistent rendering across different terminal sizes.
## Responsive Layout
The TUI provides responsive layout utilities that adapt to terminal dimensions:
- **Minimum bounds**: Layouts always respect the 80×24 minimum, ensuring readability
- **Dynamic column widths**: Tables and lists compute column widths based on available space
- **Truncation with ellipsis**: Long content is automatically truncated with `…` (U+2026) when it exceeds the available width
## Installation
This package is part of the fn workspace and is not installed separately. It is available as a private workspace package.
## API Reference
### FusionProvider
The `FusionProvider` component initializes a `TaskStore` and provides it via React context.
```tsx
import { FusionProvider } from "@fusion/tui";
function App() {
return (
<FusionProvider>
<MyComponent />
</FusionProvider>
);
}
```
#### Props
| Prop | Type | Description |
|------|------|-------------|
| `projectDir` | `string` (optional) | Explicit project directory override. When provided, skips auto-detection. |
| `children` | `React.ReactNode` | Child components that will have access to the Fusion context. |
#### Behavior
- On mount, auto-detects the Fusion project by walking up from `process.cwd()` looking for `.fusion/fusion.db`
- If no project is found, renders a red error message
- On unmount, calls `store.close()` to cleanly shut down the SQLite connection
### useFusion
Hook to access the Fusion context. Must be used within a `FusionProvider`.
```tsx
import { useFusion } from "@fusion/tui";
function TaskList() {
const { store, projectPath } = useFusion();
useEffect(() => {
store.listTasks().then((tasks) => {
// Render tasks...
});
}, [store]);
return <Text>Project: {projectPath}</Text>;
}
```
#### Returns
| Property | Type | Description |
|----------|------|-------------|
| `store` | `TaskStore` | The initialized TaskStore instance |
| `projectPath` | `string` | Absolute path to the project directory |
#### Throws
`Error` if used outside of a `FusionProvider`.
### detectProjectDir
Detect the Fusion project root directory by walking up from a starting path.
```typescript
import { detectProjectDir } from "@fusion/tui";
// Find project from current directory
const projectPath = detectProjectDir();
// Find project from a specific directory
const projectPath = detectProjectDir("/Users/me/code/my-project/src");
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `startPath` | `string` (optional) | Starting directory for the search (defaults to `process.cwd()`) |
#### Returns
The absolute path to the project root, or `null` if no project directory is detected.
### ScreenRouter
The `ScreenRouter` component provides a keyboard-navigable tab bar for switching between application screens.
```tsx
import { ScreenRouter } from "@fusion/tui";
function App() {
return (
<ScreenRouter>
{({ activeScreen }) => (
<>
{activeScreen === "board" && <BoardScreen />}
{activeScreen === "detail" && <DetailScreen />}
{activeScreen === "activity" && <ActivityScreen />}
{activeScreen === "agents" && <AgentsScreen />}
{activeScreen === "settings" && <SettingsScreen />}
</>
)}
</ScreenRouter>
);
}
```
#### Available Screens
The router manages five screens in this order:
| Index | Screen ID | Label | Shortcut |
|-------|-----------|-------|----------|
| 1 | `board` | Board | `1` |
| 2 | `detail` | Detail | `2` |
| 3 | `activity` | Activity | `3` |
| 4 | `agents` | Agents | `4` |
| 5 | `settings` | Settings | `5` |
#### Keyboard Navigation
| Key | Action |
|-----|--------|
| `1` - `5` | Jump directly to the corresponding tab |
| `Tab` | Cycle forward through tabs (wraps from end to start) |
| `Shift+Tab` | Cycle backward through tabs (wraps from start to end) |
#### Tab Bar Rendering
The tab bar displays all five tabs horizontally with:
- Active tab highlighted with bold text, cyan background, and black text
- Inactive tabs shown in white text
- A border line below the tab bar
#### Props
| Prop | Type | Description |
|------|------|-------------|
| `initialScreen` | `ScreenId` (optional) | Initial screen to display on mount (default: `"board"`) |
| `onScreenChange` | `(screenId: ScreenId) => void` (optional) | Callback when user navigates to a different screen |
| `activeScreen` | `ScreenId` (optional) | Externally controlled active screen |
| `children` | `(props: ScreenComponentProps) => React.ReactNode` | Render function that receives `activeScreen` and returns the screen content |
#### ScreenComponentProps
| Property | Type | Description |
|----------|------|-------------|
| `activeScreen` | `ScreenId` | The currently active screen ID (`"board"` \| `"detail"` \| `"activity"` \| `"agents"` \| `"settings"`) |
#### Exports
The following are exported from `@fusion/tui`:
- `ScreenRouter` — The main router component
- `SCREENS` — Array of screen definitions with `id`, `label`, and `shortcut`
- `getScreenById(id)` — Get screen definition by ID
- `getScreenIndex(id)` — Get screen index by ID
- `type ScreenId` — Type for screen identifiers
### Global Keyboard Shortcuts
The TUI provides centralized global keyboard shortcuts via the `useGlobalShortcuts` hook. Place this hook at the app root level to enable consistent shortcut handling across all screens.
```tsx
import { useGlobalShortcuts, HelpOverlay } from "@fusion/tui";
function App() {
const { helpVisible, toggleHelp } = useGlobalShortcuts({
onScreenChange: setActiveScreen,
});
return (
<>
{helpVisible && <HelpOverlay onClose={toggleHelp} />}
<ScreenRouter ... />
</>
);
}
```
#### Available Shortcuts
| Key | Action | Focus Guard |
|-----|--------|-------------|
| `Ctrl+C` | Quit (emergency exit) | Always works |
| `q` | Quit | Only when no text input focused |
| `?` | Toggle help overlay | Only when no text input focused |
| `h` | Toggle help overlay (alternate) | Only when no text input focused |
| `1` - `5` | Switch screens | Only when no text input focused |
#### Focus Guard
Global shortcuts (except `Ctrl+C`) are suppressed when text input is focused. This prevents accidental navigation while typing.
To enable focus guarding for text inputs, import `FocusGuardRef` and set `isFocused` on focus/blur events:
```tsx
import { FocusGuardRef } from "@fusion/tui";
function TextInput() {
return (
<Input
onFocus={() => { FocusGuardRef.isFocused = true; }}
onBlur={() => { FocusGuardRef.isFocused = false; }}
/>
);
}
```
#### useGlobalShortcuts Hook
```tsx
const result = useGlobalShortcuts({
onScreenChange: (screenId) => {
// Handle screen switch triggered by number keys
},
});
```
##### Options
| Property | Type | Description |
|----------|------|-------------|
| `onScreenChange` | `(screenId: ScreenId) => void` (optional) | Callback when user presses 1-5 to switch screens |
##### Returns
| Property | Type | Description |
|----------|------|-------------|
| `helpVisible` | `boolean` | Whether the help overlay is currently visible |
| `toggleHelp` | `() => void` | Toggle the help overlay visibility |
| `hideHelp` | `() => void` | Hide the help overlay |
#### HelpOverlay Component
The `HelpOverlay` component displays available keyboard shortcuts. It handles `Escape` and `q` to close.
```tsx
<HelpOverlay onClose={toggleHelp} />
```
##### Props
| Property | Type | Description |
|----------|------|-------------|
| `onClose` | `() => void` | Callback to close the overlay |
#### Exports
The following are exported from `@fusion/tui`:
- `useGlobalShortcuts` — Hook for handling global keyboard shortcuts
- `HelpOverlay` — Component for displaying keyboard shortcuts
- `FocusGuardRef` — Shared ref for tracking text input focus state
## Example
```tsx
import React, { useState } from "react";
import { render, Box } from "ink";
import { FusionProvider, useFusion, ScreenRouter, useGlobalShortcuts, HelpOverlay, ResponsiveHeader, ResponsiveTable, ResponsiveStatusBar } from "@fusion/tui";
function App() {
const { projectPath } = useFusion();
const [activeScreen, setActiveScreen] = useState("board");
// Global keyboard shortcuts
const { helpVisible, toggleHelp } = useGlobalShortcuts({
onScreenChange: setActiveScreen,
});
return (
<Box flexDirection="column">
{/* Help overlay */}
{helpVisible && (
<Box marginBottom={1}>
<HelpOverlay onClose={toggleHelp} />
</Box>
)}
{/* Responsive header */}
<ResponsiveHeader title={`Fusion TUI | Project: ${projectPath}`} />
{/* Screen router */}
<ScreenRouter
activeScreen={activeScreen}
onScreenChange={setActiveScreen}
>
{({ activeScreen }) => (
<Box flexDirection="column">
{activeScreen === "board" && (
<ResponsiveTable
columns={[
{ header: "ID", minWidth: 10 },
{ header: "Description", minWidth: 30, canGrow: true },
{ header: "Status", minWidth: 12 },
]}
rows={[
["FN-001", "Implement feature", "todo"],
["FN-002", "Fix bug in auth", "done"],
]}
/>
)}
{activeScreen === "detail" && (
<Box>
<Text>Detail Screen</Text>
</Box>
)}
</Box>
)}
</ScreenRouter>
{/* Responsive status bar */}
<ResponsiveStatusBar />
</Box>
);
}
render(
<FusionProvider>
<App />
</FusionProvider>
);
```
## Responsive Layout Utilities
The TUI provides utilities for building responsive layouts that adapt to terminal dimensions.
### useTerminalDimensions
Hook to read live terminal dimensions from Ink's `useStdout()` with minimum bounds applied.
```tsx
import { useTerminalDimensions } from "@fusion/tui";
function MyComponent() {
const { columns, rows, isMinimumSize, extraColumns } = useTerminalDimensions();
return (
<Box>
<Text>Terminal: {columns}x{rows}</Text>
{!isMinimumSize && <Text dimColor> (wider than minimum)</Text>}
</Box>
);
}
```
#### Returns
| Property | Type | Description |
|----------|------|-------------|
| `columns` | `number` | Effective column count (minimum 80) |
| `rows` | `number` | Effective row count (minimum 24) |
| `isMinimumSize` | `boolean` | Whether terminal meets minimum size |
| `extraColumns` | `number` | Extra columns beyond the 80-column minimum |
### computeColumnLayout
Calculate column widths based on terminal dimensions and column definitions.
```tsx
import { computeColumnLayout } from "@fusion/tui";
const layout = computeColumnLayout(120, [
{ minWidth: 10, canGrow: false }, // Fixed-width ID column
{ minWidth: 30, canGrow: true, growWeight: 2 }, // Description (grows 2x)
{ minWidth: 15, canGrow: true }, // Status (grows 1x)
]);
console.log(layout.widths); // e.g., [10, 63, 47]
console.log(layout.totalWidth); // 120
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `columns` | `number` | Available terminal columns |
| `definitions` | `ColumnDefinition[]` | Column definitions with minWidth, preferredWidth, canGrow, growWeight |
| `strategy` | `ColumnStrategy` | Allocation strategy: `"equal"`, `"fixed"`, `"proportional"`, `"content-heavy"` |
#### Returns
| Property | Type | Description |
|----------|------|-------------|
| `widths` | `number[]` | Calculated width for each column |
| `totalWidth` | `number` | Total width used by all columns |
| `remainingColumns` | `number` | Leftover columns after minimum allocations |
### truncateText
Truncate text to a maximum width with ellipsis.
```tsx
import { truncateText } from "@fusion/tui";
truncateText("Hello World", 8); // "Hello W…"
truncateText("Hi", 10); // "Hi" (fits)
truncateText("Hello", 2); // "…" (too short)
truncateText("Hello World", 10, "~~"); // "Hello Wo~~" (custom ellipsis)
```
### Responsive Components
#### ResponsiveHeader
A header component that adapts to terminal width.
```tsx
import { ResponsiveHeader } from "@fusion/tui";
<ResponsiveHeader title="My App" />
```
#### ResponsiveTable
A table component with responsive column widths and truncation.
```tsx
import { ResponsiveTable } from "@fusion/tui";
<ResponsiveTable
columns={[
{ header: "ID", minWidth: 10 },
{ header: "Description", minWidth: 30, canGrow: true },
{ header: "Status", minWidth: 12 },
]}
rows={[
["FN-001", "Implement feature", "todo"],
["FN-002", "Fix bug in auth", "done"],
]}
/>
```
#### ResponsiveStatusBar
A status bar showing current terminal dimensions.
```tsx
import { ResponsiveStatusBar } from "@fusion/tui";
<ResponsiveStatusBar />
// Displays: "Terminal: 120x40 | Minimum: 80x24"
```
### Exports
The following are exported from `@fusion/tui`:
- `useTerminalDimensions` — Hook for reading terminal dimensions
- `computeColumnLayout` — Function for calculating column widths
- `truncateText` — Function for truncating text with ellipsis
- `ResponsiveHeader` — Header component with responsive content
- `ResponsiveTable` — Table component with responsive columns
- `ResponsiveTaskRow` — Task row with truncation
- `ResponsiveStatusBar` — Status bar showing terminal info
- `MIN_TERMINAL_COLUMNS` — Minimum supported terminal width (80)
- `MIN_TERMINAL_ROWS` — Minimum supported terminal height (24)

View File

@@ -1,49 +0,0 @@
{
"name": "@fusion/tui",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion TUI: terminal UI for interacting with the Fusion task store and AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/tui"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"exports": {
".": {
"types": "./src/index.tsx",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"dev": "tsx src/index.tsx",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/core": "workspace:*",
"ink": "^6.8.0",
"react": "^19.0.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.1.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
"engines": {
"node": ">=22.5.0"
},
"private": true
}

View File

@@ -1,260 +0,0 @@
/**
* Tests for FusionContext provider and project detection.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import React from "react";
import { render } from "ink";
import { Writable } from "node:stream";
import { detectProjectDir } from "../project-detect";
import { FusionProvider, useFusion, FusionContext } from "../fusion-context";
import { TaskStore } from "@fusion/core";
import { mkdir, writeFile } from "fs/promises";
import { rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tempWorkspace } from "@fusion/test-utils";
function createSinkStream(): NodeJS.WriteStream {
const stream = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}) as NodeJS.WriteStream;
stream.columns = 80;
stream.rows = 24;
return stream;
}
function renderTest(node: React.ReactNode) {
return render(node, {
stdout: createSinkStream(),
stderr: createSinkStream(),
patchConsole: false,
exitOnCtrlC: false,
maxFps: 1000,
});
}
// Mock TaskStore to avoid actual filesystem operations in most tests
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual("@fusion/core");
return {
...actual as object,
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
})),
};
});
describe("detectProjectDir", () => {
it("returns project root when .fusion/fusion.db exists in start directory", async () => {
const projectDir = tempWorkspace("fusion-test-project-1-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
const result = detectProjectDir(projectDir);
expect(result).toBe(projectDir);
});
it("returns project root when .fusion/fusion.db exists in a parent directory", async () => {
const projectDir = tempWorkspace("fusion-test-project-2-");
const subDir = join(projectDir, "src", "components");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
await mkdir(subDir, { recursive: true });
const result = detectProjectDir(subDir);
expect(result).toBe(projectDir);
});
it("returns null when no .fusion/ exists anywhere up to root", async () => {
// Use a directory that definitely won't have .fusion above it
const startDir = tempWorkspace("no-fusion-project-");
await mkdir(startDir, { recursive: true });
const result = detectProjectDir(startDir);
expect(result).toBeNull();
});
it("returns null when .fusion/ exists but no fusion.db", async () => {
const projectDir = tempWorkspace("fusion-test-project-3-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
// Don't create fusion.db
const result = detectProjectDir(projectDir);
expect(result).toBeNull();
});
});
describe("FusionProvider", () => {
it("initializes TaskStore and provides it via context when project dir is valid", async () => {
const projectDir = tempWorkspace("fusion-provider-test-1-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
let capturedStore: TaskStore | null = null;
let capturedPath: string | null = null;
function TestComponent() {
const { store, projectPath } = useFusion();
capturedStore = store;
capturedPath = projectPath;
return null;
}
const instance = renderTest(
<FusionProvider projectDir={projectDir}>
<TestComponent />
</FusionProvider>
);
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
expect(capturedStore).not.toBeNull();
expect(capturedPath).toBe(projectDir);
instance.unmount();
});
it("sets error state when no project directory is found", async () => {
// Compute a path that does not exist without creating it.
const nonExistentDir = join(tmpdir(), `non-existent-fusion-project-${Date.now()}-${Math.random().toString(36).slice(2)}`);
function TestComponent() {
const { store } = useFusion();
return null;
}
const instance = renderTest(
<FusionProvider projectDir={nonExistentDir}>
<TestComponent />
</FusionProvider>
);
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
// The error should be visible in the rendered output
// We can check this by verifying the component renders without crashing
// and the error message is available
instance.unmount();
// Defensive: remove the dir if something created it.
try { rmSync(nonExistentDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it("calls store.close() on unmount", async () => {
const projectDir = tempWorkspace("fusion-provider-test-2-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
let closeCalled = false;
// Create a mock store that tracks close calls
const mockStore = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockImplementation(() => {
closeCalled = true;
}),
};
vi.mocked(TaskStore).mockImplementation(() => mockStore as unknown as InstanceType<typeof TaskStore>);
function TestComponent() {
useFusion();
return null;
}
const instance = renderTest(
<FusionProvider projectDir={projectDir}>
<TestComponent />
</FusionProvider>
);
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
instance.unmount();
expect(closeCalled).toBe(true);
// Reset the mock
vi.mocked(TaskStore).mockClear();
});
it("accepts explicit projectDir prop and uses it instead of auto-detection", async () => {
const explicitDir = tempWorkspace("fusion-explicit-project-");
await mkdir(join(explicitDir, ".fusion"), { recursive: true });
await writeFile(join(explicitDir, ".fusion", "fusion.db"), "");
let capturedPath: string | null = null;
function TestComponent() {
const { projectPath } = useFusion();
capturedPath = projectPath;
return null;
}
const instance = renderTest(
<FusionProvider projectDir={explicitDir}>
<TestComponent />
</FusionProvider>
);
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
expect(capturedPath).toBe(explicitDir);
instance.unmount();
});
});
describe("useFusion hook", () => {
it("throws error when used outside of FusionProvider", () => {
// Ink captures render errors and displays them in the output rather than throwing.
// The test output shows:
// ERROR useFusion must be used within a <FusionProvider>
// This verifies the hook correctly throws when used outside a provider.
// Note: We cannot use expect().toThrow() with ink's render.
// Verify the context is properly exported and not null
expect(FusionContext).toBeDefined();
});
it("returns context value when used inside FusionProvider", async () => {
const projectDir = tempWorkspace("fusion-hook-test-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
let contextValue: { store: TaskStore; projectPath: string } | null = null;
function GoodComponent() {
contextValue = useFusion();
return null;
}
const instance = renderTest(
<FusionProvider projectDir={projectDir}>
<GoodComponent />
</FusionProvider>
);
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
expect(contextValue).not.toBeNull();
expect(contextValue!.store).toBeDefined();
expect(contextValue!.projectPath).toBe(projectDir);
instance.unmount();
});
});

View File

@@ -1,519 +0,0 @@
/**
* Tests for global keyboard shortcuts hook.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { render, Box, Text } from "ink";
import { Writable } from "node:stream";
import { useGlobalShortcuts, HelpOverlay, FocusGuardRef, type ScreenId } from "../hooks/use-global-shortcuts";
import type { Key } from "ink";
// Track captured handlers for test assertions
let capturedUseInputHandlers: ((input: string, key: Key) => void)[] = [];
let capturedExitFn: (() => void) | undefined;
function createSinkStream(): NodeJS.WriteStream {
const stream = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}) as NodeJS.WriteStream;
stream.columns = 80;
stream.rows = 24;
return stream;
}
function renderTest(node: React.ReactNode) {
return render(node, {
stdout: createSinkStream(),
stderr: createSinkStream(),
patchConsole: false,
exitOnCtrlC: false,
maxFps: 1000,
});
}
// Mock ink hooks to avoid raw mode errors in tests
vi.mock("ink", async (importOriginal) => {
const actual = await importOriginal<typeof import("ink")>();
return {
...actual,
useInput: vi.fn((handler: (input: string, key: Key) => void) => {
capturedUseInputHandlers.push(handler);
}),
useApp: vi.fn().mockReturnValue({
exit: vi.fn(() => {
capturedExitFn?.();
}),
}),
};
});
describe("useGlobalShortcuts", () => {
beforeEach(() => {
capturedUseInputHandlers = [];
capturedExitFn = undefined;
// Reset focus guard ref
FocusGuardRef.isFocused = false;
});
afterEach(() => {
capturedUseInputHandlers = [];
FocusGuardRef.isFocused = false;
});
describe("initial state", () => {
it("starts with help overlay hidden", async () => {
let capturedHelpVisible: boolean | undefined;
function TestComponent() {
const { helpVisible } = useGlobalShortcuts();
capturedHelpVisible = helpVisible;
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(capturedHelpVisible).toBe(false);
instance.unmount();
});
it("provides toggleHelp function", async () => {
let toggleHelpFn: (() => void) | undefined;
function TestComponent() {
const { toggleHelp } = useGlobalShortcuts();
toggleHelpFn = toggleHelp;
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(toggleHelpFn).toBeDefined();
expect(typeof toggleHelpFn).toBe("function");
instance.unmount();
});
it("provides hideHelp function", async () => {
let hideHelpFn: (() => void) | undefined;
function TestComponent() {
const { hideHelp } = useGlobalShortcuts();
hideHelpFn = hideHelp;
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(hideHelpFn).toBeDefined();
expect(typeof hideHelpFn).toBe("function");
instance.unmount();
});
});
describe("helpVisible state management", () => {
it("toggleHelp toggles helpVisible from false to true", async () => {
let helpVisibleValues: boolean[] = [];
let toggleFn: (() => void) | undefined;
function TestComponent() {
const { helpVisible, toggleHelp } = useGlobalShortcuts();
helpVisibleValues.push(helpVisible);
toggleFn = toggleHelp;
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
// Initial state should be false
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(false);
// Call toggle
toggleFn?.();
await new Promise((resolve) => setTimeout(resolve, 50));
// After toggle, state should be true
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(true);
instance.unmount();
});
it("toggleHelp toggles helpVisible from true to false", async () => {
let helpVisibleValues: boolean[] = [];
let toggleFn: (() => void) | undefined;
function TestComponent() {
const { helpVisible, toggleHelp } = useGlobalShortcuts();
helpVisibleValues.push(helpVisible);
toggleFn = toggleHelp;
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
// First toggle
toggleFn?.();
await new Promise((resolve) => setTimeout(resolve, 50));
// Second toggle should bring back to false
toggleFn?.();
await new Promise((resolve) => setTimeout(resolve, 50));
// State should be false again
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(false);
instance.unmount();
});
});
describe("screen change callback", () => {
it("accepts onScreenChange option", async () => {
const onScreenChange = vi.fn();
function TestComponent() {
useGlobalShortcuts({ onScreenChange });
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onScreenChange).toBeDefined();
instance.unmount();
});
it("calls onScreenChange with screenId when number key is pressed", async () => {
const onScreenChange = vi.fn();
function TestComponent() {
useGlobalShortcuts({ onScreenChange });
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
// Get the registered input handler
expect(capturedUseInputHandlers.length).toBeGreaterThan(0);
const handler = capturedUseInputHandlers[0];
// Simulate pressing "1"
handler("1", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(onScreenChange).toHaveBeenCalledWith("board");
instance.unmount();
});
it("calls onScreenChange with correct screen IDs for keys 1-5", async () => {
const onScreenChange = vi.fn();
function TestComponent() {
useGlobalShortcuts({ onScreenChange });
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const expectedScreens: ScreenId[] = ["board", "detail", "activity", "agents", "settings"];
const handler = capturedUseInputHandlers[0];
for (let i = 0; i < 5; i++) {
onScreenChange.mockClear();
const key = String(i + 1);
handler(key, { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(onScreenChange).toHaveBeenCalledWith(expectedScreens[i]);
}
instance.unmount();
});
});
describe("focus guard", () => {
it("prevents screen change when FocusGuardRef.isFocused is true", async () => {
const onScreenChange = vi.fn();
// Set focus guard BEFORE render
FocusGuardRef.isFocused = true;
function TestComponent() {
useGlobalShortcuts({ onScreenChange });
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
// Simulate pressing "1" - should NOT trigger screen change when focused
handler("1", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
// onScreenChange should NOT be called when focused
expect(onScreenChange).not.toHaveBeenCalled();
instance.unmount();
});
it("allows screen change when FocusGuardRef.isFocused is false", async () => {
const onScreenChange = vi.fn();
// Ensure focus guard is NOT set
FocusGuardRef.isFocused = false;
function TestComponent() {
useGlobalShortcuts({ onScreenChange });
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
// Simulate pressing "1" - should trigger screen change when not focused
handler("1", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
// onScreenChange SHOULD be called when not focused
expect(onScreenChange).toHaveBeenCalledWith("board");
instance.unmount();
});
});
describe("Ctrl+C exit", () => {
it("calls exit when Ctrl+C is pressed", async () => {
let exitCalled = false;
capturedExitFn = () => {
exitCalled = true;
};
function TestComponent() {
useGlobalShortcuts();
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
// Simulate Ctrl+C
handler("c", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: true, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(exitCalled).toBe(true);
instance.unmount();
});
});
describe("q exit (focus guard)", () => {
it("calls exit when q is pressed and FocusGuardRef.isFocused is false", async () => {
let exitCalled = false;
capturedExitFn = () => {
exitCalled = true;
};
FocusGuardRef.isFocused = false;
function TestComponent() {
useGlobalShortcuts();
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
handler("q", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(exitCalled).toBe(true);
instance.unmount();
});
it("does not call exit when q is pressed but FocusGuardRef.isFocused is true", async () => {
let exitCalled = false;
capturedExitFn = () => {
exitCalled = true;
};
// Set focus guard - input is focused
FocusGuardRef.isFocused = true;
function TestComponent() {
useGlobalShortcuts();
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
handler("q", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(exitCalled).toBe(false);
instance.unmount();
});
});
describe("help toggle (? and h)", () => {
it("triggers toggle when ? is pressed and FocusGuardRef.isFocused is false", async () => {
let helpVisibleValues: boolean[] = [];
FocusGuardRef.isFocused = false;
function TestComponent() {
const { helpVisible } = useGlobalShortcuts();
helpVisibleValues.push(helpVisible);
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
// Press "?" to toggle
handler("?", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 50));
// Help should be visible after toggle
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(true);
instance.unmount();
});
it("triggers toggle when h is pressed and FocusGuardRef.isFocused is false", async () => {
let helpVisibleValues: boolean[] = [];
FocusGuardRef.isFocused = false;
function TestComponent() {
const { helpVisible } = useGlobalShortcuts();
helpVisibleValues.push(helpVisible);
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
// Press "h" to toggle
handler("h", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 50));
// Help should be visible after toggle
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(true);
instance.unmount();
});
it("does not trigger toggle when ? is pressed but FocusGuardRef.isFocused is true", async () => {
let helpVisibleValues: boolean[] = [];
// Set focus guard - input is focused
FocusGuardRef.isFocused = true;
function TestComponent() {
const { helpVisible } = useGlobalShortcuts();
helpVisibleValues.push(helpVisible);
return <Text>Test</Text>;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[0];
// Press "?" - should NOT toggle when focused
handler("?", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 50));
// Help should still be hidden
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(false);
instance.unmount();
});
});
});
describe("HelpOverlay", () => {
it("renders without crashing", async () => {
const onClose = vi.fn();
const instance = renderTest(<HelpOverlay onClose={onClose} />);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(() => instance.unmount()).not.toThrow();
});
it("calls onClose when Escape is pressed", async () => {
const onClose = vi.fn();
function TestComponent() {
return <HelpOverlay onClose={onClose} />;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[capturedUseInputHandlers.length - 1];
handler("", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: true, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(onClose).toHaveBeenCalled();
instance.unmount();
});
it("calls onClose when q is pressed", async () => {
const onClose = vi.fn();
function TestComponent() {
return <HelpOverlay onClose={onClose} />;
}
const instance = renderTest(<TestComponent />);
await new Promise((resolve) => setTimeout(resolve, 50));
const handler = capturedUseInputHandlers[capturedUseInputHandlers.length - 1];
handler("q", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(onClose).toHaveBeenCalled();
instance.unmount();
});
it("displays keyboard shortcuts", async () => {
const onClose = vi.fn();
const instance = renderTest(<HelpOverlay onClose={onClose} />);
await new Promise((resolve) => setTimeout(resolve, 50));
// The component should render without error
expect(() => instance.unmount()).not.toThrow();
});
});

View File

@@ -1,384 +0,0 @@
/**
* Tests for responsive layout utilities (terminal dimensions and truncation).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { Writable } from "node:stream";
import { computeColumnLayout, MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS, type ColumnDefinition } from "../utils/terminal";
import { truncateText, truncateWithOptions, padText, fitText } from "../utils/truncate";
// Mock stdout state as a mutable object that can be updated between tests
const mockStdout = {
columns: 80,
rows: 24,
write: vi.fn(),
};
function createSinkStream(): NodeJS.WriteStream {
const stream = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}) as NodeJS.WriteStream;
stream.columns = 80;
stream.rows = 24;
return stream;
}
function renderTest(node: React.ReactNode) {
return render(node, {
stdout: createSinkStream(),
stderr: createSinkStream(),
patchConsole: false,
exitOnCtrlC: false,
maxFps: 1000,
});
}
// Mock Ink's useStdout for terminal dimension tests
// The mock returns a function that reads from the mutable mockStdout object
vi.mock("ink", async (importOriginal) => {
const actual = await importOriginal<typeof import("ink")>();
return {
...actual,
useStdout: () => mockStdout,
// Keep render from actual ink
render: actual?.render,
Box: actual?.Box,
Text: actual?.Text,
};
});
// Import after mocking
import { useTerminalDimensions } from "../utils/terminal";
import { render } from "ink";
describe("terminal.ts", () => {
describe("MIN_TERMINAL_* constants", () => {
it("has minimum column count of 80", () => {
expect(MIN_TERMINAL_COLUMNS).toBe(80);
});
it("has minimum row count of 24", () => {
expect(MIN_TERMINAL_ROWS).toBe(24);
});
});
describe("useTerminalDimensions hook", () => {
it("returns dimensions with minimum bounds applied", () => {
let dimensions: ReturnType<typeof useTerminalDimensions> | null = null;
function TestComponent() {
dimensions = useTerminalDimensions();
return null;
}
const instance = renderTest(<TestComponent />);
instance.unmount();
expect(dimensions).not.toBeNull();
// Should have minimum bounds applied (80 columns, 24 rows)
expect(dimensions!.columns).toBeGreaterThanOrEqual(80);
expect(dimensions!.rows).toBeGreaterThanOrEqual(24);
expect(dimensions!.isMinimumSize).toBe(true);
expect(dimensions!.extraColumns).toBe(0);
});
it("does not crash when rendered", () => {
function TestComponent() {
const dims = useTerminalDimensions();
return null;
}
const instance = renderTest(<TestComponent />);
expect(() => instance.unmount()).not.toThrow();
});
});
describe("computeColumnLayout", () => {
it("returns empty layout for no columns", () => {
const layout = computeColumnLayout(100, []);
expect(layout.widths).toEqual([]);
expect(layout.totalWidth).toBe(0);
expect(layout.remainingColumns).toBe(100);
});
it("uses minimum widths when at or below minimum total", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10 },
{ minWidth: 20 },
{ minWidth: 15 },
];
const layout = computeColumnLayout(80, definitions);
expect(layout.widths).toEqual([10, 20, 15]);
expect(layout.totalWidth).toBe(45);
expect(layout.remainingColumns).toBe(35);
});
it("distributes extra columns proportionally by default", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true, growWeight: 1 },
{ minWidth: 20, canGrow: true, growWeight: 2 },
{ minWidth: 15, canGrow: false }, // Won't grow
];
const layout = computeColumnLayout(100, definitions);
// Minimum total: 45, Extra: 55
// Total weight: 3 (1+2)
// Column 0: 10 + floor(55 * 1 / 3) = 10 + 18 = 28
// Column 1: 20 + floor(55 * 2 / 3) = 20 + 36 = 56 (but due to rounding in loop, becomes 57)
// Column 2: 15 (doesn't grow)
expect(layout.widths[0]).toBe(28);
// Note: Due to rounding, the last growable column gets the remainder
expect(layout.widths[2]).toBe(15);
expect(layout.totalWidth).toBe(100);
// Verify proportional distribution (columns 0 and 1 should be ~2x each other)
expect(layout.widths[0]).toBeLessThan(layout.widths[1]);
expect(layout.widths[1] / layout.widths[0]).toBeGreaterThan(1.5);
});
it("distributes extra columns equally with 'equal' strategy", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 20, canGrow: true },
{ minWidth: 15, canGrow: true },
];
const layout = computeColumnLayout(100, definitions, "equal");
// Minimum total: 45, Extra: 55
// Extra per growable: floor(55 / 3) = 18
// Remainder: 55 % 3 = 1
expect(layout.widths[0]).toBe(28); // 10 + 18
expect(layout.widths[1]).toBe(38); // 20 + 18
expect(layout.widths[2]).toBe(33); // 15 + 18
expect(layout.remainingColumns).toBe(1); // Rounding remainder
});
it("does not distribute extra columns with 'fixed' strategy", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 20, canGrow: true },
];
const layout = computeColumnLayout(100, definitions, "fixed");
expect(layout.widths).toEqual([10, 20]);
expect(layout.totalWidth).toBe(30);
expect(layout.remainingColumns).toBe(70);
});
it("prioritizes content-heavy columns with 'content-heavy' strategy", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true, preferredWidth: 20 }, // Needs 10 more
{ minWidth: 30, canGrow: true, preferredWidth: 35 }, // Needs 5 more
{ minWidth: 15, canGrow: false }, // Won't grow
];
const layout = computeColumnLayout(100, definitions, "content-heavy");
// Minimum total: 55, Extra: 45
// Content scores: 10, 5, 0
// Total score: 15
// Column 0: 10 + floor(45 * 10 / 15) = 10 + 30 = 40
// Column 1: 30 + floor(45 * 5 / 15) = 30 + 15 = 45
// Column 2: 15 (doesn't grow)
expect(layout.widths[0]).toBe(40);
expect(layout.widths[1]).toBe(45);
expect(layout.widths[2]).toBe(15);
expect(layout.totalWidth).toBe(100);
});
it("handles edge case of exactly minimum width", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 20, canGrow: true },
];
const layout = computeColumnLayout(30, definitions);
expect(layout.widths).toEqual([10, 20]);
expect(layout.totalWidth).toBe(30);
});
it("defaults growWeight to 1", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true }, // Default weight: 1
{ minWidth: 20, canGrow: true }, // Default weight: 1
];
const layout = computeColumnLayout(100, definitions, "proportional");
// Both columns grow equally since weights are equal
expect(layout.widths[0]).toBeGreaterThan(10);
expect(layout.widths[1]).toBeGreaterThan(20);
expect(layout.totalWidth).toBe(100);
// Both should be allocated more than their minimum
expect(layout.widths[0] + layout.widths[1]).toBeGreaterThan(30);
});
});
});
describe("truncate.ts", () => {
describe("truncateText", () => {
it("returns text unchanged when it fits", () => {
expect(truncateText("Hello", 10)).toBe("Hello");
});
it("returns text unchanged when it exactly fits", () => {
expect(truncateText("Hello", 5)).toBe("Hello");
});
it("truncates with ellipsis when text exceeds width", () => {
expect(truncateText("Hello World", 8)).toBe("Hello W…");
});
it("returns single ellipsis when width is very small", () => {
expect(truncateText("Hello", 1)).toBe("…");
expect(truncateText("Hello", 2)).toBe("…");
expect(truncateText("Hello", 3)).toBe("…");
});
it("returns truncated ellipsis when width is exactly 1-3", () => {
expect(truncateText("Hello", 1)).toBe("…");
expect(truncateText("Hello", 2)).toBe("…");
expect(truncateText("Hello", 3)).toBe("…");
});
it("uses custom ellipsis", () => {
// At width 10, available is 10 - 2 (for "~~") = 8
expect(truncateText("Hello World", 10, "~~")).toBe("Hello Wo~~");
});
it("handles empty string", () => {
expect(truncateText("", 10)).toBe("");
});
it("handles zero width", () => {
expect(truncateText("Hello", 0)).toBe("");
});
it("handles negative width", () => {
expect(truncateText("Hello", -5)).toBe("");
});
it("truncates very long text correctly", () => {
const longText = "a".repeat(1000);
expect(truncateText(longText, 10)).toBe("aaaaaaaaa…");
expect(truncateText(longText, 10).length).toBe(10);
});
});
describe("truncateWithOptions", () => {
it("respects preserveWords option", () => {
const text = "Hello World Example";
// At width 12, without preserveWords: "Hello World…"
// With preserveWords: looks for space near boundary
const result = truncateWithOptions(text, 12, { preserveWords: true });
// Should not break mid-word
expect(result).not.toMatch(/^[^ ]* …$/); // No mid-word break
});
it("respects custom ellipsis option", () => {
// At width 8 with "~~" (2 chars), available is 8 - 2 = 6
const result = truncateWithOptions("Hello World", 8, { ellipsis: "~~" });
expect(result).toBe("Hello ~~");
});
it("respects minTruncateWidth option", () => {
// With minTruncateWidth of 6, at width 5 should use ellipsis
const result = truncateWithOptions("Hello World", 5, { minTruncateWidth: 6 });
expect(result).toBe("…");
});
});
describe("padText", () => {
it("pads text to the right by default", () => {
expect(padText("Hi", 6)).toBe("Hi ");
});
it("pads text to the left with right alignment", () => {
expect(padText("Hi", 6, "right")).toBe(" Hi");
});
it("centers text with center alignment", () => {
expect(padText("Hi", 6, "center")).toBe(" Hi ");
});
it("does not pad when text equals width", () => {
expect(padText("Hello", 5)).toBe("Hello");
});
it("truncates when text exceeds width", () => {
expect(padText("Hello World", 5)).toBe("Hello");
});
it("handles zero width", () => {
expect(padText("Hello", 0)).toBe("");
});
});
describe("fitText", () => {
it("pads short text to fill width", () => {
expect(fitText("Hi", 6)).toBe("Hi ");
});
it("truncates long text when no ellipsis specified", () => {
expect(fitText("Hello World", 6)).toBe("Hello");
});
it("truncates with ellipsis when specified", () => {
expect(fitText("Hello World", 8, "left", "…")).toBe("Hello W…");
});
it("respects alignment when padding", () => {
expect(fitText("Hi", 6, "center")).toBe(" Hi ");
expect(fitText("Hi", 6, "right")).toBe(" Hi");
});
it("handles edge case of equal width", () => {
expect(fitText("Hello", 5)).toBe("Hello");
});
it("handles zero width", () => {
expect(fitText("Hello", 0)).toBe("");
});
});
});
describe("integration: column layout with truncation", () => {
it("computes layout and truncates content to fit", () => {
const columns = 80;
const definitions: ColumnDefinition[] = [
{ minWidth: 8 }, // ID
{ minWidth: 40, canGrow: true }, // Description
{ minWidth: 10 }, // Status
{ minWidth: 12, canGrow: true }, // Created
{ minWidth: 10 }, // Priority
];
const layout = computeColumnLayout(columns, definitions);
// Verify widths are calculated
expect(layout.widths.length).toBe(5);
expect(layout.totalWidth).toBeLessThanOrEqual(columns);
// Simulate truncating content to fit
const longDescription = "This is a very long task description that needs truncation";
const truncated = truncateText(longDescription, layout.widths[1]);
expect(truncated.length).toBeLessThanOrEqual(layout.widths[1]);
});
it("produces deterministic layout at minimum terminal width", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 30, canGrow: true },
{ minWidth: 15, canGrow: true },
{ minWidth: 25, canGrow: false },
];
// Run multiple times to verify determinism
const layout1 = computeColumnLayout(80, definitions);
const layout2 = computeColumnLayout(80, definitions);
expect(layout1.widths).toEqual(layout2.widths);
expect(layout1.totalWidth).toBe(layout2.totalWidth);
});
});

View File

@@ -1,222 +0,0 @@
/**
* Tests for ScreenRouter component.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React, { useState } from "react";
import { render, Box, Text } from "ink";
import { Writable } from "node:stream";
import { mkdir, writeFile, remove } from "fs/promises";
import { join } from "node:path";
import { ScreenRouter, SCREENS, type ScreenId } from "../components/screen-router";
// Track temp directories for cleanup
const tempDirs: string[] = [];
function createSinkStream(): NodeJS.WriteStream {
const stream = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}) as NodeJS.WriteStream;
stream.columns = 80;
stream.rows = 24;
return stream;
}
function renderTest(node: React.ReactNode) {
return render(node, {
stdout: createSinkStream(),
stderr: createSinkStream(),
patchConsole: false,
exitOnCtrlC: false,
maxFps: 1000,
});
}
afterEach(async () => {
// Clean up temp directories
for (const dir of tempDirs) {
try {
await remove(dir);
} catch {
// Ignore cleanup errors
}
}
tempDirs.length = 0;
});
// Mock useInput to avoid raw mode errors in tests
vi.mock("ink", async (importOriginal) => {
const actual = await importOriginal<typeof import("ink")>();
return {
...actual,
useInput: vi.fn(),
};
});
describe("SCREENS constant", () => {
it("contains exactly five screens in the correct order", () => {
expect(SCREENS).toHaveLength(5);
expect(SCREENS[0].id).toBe("board");
expect(SCREENS[1].id).toBe("detail");
expect(SCREENS[2].id).toBe("activity");
expect(SCREENS[3].id).toBe("agents");
expect(SCREENS[4].id).toBe("settings");
});
it("each screen has a unique shortcut", () => {
const shortcuts = SCREENS.map((s) => s.shortcut);
const uniqueShortcuts = new Set(shortcuts);
expect(uniqueShortcuts.size).toBe(5);
});
it("shortcuts are 1-5 in order", () => {
expect(SCREENS[0].shortcut).toBe("1");
expect(SCREENS[1].shortcut).toBe("2");
expect(SCREENS[2].shortcut).toBe("3");
expect(SCREENS[3].shortcut).toBe("4");
expect(SCREENS[4].shortcut).toBe("5");
});
it("each screen has a label", () => {
SCREENS.forEach((screen) => {
expect(screen.label).toBeTruthy();
expect(typeof screen.label).toBe("string");
});
});
});
describe("ScreenRouter", () => {
describe("rendering", () => {
it("renders without crashing", async () => {
const { unmount } = renderTest(
<ScreenRouter>
{({ activeScreen }) => (
<Box>
<Text>Active: {activeScreen}</Text>
</Box>
)}
</ScreenRouter>
);
// Wait for render
await new Promise((resolve) => setTimeout(resolve, 50));
expect(() => unmount()).not.toThrow();
});
it("renders all five tab markers with shortcut numbers", async () => {
const { unmount } = renderTest(
<ScreenRouter>
{({ activeScreen }) => (
<Box>
<Text data-testid="active">{activeScreen}</Text>
</Box>
)}
</ScreenRouter>
);
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify tab markers are rendered (1-5)
// The ScreenRouter renders "1. Board", "2. Detail", etc.
// We can verify the component renders correctly by checking the unmount doesn't throw
expect(() => unmount()).not.toThrow();
});
it("passes activeScreen prop to children function", async () => {
let capturedActiveScreen: ScreenId | undefined;
const { unmount } = renderTest(
<ScreenRouter>
{({ activeScreen }) => {
capturedActiveScreen = activeScreen;
return (
<Box>
<Text>Screen: {activeScreen}</Text>
</Box>
);
}}
</ScreenRouter>
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(capturedActiveScreen).toBe("board");
unmount();
});
it("renders screen content below tab bar", async () => {
const { unmount } = renderTest(
<ScreenRouter>
{({ activeScreen }) => (
<Box>
<Text data-testid="screen-content">Content for {activeScreen}</Text>
</Box>
)}
</ScreenRouter>
);
await new Promise((resolve) => setTimeout(resolve, 50));
// The content should be rendered - we verify by successful unmount
expect(() => unmount()).not.toThrow();
});
});
describe("active screen tracking", () => {
it("defaults to board screen", async () => {
let activeScreen: ScreenId = "detail"; // Start with non-default
const { unmount } = renderTest(
<ScreenRouter>
{({ activeScreen: screen }) => {
activeScreen = screen;
return (
<Box>
<Text>{screen}</Text>
</Box>
);
}}
</ScreenRouter>
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(activeScreen).toBe("board");
unmount();
});
it("provides deterministic active marker for test assertions", async () => {
// Test that we can reliably detect the active tab
let activeTabId: ScreenId = "board";
const TestApp = () => {
const [, setCount] = useState(0);
return (
<ScreenRouter>
{({ activeScreen }) => {
activeTabId = activeScreen;
return (
<Box>
<Text>{activeScreen}</Text>
<Text onPress={() => setCount(c => c + 1)}>Update</Text>
</Box>
);
}}
</ScreenRouter>
);
};
const { unmount } = renderTest(<TestApp />);
await new Promise((resolve) => setTimeout(resolve, 50));
// Active screen is board
expect(activeTabId).toBe("board");
unmount();
});
});
});

View File

@@ -1,26 +0,0 @@
/**
* @fusion/tui components
*
* Reusable UI components for the Fusion TUI.
*/
export {
ScreenRouter,
SCREENS,
getScreenById,
getScreenIndex,
type ScreenId,
type Screen,
type ScreenRouterProps,
type ScreenComponentProps,
} from "./screen-router.js";
export {
ResponsiveHeader,
ResponsiveTable,
ResponsiveTaskRow,
ResponsiveStatusBar,
type TableColumn,
type ResponsiveTableProps,
type ResponsiveTaskRowProps,
} from "./responsive-layout.js";

View File

@@ -1,205 +0,0 @@
/**
* Responsive layout components for TUI.
*
* Provides components that adapt to terminal dimensions using the
* terminal dimension and truncation utilities.
*/
import React from "react";
import { Box, Text } from "ink";
import { useTerminalDimensions, computeColumnLayout } from "../utils/terminal.js";
import { truncateText } from "../utils/truncate.js";
/**
* ResponsiveHeader - A header that adapts to terminal width.
*
* At minimum width (80 columns), shows compact header.
* At wider widths, shows additional context information.
*/
export function ResponsiveHeader({ title }: { title: string }): React.ReactNode {
const { columns, isMinimumSize } = useTerminalDimensions();
return (
<Box flexDirection="column" paddingBottom={1}>
<Box>
<Text bold>{title}</Text>
{!isMinimumSize && columns >= 100 && (
<Text dimColor> — Extended view</Text>
)}
</Box>
{!isMinimumSize && (
<Text dimColor>Width: {columns} columns</Text>
)}
</Box>
);
}
/**
* Column configuration for responsive tables.
*/
export interface TableColumn {
/** Column header text */
header: string;
/** Minimum width */
minWidth: number;
/** Preferred width for content-heavy columns */
preferredWidth?: number;
/** Whether column can grow */
canGrow?: boolean;
/** Growth weight relative to other growable columns */
growWeight?: number;
}
/**
* ResponsiveTable - A table component that adapts to terminal width.
*
* Computes column widths based on available terminal columns and
* applies truncation to cell content that exceeds column width.
*/
export interface ResponsiveTableProps {
/** Column definitions */
columns: TableColumn[];
/** Row data as arrays of strings */
rows: string[][];
/** Gap between columns */
gap?: number;
}
/**
* Calculate column widths for the table based on terminal dimensions.
*/
function calculateTableColumnWidths(
terminalColumns: number,
columns: TableColumn[],
gap: number
): number[] {
const availableForColumns = terminalColumns - (columns.length - 1) * gap;
const layout = computeColumnLayout(
availableForColumns,
columns.map((col) => ({
minWidth: col.minWidth,
preferredWidth: col.preferredWidth,
canGrow: col.canGrow,
growWeight: col.growWeight ?? 1,
})),
"proportional"
);
return layout.widths;
}
export function ResponsiveTable({
columns,
rows,
gap = 2,
}: ResponsiveTableProps): React.ReactNode {
const { columns: terminalColumns } = useTerminalDimensions();
const columnWidths = calculateTableColumnWidths(terminalColumns, columns, gap);
return (
<Box flexDirection="column">
{/* Header row */}
<Box flexDirection="row">
{columns.map((col, i) => (
<Box key={col.header} width={columnWidths[i]} marginRight={i < columns.length - 1 ? gap : 0}>
<Text bold underline>{col.header}</Text>
</Box>
))}
</Box>
{/* Divider */}
<Box flexDirection="row">
{columns.map((col, i) => (
<Box key={`div-${col.header}`} width={columnWidths[i]} marginRight={i < columns.length - 1 ? gap : 0}>
<Text dimColor>{"─".repeat(Math.min(col.minWidth, 20))}</Text>
</Box>
))}
</Box>
{/* Data rows */}
{rows.map((row, rowIndex) => (
<Box key={`row-${rowIndex}`} flexDirection="row">
{row.map((cell, cellIndex) => {
const width = columnWidths[cellIndex];
const truncatedCell = truncateText(cell, width);
return (
<Box key={`cell-${rowIndex}-${cellIndex}`} width={width} marginRight={cellIndex < row.length - 1 ? gap : 0}>
<Text>{truncatedCell}</Text>
</Box>
);
})}
</Box>
))}
</Box>
);
}
/**
* ResponsiveTaskRow - A single task row that truncates content.
*
* Displays task ID, description (truncated), and status with
* ellipsis for overflow content.
*/
export interface ResponsiveTaskRowProps {
/** Task ID */
id: string;
/** Task description */
description: string;
/** Task status */
status: string;
/** Minimum ID column width */
idWidth?: number;
/** Minimum status column width */
statusWidth?: number;
}
export function ResponsiveTaskRow({
id,
description,
status,
idWidth = 10,
statusWidth = 12,
}: ResponsiveTaskRowProps): React.ReactNode {
const { columns } = useTerminalDimensions();
// Calculate available width for description
const reservedWidth = idWidth + statusWidth + 4; // 4 for gaps
const descriptionWidth = Math.max(20, columns - reservedWidth);
const truncatedDescription = truncateText(description, descriptionWidth);
const truncatedStatus = truncateText(status, statusWidth);
return (
<Box flexDirection="row">
<Box width={idWidth}>
<Text bold>{id}</Text>
</Box>
<Box width={descriptionWidth} marginLeft={2}>
<Text>{truncatedDescription}</Text>
</Box>
<Box width={statusWidth} marginLeft={2}>
<Text dimColor>{truncatedStatus}</Text>
</Box>
</Box>
);
}
/**
* ResponsiveStatusBar - A status bar showing terminal dimensions.
*
* Useful for debugging responsive layout issues.
*/
export function ResponsiveStatusBar(): React.ReactNode {
const { columns, rows, isMinimumSize } = useTerminalDimensions();
return (
<Box borderStyle="single" borderTop={true} borderLeft={false} borderRight={false} borderBottom={false} marginTop={1}>
<Text dimColor>
Terminal: {columns}×{rows}
{isMinimumSize && " (minimum)"}
{" | "}
Minimum: 80×24
</Text>
</Box>
);
}

View File

@@ -1,188 +0,0 @@
/**
* ScreenRouter - Keyboard-navigable tab bar for switching between app screens.
*
* Provides a tabbed interface with:
* - Five ordered screens: Board, Detail, Activity, Agents, Settings
* - Number keys (1-5) for direct tab selection
* - Tab/Shift+Tab for cycling with wrap-around
* - Visual tab bar with active indicator
*/
import React, { useState, useCallback } from "react";
import { Box, Text, useInput } from "ink";
/**
* Available screen identifiers.
*/
export type ScreenId = "board" | "detail" | "activity" | "agents" | "settings";
/**
* Screen definition with metadata for rendering and keyboard shortcuts.
*/
export interface Screen {
id: ScreenId;
label: string;
shortcut: string;
}
/**
* Ordered list of all available screens.
*/
export const SCREENS: Screen[] = [
{ id: "board", label: "Board", shortcut: "1" },
{ id: "detail", label: "Detail", shortcut: "2" },
{ id: "activity", label: "Activity", shortcut: "3" },
{ id: "agents", label: "Agents", shortcut: "4" },
{ id: "settings", label: "Settings", shortcut: "5" },
] as const;
/**
* Props for individual screen components.
*/
export interface ScreenComponentProps {
/** The active screen ID (for conditional rendering) */
activeScreen: ScreenId;
}
/**
* Props for the ScreenRouter component.
*/
export interface ScreenRouterProps {
/**
* Initial screen to display on mount.
* @default "board"
*/
initialScreen?: ScreenId;
/**
* Callback invoked when the user navigates to a different screen.
* Use this to sync with external state (e.g., global shortcuts).
*/
onScreenChange?: (screenId: ScreenId) => void;
/**
* Externally controlled active screen.
* When provided, the router uses this instead of internal state.
*/
activeScreen?: ScreenId;
/**
* Render function for each screen.
* Receives the screen ID and should return the screen component.
*/
children: (props: ScreenComponentProps) => React.ReactNode;
}
/**
* ScreenRouter provides keyboard-navigable tab switching with visual tab bar.
*
* Features:
* - Tab bar displays all screens with active indicator
* - Number keys 1-5 jump directly to corresponding tab
* - Tab/Shift+Tab cycle forward/backward with wrap-around
* - Active screen component renders below the tab bar
*
* @example
* ```tsx
* <ScreenRouter>
* {({ activeScreen }) => (
* <>
* {activeScreen === "board" && <BoardScreen />}
* {activeScreen === "detail" && <DetailScreen />}
* {activeScreen === "activity" && <ActivityScreen />}
* {activeScreen === "agents" && <AgentsScreen />}
* {activeScreen === "settings" && <SettingsScreen />}
* </>
* )}
* </ScreenRouter>
* ```
*/
export function ScreenRouter({ children, initialScreen = "board", onScreenChange, activeScreen: externalActiveScreen }: ScreenRouterProps): React.ReactNode {
// Use external state if provided, otherwise use internal state
const [internalActiveScreen, setInternalActiveScreen] = useState<ScreenId>(initialScreen);
const activeScreen = externalActiveScreen ?? internalActiveScreen;
// Navigate to a specific screen by index
const navigateToIndex = useCallback((index: number) => {
const normalizedIndex = ((index % SCREENS.length) + SCREENS.length) % SCREENS.length;
const newScreen = SCREENS[normalizedIndex].id;
// Only update internal state if not externally controlled
if (externalActiveScreen === undefined) {
setInternalActiveScreen(newScreen);
}
onScreenChange?.(newScreen);
}, [externalActiveScreen, onScreenChange]);
// Handle keyboard input
useInput((input, key) => {
// Number keys 1-5 for direct selection
const num = parseInt(input, 10);
if (num >= 1 && num <= SCREENS.length) {
const newScreen = SCREENS[num - 1].id;
if (externalActiveScreen === undefined) {
setInternalActiveScreen(newScreen);
}
onScreenChange?.(newScreen);
return;
}
// Tab cycles forward with wrap-around
if (key.tab) {
if (key.shift) {
// Shift+Tab: go backward
const currentIndex = SCREENS.findIndex((s) => s.id === activeScreen);
navigateToIndex(currentIndex - 1);
} else {
// Tab: go forward
const currentIndex = SCREENS.findIndex((s) => s.id === activeScreen);
navigateToIndex(currentIndex + 1);
}
}
});
return (
<Box flexDirection="column">
{/* Tab Bar */}
<Box flexDirection="row" flexWrap="wrap" gap={0}>
{SCREENS.map((screen, index) => {
const isActive = screen.id === activeScreen;
const shortcutNum = index + 1;
return (
<Box key={screen.id} paddingX={1}>
<Text
bold={isActive}
backgroundColor={isActive ? "cyan" : undefined}
color={isActive ? "black" : "white"}
data-testid={`tab-${screen.id}`}
>
{isActive ? "▶ " : " "}
{shortcutNum}. {screen.label}
</Text>
</Box>
);
})}
</Box>
{/* Divider */}
<Box borderStyle="single" borderTop={false} borderLeft={false} borderRight={false} borderBottom={true}>
<Text />
</Box>
{/* Active Screen */}
<Box flexDirection="column" flexGrow={1}>
{children({ activeScreen })}
</Box>
</Box>
);
}
/**
* Get the screen definition by ID.
*/
export function getScreenById(id: ScreenId): Screen | undefined {
return SCREENS.find((s) => s.id === id);
}
/**
* Get the screen index by ID.
*/
export function getScreenIndex(id: ScreenId): number {
return SCREENS.findIndex((s) => s.id === id);
}

View File

@@ -1,203 +0,0 @@
/**
* FusionContext - React context provider for Fusion TaskStore access in TUI.
*
* Provides a centralized way to initialize and access the TaskStore
* across the TUI application, with automatic project detection and
* clean lifecycle management.
*/
import React, { createContext, useContext, useState, useEffect } from "react";
import { Text } from "ink";
import { TaskStore } from "@fusion/core";
import { detectProjectDir } from "./project-detect.js";
/**
* The shape of the value provided by FusionContext.
*/
export interface FusionContextValue {
/** The initialized TaskStore instance */
store: TaskStore;
/** Absolute path to the project directory */
projectPath: string;
}
/**
* React context for Fusion TaskStore access.
* Use `useFusion()` hook to access the context value.
*/
export const FusionContext = createContext<FusionContextValue | null>(null);
/**
* Props for the FusionProvider component.
*/
export interface FusionProviderProps {
/**
* Explicit project directory override.
* When provided, skips auto-detection and uses this path directly.
* Useful for `--project` flag support in future CLI integration.
*/
projectDir?: string;
/** Child components that will have access to the Fusion context */
children: React.ReactNode;
}
/**
* Internal state shape for the provider's state management.
*/
interface ProviderState {
store: TaskStore | null;
projectPath: string;
error: string | null;
ready: boolean;
}
/**
* FusionProvider initializes a TaskStore and provides it via React context.
*
* On mount, it either uses an explicit `projectDir` prop or auto-detects
* the project by walking up from the current working directory.
*
* - If no project is found, renders a red error message
* - If a project is found, initializes the TaskStore and provides it
* - On unmount, closes the SQLite connection
*
* @example
* ```tsx
* import { FusionProvider, useFusion } from "./fusion-context";
*
* function MyApp() {
* return (
* <FusionProvider>
* <TaskList />
* </FusionProvider>
* );
* }
*
* function TaskList() {
* const { store, projectPath } = useFusion();
* // Use store to interact with tasks...
* }
* ```
*/
export function FusionProvider({ projectDir, children }: FusionProviderProps): React.ReactNode {
const [state, setState] = useState<ProviderState>({
store: null,
projectPath: "",
error: null,
ready: false,
});
useEffect(() => {
let store: TaskStore | null = null;
let cancelled = false;
async function initialize() {
// Determine project directory
const detectedPath = projectDir ?? detectProjectDir();
if (!detectedPath) {
setState({
store: null,
projectPath: "",
error:
"No Fusion project found in current directory. Run 'fn init' to initialize one, or navigate to a project directory.",
ready: true,
});
return;
}
if (cancelled) return;
// Create and initialize the TaskStore
store = new TaskStore(detectedPath);
try {
await store.init();
} catch (err) {
if (cancelled) return;
setState({
store: null,
projectPath: detectedPath,
error: `Failed to initialize TaskStore: ${err instanceof Error ? err.message : String(err)}`,
ready: true,
});
return;
}
if (cancelled) {
// Clean up if we were cancelled after init
await store.close();
return;
}
setState({
store,
projectPath: detectedPath,
error: null,
ready: true,
});
}
initialize();
// Cleanup function: close the store on unmount
return () => {
cancelled = true;
if (store) {
store.close();
}
};
}, [projectDir]);
// Render null during initialization
if (!state.ready) {
return null;
}
// Render error message if initialization failed
if (state.error) {
return <Text color="red">{state.error}</Text>;
}
// Render the provider with context value
return (
<FusionContext.Provider value={{ store: state.store!, projectPath: state.projectPath }}>
{children}
</FusionContext.Provider>
);
}
/**
* Hook to access the Fusion context.
*
* @throws Error if used outside of a FusionProvider
* @returns The FusionContextValue containing the TaskStore and project path
*
* @example
* ```tsx
* function TaskList() {
* const { store, projectPath } = useFusion();
* const [tasks, setTasks] = useState<Task[]>([]);
*
* useEffect(() => {
* store.listTasks().then(setTasks);
* }, [store]);
*
* return (
* <Box>
* <Text>Project: {projectPath}</Text>
* {tasks.map(task => (
* <Text key={task.id}>{task.id}: {task.description}</Text>
* ))}
* </Box>
* );
* }
* ```
*/
export function useFusion(): FusionContextValue {
const ctx = useContext(FusionContext);
if (!ctx) {
throw new Error("useFusion must be used within a <FusionProvider>");
}
return ctx;
}

View File

@@ -1,11 +0,0 @@
/**
* Hooks for subscribing to TaskStore events in React components.
*/
export { useTasks } from "./use-tasks.js";
export type { UseTasksResult } from "./use-tasks.js";
export { useActivityLog } from "./use-activity-log.js";
export type { UseActivityLogOptions, UseActivityLogResult } from "./use-activity-log.js";
export { useGlobalShortcuts, HelpOverlay, type UseGlobalShortcutsOptions, type UseGlobalShortcutsResult, type HelpOverlayProps } from "./use-global-shortcuts.jsx";

View File

@@ -1,140 +0,0 @@
/**
* useActivityLog - React hook for subscribing to activity log events and maintaining live entries.
*
* This hook bridges the gap between Node.js EventEmitter and React's state model,
* enabling TUI components to display live activity data without polling.
*/
import { useState, useEffect, useCallback } from "react";
import type { ActivityLogEntry, ActivityEventType, AgentLogEntry } from "@fusion/core";
import { useFusion } from "../fusion-context.js";
/**
* Options for the useActivityLog hook.
*/
export interface UseActivityLogOptions {
/** Maximum number of entries to keep (oldest trimmed from tail) */
limit?: number;
/** Filter to only show entries of this type */
type?: ActivityEventType;
}
/**
* Return type for the useActivityLog hook.
*/
export interface UseActivityLogResult {
/** Current list of activity log entries (most recent first) */
entries: ActivityLogEntry[];
/** Whether initial data fetch is in progress */
loading: boolean;
/** Error from initial fetch, or null if successful */
error: Error | null;
/** Manual refresh function to re-fetch entries from the store */
refresh: () => Promise<void>;
}
/**
* Hook that subscribes to activity log events and maintains a live list of entries.
*
* - On mount, fetches initial entries from `store.getActivityLog()`
* - Subscribes to TaskStore's `agent:log` event
* - Prepends new entries to the list (most recent first)
* - Respects the `limit` option by trimming from the tail
* - If `type` filter is set, only includes entries matching that type
* - Cleans up event listeners on unmount
*
* @param options - Optional configuration: `{ limit?: number; type?: ActivityEventType }`
* @returns { entries: ActivityLogEntry[], loading: boolean, error: Error | null, refresh: () => Promise<void> }
*
* @example
* ```tsx
* function ActivityFeed() {
* const { entries, loading, error, refresh } = useActivityLog({ limit: 50 });
*
* if (loading) return <Text>Loading activity...</Text>;
* if (error) return <Text color="red">{error.message}</Text>;
*
* return (
* <Box flexDirection="column">
* {entries.map(entry => (
* <Text key={entry.id}>{entry.type}: {entry.details}</Text>
* ))}
* </Box>
* );
* }
* ```
*/
export function useActivityLog(options?: UseActivityLogOptions): UseActivityLogResult {
const { store } = useFusion();
const { limit = 100, type } = options ?? {};
const [entries, setEntries] = useState<ActivityLogEntry[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
// Fetch initial entries
const fetchEntries = useCallback(async () => {
try {
const initialEntries = await store.getActivityLog({ limit, type });
setEntries(initialEntries);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
setEntries([]);
} finally {
setLoading(false);
}
}, [store, limit, type]);
useEffect(() => {
let cancelled = false;
// Fetch initial entries
fetchEntries().then(() => {
if (cancelled) return;
});
// Handler for agent:log events - convert AgentLogEntry to ActivityLogEntry format
const handleAgentLog = (entry: AgentLogEntry) => {
// Convert AgentLogEntry to ActivityLogEntry for display
const activityEntry: ActivityLogEntry = {
id: `agent-${entry.taskId}-${entry.timestamp}`,
taskId: entry.taskId,
timestamp: entry.timestamp,
type: "task:updated" as ActivityEventType, // agent:log doesn't map directly to activity types
details: entry.detail ?? entry.text,
};
// If type filter is set, only include matching entries
if (type && activityEntry.type !== type) {
return;
}
setEntries((prev) => {
const newEntries = [activityEntry, ...prev];
// Trim to limit
if (limit && newEntries.length > limit) {
return newEntries.slice(0, limit);
}
return newEntries;
});
};
// Subscribe to event
store.on("agent:log", handleAgentLog);
// Cleanup function
return () => {
cancelled = true;
store.off("agent:log", handleAgentLog);
};
}, [store, limit, type, fetchEntries]);
// Manual refresh function
const refresh = useCallback(async () => {
setLoading(true);
await fetchEntries();
}, [fetchEntries]);
return { entries, loading, error, refresh };
}

View File

@@ -1,228 +0,0 @@
/**
* useGlobalShortcuts - Centralized keyboard shortcut handler for the TUI app.
*
* Handles global shortcuts in one place with proper focus-guard logic:
* - Ctrl+C always exits cleanly
* - q exits when no text input is focused
* - ?/h toggles the help overlay
* - 1-5 switch screens via callback
*
* This hook should be used at the top app/screen-router level so all screens
* share consistent behavior without scattering duplicate handlers.
*
* Focus Guard: Use the shared FocusGuardRef to track text input focus state.
* Import FocusGuardRef from this module and set FocusGuardRef.isFocused = true/false
* in text input onFocus/onBlur handlers.
*/
import React, { useState, useCallback, useEffect } from "react";
import { useInput, useApp } from "ink";
import { SCREENS, type ScreenId } from "../components/screen-router.js";
/**
* Shared ref for tracking text input focus state globally.
* Set FocusGuardRef.isFocused = true when a text input gains focus,
* and FocusGuardRef.isFocused = false when it loses focus.
*
* This is a simple module-level ref that any component can import and modify.
*
* @example
* ```tsx
* import { FocusGuardRef } from "./use-global-shortcuts";
*
* function MyTextInput() {
* return (
* <Input
* onFocus={() => { FocusGuardRef.isFocused = true; }}
* onBlur={() => { FocusGuardRef.isFocused = false; }}
* />
* );
* }
* ```
*/
export const FocusGuardRef = {
isFocused: false,
};
/**
* Props for the useGlobalShortcuts hook.
*/
export interface UseGlobalShortcutsOptions {
/**
* Callback invoked when the user presses a number key (1-5) to switch screens.
* Receives the screen ID to switch to.
*/
onScreenChange?: (screenId: ScreenId) => void;
}
/**
* Return value from the useGlobalShortcuts hook.
*/
export interface UseGlobalShortcutsResult {
/** Whether the help overlay is currently visible */
helpVisible: boolean;
/** Manually toggle the help overlay visibility */
toggleHelp: () => void;
/** Hide the help overlay */
hideHelp: () => void;
}
/**
* Hook that handles global keyboard shortcuts for the TUI.
*
* This hook should be placed at the app root level (above the ScreenRouter) to ensure
* all screens receive consistent shortcut handling. It centralizes all global shortcuts
* to prevent conflicts and duplication.
*
* Focus guard behavior:
* - Ctrl+C always exits (emergency exit)
* - q exits only when no text input is focused (via FocusGuardRef)
* - ?/h toggles help only when no text input is focused
* - Number keys (1-5) for screen switching are handled by the ScreenRouter internally
*
* @param options - Configuration options
* @param options.onScreenChange - Optional callback for screen changes triggered by number keys
*
* @example
* ```tsx
* function App() {
* const { helpVisible, toggleHelp } = useGlobalShortcuts();
*
* return (
* <>
* {helpVisible && <HelpOverlay onClose={toggleHelp} />}
* <ScreenRouter>
* {({ activeScreen }) => (
* // Screen content...
* )}
* </ScreenRouter>
* </>
* );
* }
* ```
*/
export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}): UseGlobalShortcutsResult {
const { onScreenChange } = options;
const { exit } = useApp();
const [helpVisible, setHelpVisible] = useState(false);
// Toggle help overlay
const toggleHelp = useCallback(() => {
setHelpVisible((prev) => !prev);
}, []);
// Hide help overlay
const hideHelp = useCallback(() => {
setHelpVisible(false);
}, []);
// Handle keyboard input
useInput(
(input, key) => {
// Ctrl+C always exits cleanly (emergency exit)
if (key.ctrl && input.toLowerCase() === "c") {
exit();
return;
}
// q - exit only when no text input is focused
if (input.toLowerCase() === "q" && !FocusGuardRef.isFocused) {
exit();
return;
}
// ? or h - toggle help overlay (only when not focused)
if (!FocusGuardRef.isFocused) {
if (input === "?" || input.toLowerCase() === "h") {
toggleHelp();
return;
}
// 1-5 - screen switching via callback
const num = parseInt(input, 10);
if (num >= 1 && num <= SCREENS.length) {
const screenId = SCREENS[num - 1].id;
onScreenChange?.(screenId);
return;
}
}
},
{ isActive: true } // Always active to catch global shortcuts
);
// Cleanup: hide help on unmount
useEffect(() => {
return () => {
setHelpVisible(false);
};
}, []);
return {
helpVisible,
toggleHelp,
hideHelp,
};
}
/**
* Props for the HelpOverlay component.
*/
export interface HelpOverlayProps {
/** Callback to close the help overlay */
onClose: () => void;
}
/**
* HelpOverlay component that displays keyboard shortcuts.
*
* @param props.onClose - Callback to close the overlay
*
* @example
* ```tsx
* <HelpOverlay onClose={() => setHelpVisible(false)} />
* ```
*/
export function HelpOverlay({ onClose }: HelpOverlayProps): React.ReactNode {
// Handle Escape and q to close
useInput((input, key) => {
if (key.escape || input.toLowerCase() === "q") {
onClose();
}
});
const shortcuts = [
{ key: "Ctrl+C", description: "Quit (emergency exit)" },
{ key: "q", description: "Quit (when no text input is focused)" },
{ key: "?", description: "Toggle this help overlay" },
{ key: "h", description: "Toggle this help overlay (alternate)" },
{ key: "1-5", description: "Switch screens" },
{ key: "Tab", description: "Cycle forward through tabs" },
{ key: "Shift+Tab", description: "Cycle backward through tabs" },
];
return (
<Box
flexDirection="column"
padding={1}
borderStyle="round"
borderColor="cyan"
backgroundColor="black"
>
<Text bold color="cyan">
Keyboard Shortcuts
</Text>
<Text dimColor>────────────────</Text>
{shortcuts.map((shortcut) => (
<Text key={shortcut.key}>
<Text bold color="white">{shortcut.key.padEnd(12)}</Text>
<Text dimColor>{shortcut.description}</Text>
</Text>
))}
<Text dimColor>────────────────</Text>
<Text dimColor italic>Press Esc or q to close</Text>
</Box>
);
}
// Re-export Box and Text from ink for use in HelpOverlay
import { Box, Text } from "ink";

View File

@@ -1,131 +0,0 @@
/**
* useTasks - React hook for subscribing to TaskStore events and maintaining live task list.
*
* This hook bridges the gap between Node.js EventEmitter and React's state model,
* enabling TUI components to display live task data without polling.
*/
import { useState, useEffect } from "react";
import type { Task, Column } from "@fusion/core";
import { useFusion } from "../fusion-context.js";
/**
* Return type for the useTasks hook.
*/
export interface UseTasksResult {
/** Current list of tasks */
tasks: Task[];
/** Whether initial data fetch is in progress */
loading: boolean;
/** Error from initial fetch, or null if successful */
error: Error | null;
}
/**
* Hook that subscribes to TaskStore events and maintains a live list of tasks.
*
* - On mount, fetches initial task list from `store.listTasks()`
* - Subscribes to TaskStore events: `task:created`, `task:moved`, `task:updated`, `task:deleted`, `task:merged`
* - Updates state reactively when events fire, using functional updates to avoid stale closures
* - Cleans up event listeners on unmount
*
* @returns { tasks: Task[], loading: boolean, error: Error | null }
*
* @example
* ```tsx
* function TaskList() {
* const { tasks, loading, error } = useTasks();
*
* if (loading) return <Text>Loading tasks...</Text>;
* if (error) return <Text color="red">{error.message}</Text>;
*
* return (
* <Box flexDirection="column">
* {tasks.map(task => (
* <Text key={task.id}>{task.id}: {task.description}</Text>
* ))}
* </Box>
* );
* }
* ```
*/
export function useTasks(): UseTasksResult {
const { store } = useFusion();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
// Fetch initial task list
store
.listTasks()
.then((initialTasks) => {
if (cancelled) return;
setTasks(initialTasks);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err : new Error(String(err)));
setTasks([]);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
// Event handlers for TaskStore events
const handleTaskCreated = (task: Task) => {
setTasks((prev) => {
// Avoid duplicates
if (prev.some((t) => t.id === task.id)) {
return prev;
}
return [...prev, task];
});
};
const handleTaskMoved = (data: { task: Task; from: Column; to: Column }) => {
setTasks((prev) =>
prev.map((t) => (t.id === data.task.id ? { ...t, column: data.to, columnMovedAt: new Date().toISOString() } : t))
);
};
const handleTaskUpdated = (task: Task) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
};
const handleTaskDeleted = (task: Task) => {
setTasks((prev) => prev.filter((t) => t.id !== task.id));
};
const handleTaskMerged = (result: { task: Task }) => {
setTasks((prev) =>
prev.map((t) => (t.id === result.task.id ? { ...t, column: "done" as Column } : t))
);
};
// Subscribe to events
store.on("task:created", handleTaskCreated);
store.on("task:moved", handleTaskMoved);
store.on("task:updated", handleTaskUpdated);
store.on("task:deleted", handleTaskDeleted);
store.on("task:merged", handleTaskMerged);
// Cleanup function
return () => {
cancelled = true;
store.off("task:created", handleTaskCreated);
store.off("task:moved", handleTaskMoved);
store.off("task:updated", handleTaskUpdated);
store.off("task:deleted", handleTaskDeleted);
store.off("task:merged", handleTaskMerged);
};
}, [store]);
return { tasks, loading, error };
}

View File

@@ -1,157 +0,0 @@
/**
* @fusion/tui — Terminal UI components for fn
*
* This package provides Ink-based React components for building terminal
* user interfaces that interact with Fusion task management.
*/
// Re-export FusionContext components and hooks
export { FusionProvider, useFusion, FusionContext } from "./fusion-context.js";
export type { FusionContextValue, FusionProviderProps } from "./fusion-context.js";
// Re-export project detection utility
export { detectProjectDir } from "./project-detect.js";
// Re-export components
export {
ScreenRouter,
SCREENS,
getScreenById,
getScreenIndex,
type ScreenId,
type Screen,
type ScreenRouterProps,
type ScreenComponentProps,
} from "./components/screen-router.js";
export {
ResponsiveHeader,
ResponsiveTable,
ResponsiveTaskRow,
ResponsiveStatusBar,
type TableColumn,
type ResponsiveTableProps,
type ResponsiveTaskRowProps,
} from "./components/responsive-layout.js";
// Re-export global shortcuts hooks
export {
useGlobalShortcuts,
HelpOverlay,
FocusGuardRef,
type UseGlobalShortcutsOptions,
type UseGlobalShortcutsResult,
type HelpOverlayProps,
} from "./hooks/use-global-shortcuts.js";
import React, { useState } from "react";
import { render, Box, Text } from "ink";
import { FusionProvider, useFusion } from "./fusion-context.js";
import { ScreenRouter, type ScreenId } from "./components/screen-router.js";
import { useGlobalShortcuts, HelpOverlay } from "./hooks/use-global-shortcuts.js";
import { ResponsiveHeader, ResponsiveTable, ResponsiveStatusBar } from "./components/responsive-layout.js";
import { fileURLToPath } from "url";
/**
* Demo application showing FusionProvider + ScreenRouter usage.
* Renders the screen router with placeholder screens for each tab.
* This demo only runs when the file is executed directly (not when imported).
*/
function DemoApp() {
const { projectPath } = useFusion();
const [activeScreen, setActiveScreen] = useState<ScreenId>("board");
// Global keyboard shortcuts - handles Ctrl+C, q, ?/h, 1-5
const { helpVisible, toggleHelp } = useGlobalShortcuts({
onScreenChange: setActiveScreen,
});
return (
<Box flexDirection="column" flexGrow={1}>
{/* Help Overlay - shown when toggled, displayed at top */}
{helpVisible && (
<Box marginBottom={1}>
<HelpOverlay onClose={toggleHelp} />
</Box>
)}
{/* Responsive Header */}
<ResponsiveHeader title={`Fusion TUI | Project: ${projectPath}`} />
{/* Screen Router */}
<ScreenRouter
activeScreen={activeScreen}
onScreenChange={setActiveScreen}
>
{({ activeScreen }) => (
<Box flexDirection="column" flexGrow={1}>
{activeScreen === "board" && (
<Box flexDirection="column" paddingY={1}>
<Text bold>Board Screen</Text>
<Text dimColor>View and manage tasks on the kanban board</Text>
{/* Demo: Responsive Task Table */}
<Box marginTop={1}>
<ResponsiveTable
columns={[
{ header: "ID", minWidth: 10 },
{ header: "Description", minWidth: 30, canGrow: true, preferredWidth: 60 },
{ header: "Status", minWidth: 12 },
{ header: "Size", minWidth: 6 },
]}
rows={[
["FN-001", "Implement user authentication with OAuth 2.0 integration", "todo", "M"],
["FN-002", "Fix memory leak in data processing pipeline caused by missing cleanup handlers", "in-progress", "L"],
["FN-003", "Update documentation", "done", "S"],
["FN-004", "Refactor API endpoints to use REST conventions and add proper error handling with retry logic", "review", "M"],
]}
/>
</Box>
</Box>
)}
{activeScreen === "detail" && (
<Box flexDirection="column" paddingY={1}>
<Text bold>Detail Screen</Text>
<Text dimColor>View and edit individual task details</Text>
</Box>
)}
{activeScreen === "activity" && (
<Box flexDirection="column" paddingY={1}>
<Text bold>Activity Screen</Text>
<Text dimColor>View recent activity and events</Text>
</Box>
)}
{activeScreen === "agents" && (
<Box flexDirection="column" paddingY={1}>
<Text bold>Agents Screen</Text>
<Text dimColor>Manage AI agents and their configurations</Text>
</Box>
)}
{activeScreen === "settings" && (
<Box flexDirection="column" paddingY={1}>
<Text bold>Settings Screen</Text>
<Text dimColor>Configure project settings and preferences</Text>
</Box>
)}
</Box>
)}
</ScreenRouter>
{/* Responsive Status Bar */}
<ResponsiveStatusBar />
</Box>
);
}
// Guard: only render if this file is being executed directly (not imported)
const currentFile = fileURLToPath(import.meta.url);
const isMainModule = process.argv[1] !== undefined && currentFile === process.argv[1];
const isDevRun = process.argv[1]?.includes("index.tsx");
if (isMainModule || isDevRun) {
render(
<FusionProvider>
<DemoApp />
</FusionProvider>
);
}

View File

@@ -1,51 +0,0 @@
/**
* Project directory detection for the TUI package.
*
* Provides lightweight filesystem-based detection of Fusion projects
* by walking up the directory tree looking for `.fusion/fusion.db`.
* This mirrors the behavior used by the CLI but without depending on
* CentralCore or CLI modules.
*/
import { resolve, dirname } from "node:path";
import { existsSync } from "node:fs";
/**
* Detect the Fusion project root directory by walking up from a starting path.
*
* Walks up the directory tree starting from `startPath` (or `process.cwd()` by default)
* looking for `.fusion/fusion.db`. Returns the project root directory (parent of `.fusion/`)
* when found, or `null` if no project directory is detected up to the filesystem root.
*
* @param startPath - Starting directory for the search (defaults to process.cwd())
* @returns The absolute path to the project root, or null if not found
*
* @example
* // Find project from current directory
* const projectPath = detectProjectDir();
*
* // Find project from a specific directory
* const projectPath = detectProjectDir("/Users/me/code/my-project/src");
* // Returns "/Users/me/code/my-project" if .fusion/fusion.db exists there
*/
export function detectProjectDir(startPath?: string): string | null {
let currentDir = resolve(startPath ?? process.cwd());
while (true) {
// Check for Fusion database file
const dbPath = resolve(currentDir, ".fusion", "fusion.db");
if (existsSync(dbPath)) {
return currentDir;
}
// Move up to parent directory
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// Reached filesystem root, stop
break;
}
currentDir = parentDir;
}
return null;
}

View File

@@ -1,23 +0,0 @@
/**
* TUI Utility modules.
*/
export {
useTerminalDimensions,
computeColumnLayout,
type TerminalDimensions,
type ColumnLayout,
type ColumnDefinition,
type ColumnStrategy,
MIN_TERMINAL_COLUMNS,
MIN_TERMINAL_ROWS,
} from "./terminal.js";
export {
truncateText,
truncateWithOptions,
padText,
fitText,
DEFAULT_ELLIPSIS,
type TruncateOptions,
} from "./truncate.js";

View File

@@ -1,256 +0,0 @@
/**
* Terminal dimension utilities for responsive TUI layouts.
*
* Provides hooks and helpers for reading live terminal dimensions from Ink's
* useStdout() and computing deterministic column widths.
*/
import { useStdout } from "ink";
import { useMemo } from "react";
/**
* Minimum supported terminal dimensions.
* These values are used as lower bounds for layout calculations.
*/
export const MIN_TERMINAL_COLUMNS = 80;
export const MIN_TERMINAL_ROWS = 24;
/**
* Effective terminal dimensions with minimum bounds applied.
*/
export interface TerminalDimensions {
/** Effective column count (minimum 80) */
columns: number;
/** Effective row count (minimum 24) */
rows: number;
/** Whether the terminal meets minimum size requirements */
isMinimumSize: boolean;
/** Extra columns available beyond the minimum */
extraColumns: number;
}
/**
* useTerminalDimensions - Hook to read live terminal dimensions with minimum bounds.
*
* Uses Ink's useStdout() to get the actual terminal size, then applies minimum
* bounds of 80 columns and 24 rows for layout calculations. This ensures
* deterministic layout even in smaller terminals.
*
* The hook updates whenever the terminal is resized.
*
* @returns {TerminalDimensions} Effective terminal dimensions
*
* @example
* ```tsx
* function MyComponent() {
* const { columns, rows, isMinimumSize, extraColumns } = useTerminalDimensions();
*
* return (
* <Box>
* <Text>Terminal: {columns}x{rows}</Text>
* {!isMinimumSize && <Text dimColor> (narrow)</Text>}
* </Box>
* );
* }
* ```
*/
export function useTerminalDimensions(): TerminalDimensions {
const { stdout } = useStdout();
// Defensive: use default terminal dimensions if stdout is unavailable
const columns = stdout?.columns ?? MIN_TERMINAL_COLUMNS;
const rows = stdout?.rows ?? MIN_TERMINAL_ROWS;
return useMemo(() => {
const effectiveColumns = Math.max(columns, MIN_TERMINAL_COLUMNS);
const effectiveRows = Math.max(rows, MIN_TERMINAL_ROWS);
const extraColumns = Math.max(0, effectiveColumns - MIN_TERMINAL_COLUMNS);
const isMinimumSize = effectiveColumns <= MIN_TERMINAL_COLUMNS && effectiveRows <= MIN_TERMINAL_ROWS;
return {
columns: effectiveColumns,
rows: effectiveRows,
isMinimumSize,
extraColumns,
};
}, [columns, rows]);
}
/**
* Column layout configuration for responsive tables/lists.
*/
export interface ColumnLayout {
/** Width of each column */
widths: number[];
/** Total width used by all columns */
totalWidth: number;
/** Remaining columns after minimum allocations */
remainingColumns: number;
}
/**
* Column allocation strategy.
*/
export type ColumnStrategy = "equal" | "fixed" | "proportional" | "content-heavy";
/**
* Column definition for layout calculation.
*/
export interface ColumnDefinition {
/** Minimum width for this column */
minWidth: number;
/** Preferred/ideal width (optional) */
preferredWidth?: number;
/** Whether this column can grow to fill extra space */
canGrow?: boolean;
/** Growth weight relative to other growable columns */
growWeight?: number;
}
/**
* computeColumnLayout - Calculate column widths based on terminal dimensions.
*
* Produces deterministic column widths that:
* - Respect minimum column widths
* - Keep required columns readable at 80 columns
* - Share extra width with content-heavy columns
*
* @param columns - Available terminal columns
* @param definitions - Column definitions with minimum/preferred widths
* @param strategy - Allocation strategy for extra space
* @returns {ColumnLayout} Calculated column widths
*
* @example
* ```tsx
* const layout = computeColumnLayout(100, [
* { minWidth: 10, canGrow: false }, // ID column
* { minWidth: 40, canGrow: true, growWeight: 2 }, // Description (grows 2x)
* { minWidth: 10, canGrow: true, growWeight: 1 }, // Status (grows 1x)
* ], "proportional");
* // Returns widths array based on available space
* ```
*/
export function computeColumnLayout(
columns: number,
definitions: ColumnDefinition[],
strategy: ColumnStrategy = "proportional"
): ColumnLayout {
const definitionCount = definitions.length;
if (definitionCount === 0) {
return { widths: [], totalWidth: 0, remainingColumns: columns };
}
// Step 1: Calculate minimum total width
const minimumTotal = definitions.reduce((sum, def) => sum + def.minWidth, 0);
// Step 2: If at or below minimum, use minimum widths
if (columns <= minimumTotal) {
return {
widths: definitions.map((def) => def.minWidth),
totalWidth: minimumTotal,
remainingColumns: 0,
};
}
// Step 3: Distribute extra columns based on strategy
const extraColumns = columns - minimumTotal;
const growableColumns = definitions
.map((def, index) => ({ def, index, weight: def.growWeight ?? 1 }))
.filter(({ def }) => def.canGrow);
if (growableColumns.length === 0 || strategy === "fixed") {
// Fixed strategy: don't distribute extra space
return {
widths: definitions.map((def) => def.minWidth),
totalWidth: minimumTotal,
remainingColumns: extraColumns,
};
}
if (strategy === "equal") {
// Equal strategy: divide extra space evenly among growable columns
const extraPerGrowable = Math.floor(extraColumns / growableColumns.length);
const widths = definitions.map((def) => def.minWidth);
for (const { index } of growableColumns) {
widths[index] += extraPerGrowable;
}
return {
widths,
totalWidth: columns,
remainingColumns: extraColumns % growableColumns.length,
};
}
if (strategy === "proportional") {
// Proportional strategy: distribute based on grow weights
const totalWeight = growableColumns.reduce((sum, c) => sum + c.weight, 0);
const widths = definitions.map((def) => def.minWidth);
let distributed = 0;
// Distribute proportionally (all but last to avoid rounding errors)
for (let i = 0; i < growableColumns.length - 1; i++) {
const { index, weight } = growableColumns[i];
const share = Math.floor((extraColumns * weight) / totalWeight);
widths[index] += share;
distributed += share;
}
// Last growable column gets the remainder
const last = growableColumns[growableColumns.length - 1];
widths[last.index] += extraColumns - distributed;
return {
widths,
totalWidth: columns,
remainingColumns: 0,
};
}
// Content-heavy: prioritize columns with preferredWidth
// Distribute based on how much each column is below its preferred width
const widths = definitions.map((def) => def.minWidth);
const contentScores = definitions.map((def) => {
if (!def.canGrow) return 0;
const preferred = def.preferredWidth ?? def.minWidth * 2;
return Math.max(0, preferred - def.minWidth);
});
const totalScore = contentScores.reduce((a, b) => a + b, 0);
if (totalScore === 0) {
// Fall back to equal distribution
const extraPerGrowable = Math.floor(extraColumns / growableColumns.length);
for (const { index } of growableColumns) {
widths[index] += extraPerGrowable;
}
return {
widths,
totalWidth: columns,
remainingColumns: extraColumns % growableColumns.length,
};
}
// Distribute proportionally to content score
let distributed = 0;
const sortedGrowable = [...growableColumns].sort((a, b) => {
const scoreA = contentScores[a.index];
const scoreB = contentScores[b.index];
return scoreB - scoreA; // Higher scores first
});
for (let i = 0; i < sortedGrowable.length - 1; i++) {
const { index } = sortedGrowable[i];
const share = Math.floor((extraColumns * contentScores[index]) / totalScore);
widths[index] += share;
distributed += share;
}
// Last column gets the remainder
const last = sortedGrowable[sortedGrowable.length - 1];
widths[last.index] += extraColumns - distributed;
return {
widths,
totalWidth: columns,
remainingColumns: 0,
};
}

View File

@@ -1,211 +0,0 @@
/**
* Text truncation utilities for clean terminal display.
*
* Provides consistent ellipsis output for overflow text while
* preserving short text unchanged.
*/
export const DEFAULT_ELLIPSIS = "…";
/**
* truncateText - Truncate text to a maximum width with ellipsis.
*
* When text exceeds maxWidth:
* - If maxWidth < 4, text is replaced with just ellipsis
* - Otherwise, text is truncated to (maxWidth - 1) characters + ellipsis
*
* When text fits within maxWidth, it is returned unchanged.
*
* @param text - Text to truncate (already stripped of ANSI codes)
* @param maxWidth - Maximum width in terminal columns
* @param ellipsis - Ellipsis character(s) to use (default: "…")
* @returns {string} Truncated text with ellipsis if needed
*
* @example
* ```typescript
* truncateText("Hello World", 10); // "Hello World" (fits)
* truncateText("Hello World", 8); // "Hello W…"
* truncateText("Hello World", 3); // "…" (too short for meaningful truncation)
* truncateText("Hello World", 2); // "…" (minimum display width)
* ```
*/
export function truncateText(text: string, maxWidth: number, ellipsis: string = DEFAULT_ELLIPSIS): string {
if (maxWidth <= 0) {
return "";
}
const textWidth = text.length;
// Text fits within maxWidth
if (textWidth <= maxWidth) {
return text;
}
// Too short for meaningful truncation
if (maxWidth < 4) {
return ellipsis.slice(0, Math.max(1, maxWidth));
}
// Truncate with ellipsis - reserve space for the actual ellipsis length
const availableWidth = maxWidth - ellipsis.length;
if (availableWidth <= 0) {
// Ellipsis alone exceeds width
return ellipsis.slice(0, Math.max(1, maxWidth));
}
return text.slice(0, availableWidth) + ellipsis;
}
/**
* TruncateOptions - Configuration options for truncate functions.
*/
export interface TruncateOptions {
/** Ellipsis character(s) to use */
ellipsis?: string;
/** Whether to preserve words (avoid breaking mid-word) */
preserveWords?: boolean;
/** Minimum width threshold for truncation */
minTruncateWidth?: number;
}
/**
* truncateWithOptions - Truncate with additional options.
*
* @param text - Text to truncate
* @param maxWidth - Maximum width
* @param options - Truncation options
* @returns {string} Truncated text
*/
export function truncateWithOptions(
text: string,
maxWidth: number,
options: TruncateOptions = {}
): string {
const {
ellipsis = DEFAULT_ELLIPSIS,
preserveWords = false,
minTruncateWidth = 4,
} = options;
if (maxWidth <= 0) {
return "";
}
const textWidth = text.length;
// Text fits within maxWidth
if (textWidth <= maxWidth) {
return text;
}
// Too short for meaningful truncation
if (maxWidth < minTruncateWidth) {
return ellipsis.slice(0, Math.max(1, maxWidth));
}
if (preserveWords) {
// Find the last space before the truncation point
const availableWidth = maxWidth - 1;
const truncatedAt = text.slice(0, availableWidth);
const lastSpace = truncatedAt.lastIndexOf(" ");
if (lastSpace > availableWidth * 0.5) {
// There's a word boundary in the first half - break there
const wordBoundary = text.slice(0, lastSpace).trimEnd();
if (wordBoundary.length + ellipsis.length <= maxWidth) {
return wordBoundary + ellipsis;
}
}
}
// Standard truncation
const availableWidth = maxWidth - ellipsis.length;
return text.slice(0, Math.max(0, availableWidth)) + ellipsis;
}
/**
* padText - Pad text to a specific width.
*
* @param text - Text to pad
* @param width - Target width
* @param align - Alignment direction ("left" | "right" | "center")
* @returns {string} Padded text
*
* @example
* ```typescript
* padText("Hi", 6); // "Hi " (left by default)
* padText("Hi", 6, "right"); // " Hi"
* padText("Hi", 6, "center"); // " Hi "
* ```
*/
export function padText(text: string, width: number, align: "left" | "right" | "center" = "left"): string {
if (width <= 0) {
return "";
}
const textWidth = text.length;
// Text equals or exceeds target width
if (textWidth >= width) {
return text.slice(0, width);
}
const padding = width - textWidth;
switch (align) {
case "right":
return " ".repeat(padding) + text;
case "center": {
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
}
default:
return text + " ".repeat(padding);
}
}
/**
* fitText - Fit text to a width by truncating or padding.
*
* @param text - Text to fit
* @param width - Target width
* @param align - Alignment when text is shorter than width
* @param ellipsis - Ellipsis for truncation (omit to use padding instead)
* @returns {string} Text fitted to width
*
* @example
* ```typescript
* fitText("Hi", 6); // "Hi " (padded)
* fitText("Hello World", 6); // "Hello " (truncated without ellipsis)
* ```
*/
export function fitText(
text: string,
width: number,
align: "left" | "right" | "center" = "left",
ellipsis?: string
): string {
if (width <= 0) {
return "";
}
const textWidth = text.length;
// Text exceeds target width
if (textWidth > width) {
if (ellipsis) {
return truncateText(text, width, ellipsis);
}
// Without ellipsis, truncate but don't include trailing space from mid-word break
const truncated = text.slice(0, width);
return truncated.trimEnd();
}
// Text fits perfectly - no padding needed
if (textWidth === width) {
return text;
}
// Text is shorter - pad to fit
return padText(text, width, align);
}

View File

@@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"types": ["node"],
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
}
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**/*"]
}

View File

@@ -1,37 +0,0 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
import { cpus } from "node:os";
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
const coreSourceEntry = fileURLToPath(new URL("../core/src/index.ts", import.meta.url));
const testUtilsEntry = fileURLToPath(new URL("../core/src/__test-utils__/workspace.ts", import.meta.url));
const testSetupEntry = fileURLToPath(new URL("../core/src/__test-utils__/vitest-setup.ts", import.meta.url));
const testTeardownEntry = fileURLToPath(new URL("../core/src/__test-utils__/vitest-teardown.ts", import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@fusion/core": coreSourceEntry,
"@fusion/test-utils": testUtilsEntry,
},
},
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
setupFiles: [testSetupEntry],
globalSetup: [testTeardownEntry],
passWithNoTests: true,
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true,
coverage: {
enabled: false,
reporter: ["text", "html", "json"],
reportsDirectory: "./coverage",
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.d.ts", "dist/**"],
},
},
});