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:
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
307
packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx
Normal file
307
packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render } from "ink-testing-library";
|
||||||
|
import { DashboardApp } from "../app.js";
|
||||||
|
import { DashboardTUI } from "../controller.js";
|
||||||
|
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues } from "../state.js";
|
||||||
|
|
||||||
|
function newController(): DashboardTUI {
|
||||||
|
return new DashboardTUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSystemInfo() {
|
||||||
|
return {
|
||||||
|
host: "localhost",
|
||||||
|
port: 4040,
|
||||||
|
baseUrl: "http://localhost:4040",
|
||||||
|
authEnabled: false,
|
||||||
|
engineMode: "active" as const,
|
||||||
|
fileWatcher: true,
|
||||||
|
startTimeMs: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInteractiveData(opts: {
|
||||||
|
projects?: ProjectItem[];
|
||||||
|
tasks?: TaskItem[];
|
||||||
|
agents?: AgentItem[];
|
||||||
|
detail?: AgentDetailItem | null;
|
||||||
|
settings?: SettingsValues;
|
||||||
|
models?: ModelItem[];
|
||||||
|
} = {}) {
|
||||||
|
const projects = opts.projects ?? [];
|
||||||
|
const tasks = opts.tasks ?? [];
|
||||||
|
const agents = opts.agents ?? [];
|
||||||
|
const detail = opts.detail ?? null;
|
||||||
|
const settings: SettingsValues = opts.settings ?? {
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxWorktrees: 2,
|
||||||
|
autoMerge: false,
|
||||||
|
mergeStrategy: "direct",
|
||||||
|
pollIntervalMs: 60000,
|
||||||
|
enginePaused: false,
|
||||||
|
globalPause: false,
|
||||||
|
};
|
||||||
|
const models = opts.models ?? [];
|
||||||
|
return {
|
||||||
|
listProjects: async () => projects,
|
||||||
|
listTasks: async () => tasks,
|
||||||
|
listAgents: async () => agents,
|
||||||
|
getAgentDetail: async (_id: string) => detail,
|
||||||
|
updateAgentState: async (_id: string, _state: string) => {},
|
||||||
|
deleteAgent: async (_id: string) => {},
|
||||||
|
getSettings: async () => settings,
|
||||||
|
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
||||||
|
listModels: () => models,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DashboardApp smoke", () => {
|
||||||
|
it("renders the splash brand mark and tagline before systemInfo arrives", () => {
|
||||||
|
const controller = newController();
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("╭─────╮");
|
||||||
|
expect(frame).toContain("AI coding agent dashboard");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the FUSION block letters after the wipe-in animation runs", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
expect(lastFrame() ?? "").toContain("███████╗");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders system panel content once setSystemInfo fires", () => {
|
||||||
|
const controller = newController();
|
||||||
|
const { lastFrame, unmount, rerender } = render(<DashboardApp controller={controller} />);
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
rerender(<DashboardApp controller={controller} />);
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("http://localhost:4040");
|
||||||
|
expect(frame).not.toContain("███████╗");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows interactive empty-state when no data source is wired", () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setMode("interactive");
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
expect(lastFrame() ?? "").toContain("Interactive mode unavailable");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the selected project name in board view header", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
const projects: ProjectItem[] = [
|
||||||
|
{ id: "p1", name: "alpha", path: "/tmp/alpha" },
|
||||||
|
{ id: "p2", name: "beta", path: "/tmp/beta" },
|
||||||
|
];
|
||||||
|
const tasks: TaskItem[] = [
|
||||||
|
{ id: "t1", title: "first", description: "", column: "todo" },
|
||||||
|
];
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setInteractiveData(makeInteractiveData({ projects, tasks }));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("board");
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
// Board shows the currently selected project; first project "alpha" is selected by default
|
||||||
|
expect(frame).toContain("alpha");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DashboardTUI snapshot stability", () => {
|
||||||
|
it("returns the same snapshot reference across reads when state has not changed", () => {
|
||||||
|
const controller = newController();
|
||||||
|
const a = controller.getSnapshot();
|
||||||
|
const b = controller.getSnapshot();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates the cached snapshot when state changes", () => {
|
||||||
|
const controller = newController();
|
||||||
|
const a = controller.getSnapshot();
|
||||||
|
controller.setLoadingStatus("Working…");
|
||||||
|
const b = controller.getSnapshot();
|
||||||
|
expect(b).not.toBe(a);
|
||||||
|
expect(b.loadingStatus).toBe("Working…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies subscribers on state change", () => {
|
||||||
|
const controller = newController();
|
||||||
|
const cb = vi.fn();
|
||||||
|
const unsub = controller.subscribe(cb);
|
||||||
|
controller.setLoadingStatus("Tick");
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
unsub();
|
||||||
|
controller.setLoadingStatus("Tock");
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles mode and reflects it in the snapshot", () => {
|
||||||
|
const controller = newController();
|
||||||
|
expect(controller.getSnapshot().mode).toBe("status");
|
||||||
|
controller.setMode("interactive");
|
||||||
|
expect(controller.getSnapshot().mode).toBe("interactive");
|
||||||
|
controller.setMode("status");
|
||||||
|
expect(controller.getSnapshot().mode).toBe("status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends log entries and exposes them in the snapshot", () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.log("hello", "scope");
|
||||||
|
const entries = controller.getSnapshot().logEntries;
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].message).toBe("hello");
|
||||||
|
expect(entries[0].prefix).toBe("scope");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setInteractiveView updates interactiveView in snapshot", () => {
|
||||||
|
const controller = newController();
|
||||||
|
expect(controller.getSnapshot().interactiveView).toBe("board");
|
||||||
|
controller.setInteractiveView("agents");
|
||||||
|
expect(controller.getSnapshot().interactiveView).toBe("agents");
|
||||||
|
controller.setInteractiveView("settings");
|
||||||
|
expect(controller.getSnapshot().interactiveView).toBe("settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Agents view", () => {
|
||||||
|
it("renders agents list when setInteractiveView('agents') is set", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
const agents: AgentItem[] = [
|
||||||
|
{ id: "a1", name: "worker-1", state: "active", role: "executor" },
|
||||||
|
{ id: "a2", name: "worker-2", state: "idle", role: "executor" },
|
||||||
|
];
|
||||||
|
controller.setInteractiveData(makeInteractiveData({ agents }));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("agents");
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("worker-1");
|
||||||
|
expect(frame).toContain("worker-2");
|
||||||
|
expect(frame).toContain("Agents");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Agent Detail panel label", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setInteractiveData(makeInteractiveData());
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("agents");
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(lastFrame() ?? "").toContain("Agent Detail");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Settings view", () => {
|
||||||
|
it("renders settings list when setInteractiveView('settings') is set", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
const settings: SettingsValues = {
|
||||||
|
maxConcurrent: 3,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
autoMerge: true,
|
||||||
|
mergeStrategy: "direct",
|
||||||
|
pollIntervalMs: 60000,
|
||||||
|
enginePaused: false,
|
||||||
|
globalPause: false,
|
||||||
|
};
|
||||||
|
controller.setInteractiveData(makeInteractiveData({ settings }));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("settings");
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("Settings");
|
||||||
|
expect(frame).toContain("Max Concurrent");
|
||||||
|
expect(frame).toContain("Auto Merge");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders models subsection when models are provided", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
const models: ModelItem[] = [
|
||||||
|
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", provider: "anthropic", contextWindow: 200000 },
|
||||||
|
];
|
||||||
|
controller.setInteractiveData(makeInteractiveData({ models }));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("settings");
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("Available Models");
|
||||||
|
expect(frame).toContain("Claude 3.5 Sonnet");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Board view", () => {
|
||||||
|
it("renders kanban columns in board view", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
const tasks: TaskItem[] = [
|
||||||
|
{ id: "t1", title: "Task One", description: "", column: "todo" },
|
||||||
|
{ id: "t2", title: "Task Two", description: "", column: "in-progress" },
|
||||||
|
];
|
||||||
|
controller.setInteractiveData(makeInteractiveData({
|
||||||
|
projects: [{ id: "p1", name: "my-project", path: "/tmp/p" }],
|
||||||
|
tasks,
|
||||||
|
}));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("board");
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("TODO");
|
||||||
|
expect(frame).toContain("IN PROGRESS");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LogsPanel indicator", () => {
|
||||||
|
it("renders the selection arrow on the highlighted log row", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setActiveSection("logs");
|
||||||
|
controller.log("first message", "test");
|
||||||
|
controller.log("second message", "test");
|
||||||
|
controller.log("third message", "test");
|
||||||
|
// Select index 1 (middle entry)
|
||||||
|
controller.setSelectedLogIndex(1);
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("▶");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no selection arrow on non-focused log entries", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setActiveSection("logs");
|
||||||
|
controller.log("only message", "test");
|
||||||
|
controller.setSelectedLogIndex(0);
|
||||||
|
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
// The selected entry shows the arrow; it should appear at least once
|
||||||
|
expect(frame).toContain("▶");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { LogRingBuffer } from "../log-ring-buffer.js";
|
||||||
|
|
||||||
|
describe("LogRingBuffer", () => {
|
||||||
|
let buffer: LogRingBuffer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
buffer = new LogRingBuffer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores entries and reports total count", () => {
|
||||||
|
buffer.push({ timestamp: new Date(), level: "info", message: "test1" });
|
||||||
|
buffer.push({ timestamp: new Date(), level: "warn", message: "test2" });
|
||||||
|
expect(buffer.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all entries in chronological order", () => {
|
||||||
|
buffer.push({ timestamp: new Date("2026-01-01T10:00:00"), level: "info", message: "first" });
|
||||||
|
buffer.push({ timestamp: new Date("2026-01-01T11:00:00"), level: "info", message: "second" });
|
||||||
|
const entries = buffer.getAll();
|
||||||
|
expect(entries.length).toBe(2);
|
||||||
|
expect(entries[0].message).toBe("first");
|
||||||
|
expect(entries[1].message).toBe("second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at MAX_LOG_ENTRIES (1000)", () => {
|
||||||
|
for (let i = 0; i < 1500; i++) {
|
||||||
|
buffer.push({ timestamp: new Date(), level: "info", message: `entry-${i}` });
|
||||||
|
}
|
||||||
|
const entries = buffer.getAll();
|
||||||
|
expect(entries.length).toBe(1000);
|
||||||
|
expect(buffer.total).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains chronological order when overwriting", () => {
|
||||||
|
for (let i = 0; i < 1500; i++) {
|
||||||
|
buffer.push({ timestamp: new Date(2026, 0, 1, 0, i), level: "info", message: `entry-${i}` });
|
||||||
|
}
|
||||||
|
const entries = buffer.getAll();
|
||||||
|
expect(entries.length).toBe(1000);
|
||||||
|
expect(entries[0].message).toBe("entry-500");
|
||||||
|
expect(entries[entries.length - 1].message).toBe("entry-1499");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears all entries", () => {
|
||||||
|
buffer.push({ timestamp: new Date(), level: "info", message: "test" });
|
||||||
|
buffer.clear();
|
||||||
|
expect(buffer.getAll().length).toBe(0);
|
||||||
|
expect(buffer.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores entries with different levels", () => {
|
||||||
|
buffer.push({ timestamp: new Date(), level: "info", message: "info msg" });
|
||||||
|
buffer.push({ timestamp: new Date(), level: "warn", message: "warn msg" });
|
||||||
|
buffer.push({ timestamp: new Date(), level: "error", message: "error msg" });
|
||||||
|
const entries = buffer.getAll();
|
||||||
|
expect(entries[0].level).toBe("info");
|
||||||
|
expect(entries[1].level).toBe("warn");
|
||||||
|
expect(entries[2].level).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores entries with prefix", () => {
|
||||||
|
buffer.push({ timestamp: new Date(), level: "info", message: "msg", prefix: "engine" });
|
||||||
|
const entries = buffer.getAll();
|
||||||
|
expect(entries[0].prefix).toBe("engine");
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
1773
packages/cli/src/commands/dashboard-tui/app.tsx
Normal file
1773
packages/cli/src/commands/dashboard-tui/app.tsx
Normal file
File diff suppressed because it is too large
Load Diff
308
packages/cli/src/commands/dashboard-tui/controller.ts
Normal file
308
packages/cli/src/commands/dashboard-tui/controller.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { LogRingBuffer } from "./log-ring-buffer.js";
|
||||||
|
import type { LogEntry } from "./log-ring-buffer.js";
|
||||||
|
import type {
|
||||||
|
SystemInfo,
|
||||||
|
TaskStats,
|
||||||
|
SettingsValues,
|
||||||
|
TUICallbacks,
|
||||||
|
SectionId,
|
||||||
|
DashboardState,
|
||||||
|
InteractiveData,
|
||||||
|
InteractiveView,
|
||||||
|
} from "./state.js";
|
||||||
|
import { SECTION_ORDER } from "./state.js";
|
||||||
|
|
||||||
|
// ── DashboardTUI ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Public API is identical to the old imperative class so dashboard.ts requires
|
||||||
|
// no changes other than the import path. State fields are kept as direct class
|
||||||
|
// properties (matching the old names) so the test suite can reach them via
|
||||||
|
// `(tui as any).activeSection` etc. without modification.
|
||||||
|
//
|
||||||
|
// The Ink App component subscribes via `subscribe()` / `getSnapshot()` — the
|
||||||
|
// same pattern as `useSyncExternalStore`.
|
||||||
|
|
||||||
|
export class DashboardTUI {
|
||||||
|
// State fields mirror the original private layout so tests can access them.
|
||||||
|
activeSection: SectionId = "system";
|
||||||
|
// Named `logBuffer` to match what captureConsole tests access via
|
||||||
|
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
|
||||||
|
logBuffer: LogRingBuffer;
|
||||||
|
systemInfo: SystemInfo | null = null;
|
||||||
|
taskStats: TaskStats | null = null;
|
||||||
|
settings: SettingsValues | null = null;
|
||||||
|
callbacks: TUICallbacks | null = null;
|
||||||
|
isRunning = false;
|
||||||
|
showHelp = false;
|
||||||
|
logsSeverityFilter: "all" | LogEntry["level"] = "all";
|
||||||
|
logsWrapEnabled = false;
|
||||||
|
logsExpandedMode = false;
|
||||||
|
selectedLogIndex = 0;
|
||||||
|
logsViewportStart = 0;
|
||||||
|
loadingStatus = "Starting…";
|
||||||
|
mode: "status" | "interactive" = "status";
|
||||||
|
interactiveData: InteractiveData | null = null;
|
||||||
|
interactiveView: InteractiveView = "board";
|
||||||
|
|
||||||
|
// Subscribers registered by the Ink App component.
|
||||||
|
private subscribers: Set<() => void> = new Set();
|
||||||
|
|
||||||
|
// Cached snapshot — useSyncExternalStore compares by Object.is, so we must
|
||||||
|
// return the same reference between renders unless state actually changed.
|
||||||
|
// notify() invalidates this; getSnapshot() rebuilds on demand.
|
||||||
|
private cachedSnapshot: DashboardState | null = null;
|
||||||
|
|
||||||
|
// Ink instance — set when start() is called.
|
||||||
|
private inkInstance: { unmount: () => void; waitUntilExit: () => Promise<unknown> } | null = null;
|
||||||
|
|
||||||
|
// Uptime ticker to keep footer time live.
|
||||||
|
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logBuffer = new LogRingBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subscription API (for Ink App) ────────────────────────────────────────
|
||||||
|
|
||||||
|
subscribe(callback: () => void): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
return () => this.subscribers.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): DashboardState {
|
||||||
|
if (this.cachedSnapshot) return this.cachedSnapshot;
|
||||||
|
this.cachedSnapshot = {
|
||||||
|
activeSection: this.activeSection,
|
||||||
|
logEntries: this.logBuffer.getAll(),
|
||||||
|
systemInfo: this.systemInfo,
|
||||||
|
taskStats: this.taskStats,
|
||||||
|
settings: this.settings,
|
||||||
|
callbacks: this.callbacks,
|
||||||
|
showHelp: this.showHelp,
|
||||||
|
logsSeverityFilter: this.logsSeverityFilter,
|
||||||
|
logsWrapEnabled: this.logsWrapEnabled,
|
||||||
|
logsExpandedMode: this.logsExpandedMode,
|
||||||
|
selectedLogIndex: this.selectedLogIndex,
|
||||||
|
logsViewportStart: this.logsViewportStart,
|
||||||
|
loadingStatus: this.loadingStatus,
|
||||||
|
mode: this.mode,
|
||||||
|
interactiveData: this.interactiveData,
|
||||||
|
interactiveView: this.interactiveView,
|
||||||
|
};
|
||||||
|
return this.cachedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this.cachedSnapshot = null;
|
||||||
|
for (const cb of this.subscribers) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API (unchanged from original DashboardTUI) ─────────────────────
|
||||||
|
|
||||||
|
get running(): boolean {
|
||||||
|
return this.isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallbacks(callbacks: TUICallbacks): void {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSystemInfo(info: SystemInfo): void {
|
||||||
|
this.systemInfo = info;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTaskStats(stats: TaskStats): void {
|
||||||
|
this.taskStats = stats;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings(settings: SettingsValues): void {
|
||||||
|
this.settings = settings;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingStatus(text: string): void {
|
||||||
|
this.loadingStatus = text;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractiveData(data: InteractiveData): void {
|
||||||
|
this.interactiveData = data;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractiveView(view: InteractiveView): void {
|
||||||
|
this.interactiveView = view;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(entry: Omit<LogEntry, "timestamp">): void {
|
||||||
|
this.logBuffer.push({ ...entry, timestamp: new Date() });
|
||||||
|
this.clampSelectedLogIndex(this.getFilteredLogEntries());
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogs(): void {
|
||||||
|
this.logBuffer.clear();
|
||||||
|
this.selectedLogIndex = 0;
|
||||||
|
this.logsViewportStart = 0;
|
||||||
|
this.logsExpandedMode = false;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: string, prefix?: string): void {
|
||||||
|
this.addLog({ level: "info", message, prefix });
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, prefix?: string): void {
|
||||||
|
this.addLog({ level: "warn", message, prefix });
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, prefix?: string): void {
|
||||||
|
this.addLog({ level: "error", message, prefix });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State helpers called from Ink App ────────────────────────────────────
|
||||||
|
|
||||||
|
setActiveSection(section: SectionId): void {
|
||||||
|
this.activeSection = section;
|
||||||
|
this.showHelp = false;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowHelp(show: boolean): void {
|
||||||
|
this.showHelp = show;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogsWrapEnabled(enabled: boolean): void {
|
||||||
|
this.logsWrapEnabled = enabled;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogsExpandedMode(expanded: boolean): void {
|
||||||
|
this.logsExpandedMode = expanded;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedLogIndex(index: number): void {
|
||||||
|
const entries = this.getFilteredLogEntries();
|
||||||
|
this.selectedLogIndex = this.clampIndex(index, entries.length);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogsViewportStart(start: number): void {
|
||||||
|
this.logsViewportStart = start;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode: "status" | "interactive"): void {
|
||||||
|
this.mode = mode;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleSection(direction: 1 | -1): void {
|
||||||
|
const idx = SECTION_ORDER.indexOf(this.activeSection);
|
||||||
|
this.activeSection = SECTION_ORDER[(idx + direction + SECTION_ORDER.length) % SECTION_ORDER.length];
|
||||||
|
this.showHelp = false;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleSeverityFilter(): void {
|
||||||
|
const order: Array<"all" | LogEntry["level"]> = ["all", "info", "warn", "error"];
|
||||||
|
const idx = order.indexOf(this.logsSeverityFilter);
|
||||||
|
this.logsSeverityFilter = order[(idx + 1) % order.length];
|
||||||
|
this.clampSelectedLogIndex(this.getFilteredLogEntries());
|
||||||
|
this.logsViewportStart = 0;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilteredLogEntries(): LogEntry[] {
|
||||||
|
const all = this.logBuffer.getAll();
|
||||||
|
return this.logsSeverityFilter === "all"
|
||||||
|
? all
|
||||||
|
: all.filter((e) => e.level === this.logsSeverityFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUtilityAction(key: string): Promise<void> {
|
||||||
|
if (!this.callbacks) return;
|
||||||
|
|
||||||
|
switch (key.toLowerCase()) {
|
||||||
|
case "r":
|
||||||
|
await this.callbacks.onRefreshStats();
|
||||||
|
break;
|
||||||
|
case "c":
|
||||||
|
this.callbacks.onClearLogs();
|
||||||
|
this.clearLogs();
|
||||||
|
break;
|
||||||
|
case "t":
|
||||||
|
if (this.systemInfo) {
|
||||||
|
const newPaused = this.systemInfo.engineMode !== "paused";
|
||||||
|
const newSettings = await this.callbacks.onTogglePause(newPaused);
|
||||||
|
const newEngineMode = newSettings.enginePaused ? "paused" : "active";
|
||||||
|
this.setSystemInfo({ ...this.systemInfo, engineMode: newEngineMode });
|
||||||
|
this.setSettings(newSettings);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.isRunning) return;
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
// Dynamic import avoids pulling Ink into non-TTY paths (CI, tests
|
||||||
|
// that only exercise pure logic).
|
||||||
|
const { render } = await import("ink");
|
||||||
|
const { createElement } = await import("react");
|
||||||
|
const { DashboardApp } = await import("./app.js");
|
||||||
|
|
||||||
|
this.inkInstance = render(
|
||||||
|
createElement(DashboardApp, { controller: this }),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.uptimeTimer = setInterval(() => {
|
||||||
|
if (this.isRunning) this.notify();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
if (this.uptimeTimer) {
|
||||||
|
clearInterval(this.uptimeTimer);
|
||||||
|
this.uptimeTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.inkInstance) {
|
||||||
|
this.inkInstance.unmount();
|
||||||
|
this.inkInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private clampSelectedLogIndex(entries: LogEntry[]): void {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
this.selectedLogIndex = 0;
|
||||||
|
this.logsExpandedMode = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectedLogIndex >= entries.length) {
|
||||||
|
this.selectedLogIndex = entries.length - 1;
|
||||||
|
}
|
||||||
|
if (this.selectedLogIndex < 0) {
|
||||||
|
this.selectedLogIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampIndex(index: number, length: number): number {
|
||||||
|
if (length === 0) return 0;
|
||||||
|
return Math.max(0, Math.min(index, length - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { ProjectItem, TaskItem, InteractiveData } from "../state.js";
|
||||||
|
|
||||||
|
export interface ProjectsState {
|
||||||
|
projects: ProjectItem[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TasksState {
|
||||||
|
tasks: TaskItem[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjects(interactiveData: InteractiveData | null): ProjectsState {
|
||||||
|
const [state, setState] = useState<ProjectsState>({ projects: [], loading: false, error: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interactiveData) return;
|
||||||
|
setState({ projects: [], loading: true, error: null });
|
||||||
|
interactiveData.listProjects().then((projects) => {
|
||||||
|
setState({ projects, loading: false, error: null });
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setState({ projects: [], loading: false, error: message });
|
||||||
|
});
|
||||||
|
}, [interactiveData]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTasks(
|
||||||
|
interactiveData: InteractiveData | null,
|
||||||
|
selectedProject: ProjectItem | null,
|
||||||
|
): TasksState {
|
||||||
|
const [state, setState] = useState<TasksState>({ tasks: [], loading: false, error: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interactiveData || !selectedProject) {
|
||||||
|
setState({ tasks: [], loading: false, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({ tasks: [], loading: true, error: null });
|
||||||
|
interactiveData.listTasks(selectedProject.path).then((tasks) => {
|
||||||
|
setState({ tasks, loading: false, error: null });
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setState({ tasks: [], loading: false, error: message });
|
||||||
|
});
|
||||||
|
}, [interactiveData, selectedProject]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
20
packages/cli/src/commands/dashboard-tui/index.ts
Normal file
20
packages/cli/src/commands/dashboard-tui/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Re-exports that preserve the public API surface of the old dashboard-tui.ts
|
||||||
|
// so that dashboard.ts (and any tests importing from dashboard-tui.js) need
|
||||||
|
// only update their import path.
|
||||||
|
|
||||||
|
export { DashboardTUI } from "./controller.js";
|
||||||
|
export { DashboardLogSink, formatConsoleArgs } from "./log-sink.js";
|
||||||
|
export { LogRingBuffer } from "./log-ring-buffer.js";
|
||||||
|
export { isTTYAvailable } from "./utils.js";
|
||||||
|
export type {
|
||||||
|
LogEntry,
|
||||||
|
SectionId,
|
||||||
|
SystemInfo,
|
||||||
|
TaskStats,
|
||||||
|
SettingsValues,
|
||||||
|
UtilityAction,
|
||||||
|
TUICallbacks,
|
||||||
|
InteractiveData,
|
||||||
|
ProjectItem,
|
||||||
|
TaskItem,
|
||||||
|
} from "./state.js";
|
||||||
48
packages/cli/src/commands/dashboard-tui/log-ring-buffer.ts
Normal file
48
packages/cli/src/commands/dashboard-tui/log-ring-buffer.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: Date;
|
||||||
|
level: "info" | "warn" | "error";
|
||||||
|
message: string;
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ring Buffer ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAX_LOG_ENTRIES = 1000;
|
||||||
|
|
||||||
|
export class LogRingBuffer {
|
||||||
|
private entries: LogEntry[] = [];
|
||||||
|
private count = 0;
|
||||||
|
|
||||||
|
push(entry: LogEntry): void {
|
||||||
|
if (this.entries.length < MAX_LOG_ENTRIES) {
|
||||||
|
this.entries.push(entry);
|
||||||
|
} else {
|
||||||
|
// Overwrite oldest entry in circular fashion
|
||||||
|
this.entries[this.count % MAX_LOG_ENTRIES] = entry;
|
||||||
|
}
|
||||||
|
this.count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): LogEntry[] {
|
||||||
|
if (this.count <= MAX_LOG_ENTRIES) {
|
||||||
|
return this.entries.slice();
|
||||||
|
}
|
||||||
|
// Return entries in chronological order (oldest to newest)
|
||||||
|
const start = this.count % MAX_LOG_ENTRIES;
|
||||||
|
return [
|
||||||
|
...this.entries.slice(start),
|
||||||
|
...this.entries.slice(0, start),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.entries = [];
|
||||||
|
this.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get total(): number {
|
||||||
|
return this.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
packages/cli/src/commands/dashboard-tui/log-sink.ts
Normal file
161
packages/cli/src/commands/dashboard-tui/log-sink.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import type { LogEntry } from "./log-ring-buffer.js";
|
||||||
|
|
||||||
|
// ── formatConsoleArgs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The engine's createLogger() prefixes messages with a null-byte-delimited
|
||||||
|
// severity marker so we can recover the original intent when routed via
|
||||||
|
// console.error (which is the transport it uses).
|
||||||
|
const LOG_LEVEL_MARKER_REGEX = /^\u0000fnlvl=(info|warn|error)\u0000\s*/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format heterogeneous console args into a single string, extracting a
|
||||||
|
* leading internal severity marker and `[prefix]` tag when present.
|
||||||
|
* Mirrors `util.format` loosely — objects are JSON-stringified (defensively,
|
||||||
|
* falling back to String()), everything else is coerced via String().
|
||||||
|
*/
|
||||||
|
export function formatConsoleArgs(
|
||||||
|
args: unknown[],
|
||||||
|
fallbackLevel: LogEntry["level"] = "info",
|
||||||
|
): { message: string; prefix?: string; level: LogEntry["level"] } {
|
||||||
|
const stringified = args.map((arg) => {
|
||||||
|
if (typeof arg === "string") return arg;
|
||||||
|
if (arg instanceof Error) return arg.stack ?? arg.message;
|
||||||
|
if (arg === null || arg === undefined) return String(arg);
|
||||||
|
if (typeof arg === "object") {
|
||||||
|
try { return JSON.stringify(arg); } catch { return String(arg); }
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}).join(" ");
|
||||||
|
|
||||||
|
const markerMatch = stringified.match(LOG_LEVEL_MARKER_REGEX);
|
||||||
|
const level = markerMatch?.[1] as LogEntry["level"] | undefined;
|
||||||
|
const withoutMarker = markerMatch ? stringified.replace(LOG_LEVEL_MARKER_REGEX, "") : stringified;
|
||||||
|
|
||||||
|
const match = withoutMarker.match(/^\[([^\]]+)\]\s*(.*)$/s);
|
||||||
|
if (match) {
|
||||||
|
return { prefix: match[1], message: match[2], level: level ?? fallbackLevel };
|
||||||
|
}
|
||||||
|
return { message: withoutMarker, level: level ?? fallbackLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DashboardLogSink ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that DashboardTUI exposes to the sink.
|
||||||
|
* Using an interface rather than importing the class directly
|
||||||
|
* prevents a circular dependency between sink and controller.
|
||||||
|
*/
|
||||||
|
export interface LogSinkTarget {
|
||||||
|
log(message: string, prefix?: string): void;
|
||||||
|
warn(message: string, prefix?: string): void;
|
||||||
|
error(message: string, prefix?: string): void;
|
||||||
|
readonly running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A log sink that routes messages to the TUI in TTY mode,
|
||||||
|
* or to console in non-TTY mode.
|
||||||
|
*
|
||||||
|
* `captureConsole()` monkey-patches `console.log/warn/error` so everything
|
||||||
|
* (including the engine's createLogger() output, which writes directly to
|
||||||
|
* console.error) surfaces in the TUI's log ring buffer. Without capture,
|
||||||
|
* most runtime logs render beneath the alt-screen TUI and are immediately
|
||||||
|
* overwritten on the next render, leaving the Logs tab nearly empty.
|
||||||
|
*
|
||||||
|
* Messages that start with `[prefix] rest` are unpacked so the TUI stores
|
||||||
|
* `prefix="prefix"` and `message="rest"`. Idempotent; call `releaseConsole()`
|
||||||
|
* on TUI shutdown to restore the originals.
|
||||||
|
*/
|
||||||
|
export class DashboardLogSink {
|
||||||
|
private tui: LogSinkTarget | null = null;
|
||||||
|
private isTTY: boolean;
|
||||||
|
private originalConsole: {
|
||||||
|
log: typeof console.log;
|
||||||
|
warn: typeof console.warn;
|
||||||
|
error: typeof console.error;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
constructor(tui?: LogSinkTarget) {
|
||||||
|
this.tui = tui ?? null;
|
||||||
|
this.isTTY = tui?.running ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTUI(tui: LogSinkTarget): void {
|
||||||
|
this.tui = tui;
|
||||||
|
this.isTTY = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: string, prefix?: string): void {
|
||||||
|
const line = prefix ? `[${prefix}] ${message}` : message;
|
||||||
|
if (this.tui && this.isTTY) {
|
||||||
|
this.tui.log(message, prefix);
|
||||||
|
} else if (this.originalConsole) {
|
||||||
|
this.originalConsole.log.call(console, line);
|
||||||
|
} else {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, prefix?: string): void {
|
||||||
|
const line = prefix ? `[${prefix}] ${message}` : message;
|
||||||
|
if (this.tui && this.isTTY) {
|
||||||
|
this.tui.warn(message, prefix);
|
||||||
|
} else if (this.originalConsole) {
|
||||||
|
this.originalConsole.warn.call(console, line);
|
||||||
|
} else {
|
||||||
|
console.warn(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, prefix?: string): void {
|
||||||
|
const line = prefix ? `[${prefix}] ${message}` : message;
|
||||||
|
if (this.tui && this.isTTY) {
|
||||||
|
this.tui.error(message, prefix);
|
||||||
|
} else if (this.originalConsole) {
|
||||||
|
this.originalConsole.error.call(console, line);
|
||||||
|
} else {
|
||||||
|
console.error(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
captureConsole(): void {
|
||||||
|
if (this.originalConsole) return;
|
||||||
|
this.originalConsole = {
|
||||||
|
log: console.log,
|
||||||
|
warn: console.warn,
|
||||||
|
error: console.error,
|
||||||
|
};
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
const { message, prefix, level } = formatConsoleArgs(args, "info");
|
||||||
|
this.writeCapturedConsoleLog(level, message, prefix);
|
||||||
|
};
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
const { message, prefix, level } = formatConsoleArgs(args, "warn");
|
||||||
|
this.writeCapturedConsoleLog(level, message, prefix);
|
||||||
|
};
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
const { message, prefix, level } = formatConsoleArgs(args, "error");
|
||||||
|
this.writeCapturedConsoleLog(level, message, prefix);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseConsole(): void {
|
||||||
|
if (!this.originalConsole) return;
|
||||||
|
console.log = this.originalConsole.log;
|
||||||
|
console.warn = this.originalConsole.warn;
|
||||||
|
console.error = this.originalConsole.error;
|
||||||
|
this.originalConsole = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeCapturedConsoleLog(level: LogEntry["level"], message: string, prefix?: string): void {
|
||||||
|
if (level === "error") {
|
||||||
|
this.error(message, prefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (level === "warn") {
|
||||||
|
this.warn(message, prefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.log(message, prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/cli/src/commands/dashboard-tui/logo.ts
Normal file
14
packages/cli/src/commands/dashboard-tui/logo.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// FUSION block-letter logo using Unicode box-drawing + full blocks.
|
||||||
|
// Font: ANSI Shadow (figlet-style). Hardcoded so we have no runtime dep.
|
||||||
|
// The caller applies a cyan→whiteBright vertical gradient.
|
||||||
|
|
||||||
|
export const FUSION_LOGO_LINES = [
|
||||||
|
"███████╗██╗ ██╗███████╗██╗ ██████╗ ███╗ ██╗",
|
||||||
|
"██╔════╝██║ ██║██╔════╝██║██╔═══██╗████╗ ██║",
|
||||||
|
"█████╗ ██║ ██║███████╗██║██║ ██║██╔██╗ ██║",
|
||||||
|
"██╔══╝ ██║ ██║╚════██║██║██║ ██║██║╚██╗██║",
|
||||||
|
"██║ ╚██████╔╝███████║██║╚██████╔╝██║ ╚████║",
|
||||||
|
"╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FUSION_TAGLINE = "AI coding agent dashboard";
|
||||||
164
packages/cli/src/commands/dashboard-tui/state.ts
Normal file
164
packages/cli/src/commands/dashboard-tui/state.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import type { LogEntry } from "./log-ring-buffer.js";
|
||||||
|
|
||||||
|
// ── Public types shared across the whole dashboard-tui module ─────────────────
|
||||||
|
|
||||||
|
export type { LogEntry };
|
||||||
|
|
||||||
|
export type SectionId = "logs" | "system" | "utilities" | "stats" | "settings";
|
||||||
|
|
||||||
|
export type AppMode = "status" | "interactive";
|
||||||
|
|
||||||
|
export type InteractiveView = "board" | "agents" | "settings";
|
||||||
|
|
||||||
|
export interface SystemInfo {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
baseUrl: string;
|
||||||
|
authEnabled: boolean;
|
||||||
|
authToken?: string;
|
||||||
|
tokenizedUrl?: string;
|
||||||
|
engineMode: "dev" | "active" | "paused";
|
||||||
|
fileWatcher: boolean;
|
||||||
|
startTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStats {
|
||||||
|
total: number;
|
||||||
|
byColumn: Record<string, number>;
|
||||||
|
active: number;
|
||||||
|
agents: {
|
||||||
|
idle: number;
|
||||||
|
active: number;
|
||||||
|
running: number;
|
||||||
|
error: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsValues {
|
||||||
|
maxConcurrent: number;
|
||||||
|
maxWorktrees: number;
|
||||||
|
autoMerge: boolean;
|
||||||
|
mergeStrategy: string;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
enginePaused: boolean;
|
||||||
|
globalPause: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilityAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TUICallbacks {
|
||||||
|
onRefreshStats: () => Promise<void>;
|
||||||
|
onClearLogs: () => void;
|
||||||
|
onTogglePause: (paused: boolean) => Promise<SettingsValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim project shape used by interactive mode
|
||||||
|
export interface ProjectItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim task shape used by interactive mode
|
||||||
|
export interface TaskItem {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description: string;
|
||||||
|
column: string;
|
||||||
|
agentState?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim agent shape for Agents view list
|
||||||
|
export interface AgentItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
role: string;
|
||||||
|
taskId?: string;
|
||||||
|
lastHeartbeatAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim heartbeat run for agent detail
|
||||||
|
export interface AgentRunItem {
|
||||||
|
id: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
status: string;
|
||||||
|
triggerDetail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim agent detail shape for Agents view detail panel
|
||||||
|
export interface AgentDetailItem extends AgentItem {
|
||||||
|
title?: string;
|
||||||
|
capabilities: string[];
|
||||||
|
recentRuns: AgentRunItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim model shape for Settings view models subsection
|
||||||
|
export interface ModelItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
contextWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractiveData {
|
||||||
|
listProjects: () => Promise<ProjectItem[]>;
|
||||||
|
listTasks: (projectPath: string) => Promise<TaskItem[]>;
|
||||||
|
listAgents: () => Promise<AgentItem[]>;
|
||||||
|
getAgentDetail: (id: string) => Promise<AgentDetailItem | null>;
|
||||||
|
updateAgentState: (id: string, state: string) => Promise<void>;
|
||||||
|
deleteAgent: (id: string) => Promise<void>;
|
||||||
|
getSettings: () => Promise<SettingsValues>;
|
||||||
|
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
|
||||||
|
listModels: () => ModelItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard state (mutable, shared between controller and App) ───────────────
|
||||||
|
|
||||||
|
export interface DashboardState {
|
||||||
|
activeSection: SectionId;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
systemInfo: SystemInfo | null;
|
||||||
|
taskStats: TaskStats | null;
|
||||||
|
settings: SettingsValues | null;
|
||||||
|
callbacks: TUICallbacks | null;
|
||||||
|
showHelp: boolean;
|
||||||
|
logsSeverityFilter: "all" | LogEntry["level"];
|
||||||
|
logsWrapEnabled: boolean;
|
||||||
|
logsExpandedMode: boolean;
|
||||||
|
selectedLogIndex: number;
|
||||||
|
logsViewportStart: number;
|
||||||
|
loadingStatus: string;
|
||||||
|
mode: AppMode;
|
||||||
|
interactiveData: InteractiveData | null;
|
||||||
|
interactiveView: InteractiveView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
||||||
|
|
||||||
|
export function createInitialState(): DashboardState {
|
||||||
|
return {
|
||||||
|
activeSection: "system",
|
||||||
|
logEntries: [],
|
||||||
|
systemInfo: null,
|
||||||
|
taskStats: null,
|
||||||
|
settings: null,
|
||||||
|
callbacks: null,
|
||||||
|
showHelp: false,
|
||||||
|
logsSeverityFilter: "all",
|
||||||
|
logsWrapEnabled: false,
|
||||||
|
logsExpandedMode: false,
|
||||||
|
selectedLogIndex: 0,
|
||||||
|
logsViewportStart: 0,
|
||||||
|
loadingStatus: "Starting…",
|
||||||
|
mode: "status",
|
||||||
|
interactiveData: null,
|
||||||
|
interactiveView: "board",
|
||||||
|
};
|
||||||
|
}
|
||||||
3
packages/cli/src/commands/dashboard-tui/utils.ts
Normal file
3
packages/cli/src/commands/dashboard-tui/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function isTTYAvailable(): boolean {
|
||||||
|
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
||||||
|
}
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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";
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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__/**/*"]
|
|
||||||
}
|
|
||||||
@@ -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/**"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user