From d229a127a340867f7204eb2454121f01630f883e Mon Sep 17 00:00:00 2001 From: Pete Fritchman Date: Sat, 14 Mar 2026 04:12:17 +0000 Subject: [PATCH 1/6] test: add unit tests for plugin and TUI 125 tests across 4 files covering plugin event handler state machine, DB query layer, theme resolution, and SessionList helper functions. Export testable functions from plugin (summarizeEvent, parseCmdlineFlags) and SessionList helpers. Refactor parseCmdlineFlags to accept optional args for testability. --- AGENTS.md | 25 +- Makefile | 5 +- package.json | 1 + plugin/src/index.test.ts | 619 ++++++++++++++++++++++++++ plugin/src/index.ts | 27 +- plugin/tsconfig.json | 1 + tui-ts/src/components/SessionList.tsx | 20 +- tui-ts/src/components/helpers.test.ts | 421 ++++++++++++++++++ tui-ts/src/db.test.ts | 272 +++++++++++ tui-ts/src/theme.test.ts | 77 ++++ 10 files changed, 1438 insertions(+), 30 deletions(-) create mode 100644 plugin/src/index.test.ts create mode 100644 tui-ts/src/components/helpers.test.ts create mode 100644 tui-ts/src/db.test.ts create mode 100644 tui-ts/src/theme.test.ts diff --git a/AGENTS.md b/AGENTS.md index add28c3..4c37a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,20 +14,24 @@ Open·Code session monitor. Plugin captures events → SQLite → TUI displays s ``` open·code-pulse/ ├── package.json # Root package — plugin entry (main), TUI binaries, all deps -├── Makefile # Convenience targets: install, build, typecheck, update, pack +├── Makefile # Convenience targets: install, build, typecheck, test, update, pack ├── schema.sql # Shared schema contract (source of truth, v3) ├── plugin/ # Open·Code plugin — event listener, writes to SQLite -│ ├── src/index.ts # Single-file plugin +│ ├── src/index.ts # Single-file plugin +│ ├── src/index.test.ts # Plugin unit tests │ ├── dist/ # Built output (git-ignored) │ └── tsconfig.json ├── tui-ts/ # Terminal UI — React/OpenTUI, reads SQLite │ ├── src/ -│ │ ├── cli.tsx # Entry point (shebang, bun direct execution) -│ │ ├── db.ts # SQLite query layer + stale/dead cleanup -│ │ ├── tmux.ts # Tmux attach/switch helpers -│ │ ├── theme.ts # 33 built-in themes, auto-detects from Open·Code +│ │ ├── cli.tsx # Entry point (shebang, bun direct execution) +│ │ ├── db.ts # SQLite query layer + stale/dead cleanup +│ │ ├── db.test.ts # DB layer tests +│ │ ├── tmux.ts # Tmux attach/switch helpers +│ │ ├── theme.ts # 33 built-in themes, auto-detects from Open·Code +│ │ ├── theme.test.ts # Theme resolution tests │ │ └── components/ -│ │ └── SessionList.tsx # Main UI component +│ │ ├── SessionList.tsx # Main UI component +│ │ └── helpers.test.ts # SessionList helper function tests │ └── tsconfig.json ├── AGENTS.md ├── README.md @@ -53,10 +57,12 @@ open·code-pulse/ - **Runtime**: Bun exclusively (not Node.js). Uses `bun:sqlite` native binding - **Single root package.json** — no sub-package package.json files. Plugin and TUI share dependencies. - **No linter/formatter configured** — follow TypeScript strict mode -- **No test framework** — manual verification only +- **Testing**: `bun:test` framework. Tests co-located with source (`*.test.ts`) - **No build step for TUI** — runs `.tsx` directly via Bun shebang - **Plugin builds**: `bun run build` from root (or `make build`). Outputs to `plugin/dist/` - **⚠ MANDATORY: Rebuild plugin after ANY change to `plugin/src/`**: Run `bun run build` immediately after editing. Open·Code loads from `plugin/dist/`, NOT `plugin/src/` — skipping this means your changes have no effect. This is not optional. +- **⚠ MANDATORY: Run `make test` after ANY code change**. All existing tests must pass. Do not submit changes that break tests. +- **⚠ MANDATORY: Add tests for new features**. New event types, DB queries, helper functions, and any testable logic must have corresponding unit tests. Test files are co-located: `foo.ts` → `foo.test.ts`. ## ANTI-PATTERNS (THIS PROJECT) @@ -102,6 +108,9 @@ bun run build # or: make build # Typecheck both plugin and TUI bun run typecheck # or: make typecheck +# Run tests (REQUIRED after any code change) +bun test # or: make test + # Typecheck individually (from subdirectories — tsconfig.json files exist there) cd plugin && bunx tsc --noEmit cd tui-ts && bunx tsc --noEmit diff --git a/Makefile b/Makefile index 47c7d91..53e4016 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install build typecheck update pack +.PHONY: install build typecheck test update pack install: bun install @@ -9,6 +9,9 @@ build: typecheck: bun run typecheck +test: + bun test + update: bun update diff --git a/package.json b/package.json index 9a6c006..b372de7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "scripts": { "build": "bun build plugin/src/index.ts --outdir plugin/dist --target bun --format esm", "typecheck": "cd plugin && bunx tsc --noEmit && cd ../tui-ts && bunx tsc --noEmit", + "test": "bun test", "prepublishOnly": "bun run build" }, "keywords": ["opencode", "plugin", "pulse", "monitor", "tui"], diff --git a/plugin/src/index.test.ts b/plugin/src/index.test.ts new file mode 100644 index 0000000..34f07b9 --- /dev/null +++ b/plugin/src/index.test.ts @@ -0,0 +1,619 @@ +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const testDir = mkdtempSync(join(tmpdir(), "pulse-plugin-test-")); +const testDbPath = join(testDir, "test.db"); +process.env.PULSE_DB_PATH = testDbPath; +delete process.env.TMUX_PANE; + +const { default: createPlugin, summarizeEvent, parseCmdlineFlags } = await import("./index.ts"); + +interface SessionRow { + pid: number; + session_id: string | null; + status: string; + title: string | null; + directory: string | null; + project_id: string | null; + opencode_version: string | null; + retry_message: string | null; + retry_next: number | null; + error_message: string | null; + todo_total: number; + todo_done: number; + heartbeat_at: number; + created_at: number; + updated_at: number; +} + +function createMockInput() { + const mockShell: any = (_strings: TemplateStringsArray, ..._values: any[]) => ({ + text: () => Promise.resolve(""), + quiet: () => Promise.resolve(), + }); + + return { + project: { id: "test-project", worktree: "/tmp/test-project" }, + directory: "/tmp/test-project", + worktree: "/tmp/test-project", + serverUrl: "http://localhost:0", + $: mockShell, + client: { + session: { + get: async () => ({ data: null }), + list: async () => ({ data: [] }), + status: async () => ({ data: {} }), + }, + }, + } as any; +} + +function makeEvent(type: string, properties: Record) { + return { type, properties }; +} + +function getRow(db: Database): SessionRow | null { + return db.query("SELECT * FROM sessions WHERE pid = ?").get(process.pid) as SessionRow | null; +} + +describe("parseCmdlineFlags", () => { + test("no flags returns defaults", () => { + const result = parseCmdlineFlags(["opencode"]); + expect(result.sessionId).toBeNull(); + expect(result.continueMode).toBe(false); + }); + + test("-s flag extracts session id", () => { + const result = parseCmdlineFlags(["opencode", "-s", "ses_abc123"]); + expect(result.sessionId).toBe("ses_abc123"); + expect(result.continueMode).toBe(false); + }); + + test("-c flag sets continueMode", () => { + const result = parseCmdlineFlags(["opencode", "-c"]); + expect(result.sessionId).toBeNull(); + expect(result.continueMode).toBe(true); + }); + + test("--continue flag sets continueMode", () => { + const result = parseCmdlineFlags(["opencode", "--continue"]); + expect(result.continueMode).toBe(true); + }); + + test("-s and -c together", () => { + const result = parseCmdlineFlags(["opencode", "-s", "ses_xyz", "-c"]); + expect(result.sessionId).toBe("ses_xyz"); + expect(result.continueMode).toBe(true); + }); + + test("-s at end without value returns null sessionId", () => { + const result = parseCmdlineFlags(["opencode", "-s"]); + expect(result.sessionId).toBeNull(); + }); + + test("-s with empty string value returns null sessionId", () => { + const result = parseCmdlineFlags(["opencode", "-s", ""]); + expect(result.sessionId).toBeNull(); + }); + + test("empty args array returns defaults", () => { + const result = parseCmdlineFlags([]); + expect(result.sessionId).toBeNull(); + expect(result.continueMode).toBe(false); + }); + + test("no args reads /proc/self/cmdline (smoke test)", () => { + const result = parseCmdlineFlags(); + expect(result.continueMode).toBe(false); + }); +}); + +describe("summarizeEvent", () => { + test("session.diff with file diffs", () => { + const result = summarizeEvent({ + type: "session.diff", + properties: { + sessionID: "s1", + diff: [ + { file: "src/app.ts", additions: 10, deletions: 3 }, + { file: "README.md", additions: 1, deletions: 0 }, + ], + }, + }); + expect(result).toContain("session.diff"); + expect(result).toContain("sid=s1"); + expect(result).toContain("src/app.ts(+10/-3)"); + expect(result).toContain("README.md(+1/-0)"); + }); + + test("session.diff without diffs", () => { + const result = summarizeEvent({ + type: "session.diff", + properties: { sessionID: "s2" }, + }); + expect(result).toBe("session.diff sid=s2"); + }); + + test("message.updated with info", () => { + const result = summarizeEvent({ + type: "message.updated", + properties: { + info: { sessionID: "s1", id: "msg1", role: "assistant" }, + }, + }); + expect(result).toContain("message.updated"); + expect(result).toContain("sid=s1"); + expect(result).toContain("msg=msg1"); + expect(result).toContain("role=assistant"); + }); + + test("message.updated without info", () => { + const result = summarizeEvent({ + type: "message.updated", + properties: { foo: "bar" }, + }); + expect(result).toContain("message.updated"); + expect(result).toContain("foo"); + }); + + test("message.part.updated with part and tool", () => { + const result = summarizeEvent({ + type: "message.part.updated", + properties: { + part: { + sessionID: "s1", + type: "tool_call", + tool: "bash", + state: { status: "running" }, + }, + }, + }); + expect(result).toContain("message.part.updated"); + expect(result).toContain("sid=s1"); + expect(result).toContain("type=tool_call"); + expect(result).toContain("tool=bash"); + expect(result).toContain("status=running"); + }); + + test("message.part.updated without part", () => { + const result = summarizeEvent({ + type: "message.part.updated", + properties: {}, + }); + expect(result).toContain("message.part.updated"); + }); + + test("unknown event type uses JSON fallback", () => { + const result = summarizeEvent({ + type: "some.unknown.event", + properties: { key: "value" }, + }); + expect(result).toContain("some.unknown.event"); + expect(result).toContain('"key"'); + expect(result).toContain('"value"'); + }); +}); + +describe("plugin event handler", () => { + let eventHandler: (args: { event: any }) => Promise; + let verifyDb: Database; + + beforeEach(async () => { + if (existsSync(testDbPath)) rmSync(testDbPath); + if (existsSync(testDbPath + "-wal")) rmSync(testDbPath + "-wal"); + if (existsSync(testDbPath + "-shm")) rmSync(testDbPath + "-shm"); + + const hooks = await createPlugin(createMockInput()); + eventHandler = hooks.event!; + verifyDb = new Database(testDbPath, { readonly: true }); + }); + + afterEach(async () => { + try { + await eventHandler({ event: makeEvent("server.instance.disposed", {}) }); + } catch {} + try { + verifyDb.close(); + } catch {} + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("creates initial row on startup", () => { + const row = getRow(verifyDb); + expect(row).not.toBeNull(); + expect(row!.pid).toBe(process.pid); + expect(row!.status).toBe("idle"); + expect(row!.directory).toBe("/tmp/test-project"); + expect(row!.project_id).toBe("test-project"); + expect(row!.todo_total).toBe(0); + expect(row!.todo_done).toBe(0); + }); + + test("session.status idle updates status and session_id", async () => { + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "idle" }, + }), + }); + const row = getRow(verifyDb); + expect(row!.status).toBe("idle"); + expect(row!.session_id).toBe("ses_1"); + expect(row!.retry_message).toBeNull(); + expect(row!.retry_next).toBeNull(); + }); + + test("session.status busy updates status", async () => { + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "busy" }, + }), + }); + const row = getRow(verifyDb); + expect(row!.status).toBe("busy"); + expect(row!.session_id).toBe("ses_1"); + }); + + test("session.status retry updates with retry info", async () => { + const retryNext = Date.now() + 5000; + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "retry", message: "rate limited", next: retryNext }, + }), + }); + const row = getRow(verifyDb); + expect(row!.status).toBe("retry"); + expect(row!.retry_message).toBe("rate limited"); + expect(row!.retry_next).toBe(retryNext); + }); + + test("session.idle updates status", async () => { + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "busy" }, + }), + }); + await eventHandler({ + event: makeEvent("session.idle", { sessionID: "ses_1" }), + }); + const row = getRow(verifyDb); + expect(row!.status).toBe("idle"); + expect(row!.session_id).toBe("ses_1"); + }); + + test("session.created sets session info and version", async () => { + await eventHandler({ + event: makeEvent("session.created", { + info: { + id: "ses_new", + projectID: "proj_1", + directory: "/home/user/project", + title: "Fix login bug", + version: "1.2.0", + }, + }), + }); + const row = getRow(verifyDb); + expect(row!.session_id).toBe("ses_new"); + expect(row!.project_id).toBe("proj_1"); + expect(row!.directory).toBe("/home/user/project"); + expect(row!.title).toBe("Fix login bug"); + expect(row!.opencode_version).toBe("1.2.0"); + expect(row!.status).toBe("idle"); + }); + + test("session.updated updates session info", async () => { + await eventHandler({ + event: makeEvent("session.updated", { + info: { + id: "ses_1", + projectID: "proj_1", + directory: "/home/user/project", + title: "Updated title", + version: "1.3.0", + }, + }), + }); + const row = getRow(verifyDb); + expect(row!.session_id).toBe("ses_1"); + expect(row!.title).toBe("Updated title"); + expect(row!.opencode_version).toBe("1.3.0"); + }); + + test("session.deleted clears session info", async () => { + await eventHandler({ + event: makeEvent("session.created", { + info: { + id: "ses_1", + projectID: "proj_1", + directory: "/tmp/test", + title: "My session", + version: "1.0.0", + }, + }), + }); + + await eventHandler({ event: makeEvent("session.deleted", {}) }); + const row = getRow(verifyDb); + expect(row!.session_id).toBeNull(); + expect(row!.title).toBeNull(); + expect(row!.status).toBe("idle"); + expect(row!.todo_total).toBe(0); + expect(row!.todo_done).toBe(0); + }); + + test("session.error sets error status and message", async () => { + await eventHandler({ + event: makeEvent("session.error", { + sessionID: "ses_1", + error: { message: "API rate limit exceeded", code: 429 }, + }), + }); + const row = getRow(verifyDb); + expect(row!.status).toBe("error"); + expect(row!.error_message).toContain("rate limit"); + }); + + test("session.error with null error", async () => { + await eventHandler({ + event: makeEvent("session.error", { + sessionID: "ses_1", + error: null, + }), + }); + const row = getRow(verifyDb); + expect(row!.status).toBe("error"); + expect(row!.error_message).toBeNull(); + }); + + test("permission.asked sets permission_pending", async () => { + await eventHandler({ + event: makeEvent("permission.asked", { id: "perm_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("permission_pending"); + }); + + test("permission.replied clears to idle", async () => { + await eventHandler({ + event: makeEvent("permission.asked", { id: "perm_1" }), + }); + await eventHandler({ + event: makeEvent("permission.replied", { requestID: "perm_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("idle"); + }); + + test("permission.replied falls back to question_pending", async () => { + await eventHandler({ + event: makeEvent("question.asked", { id: "q_1" }), + }); + await eventHandler({ + event: makeEvent("permission.asked", { id: "perm_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("permission_pending"); + + await eventHandler({ + event: makeEvent("permission.replied", { requestID: "perm_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("question_pending"); + }); + + test("question.asked sets question_pending when no permissions", async () => { + await eventHandler({ + event: makeEvent("question.asked", { id: "q_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("question_pending"); + }); + + test("question.asked stays permission_pending when permissions exist", async () => { + await eventHandler({ + event: makeEvent("permission.asked", { id: "perm_1" }), + }); + await eventHandler({ + event: makeEvent("question.asked", { id: "q_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("permission_pending"); + }); + + test("question.replied clears to idle", async () => { + await eventHandler({ + event: makeEvent("question.asked", { id: "q_1" }), + }); + await eventHandler({ + event: makeEvent("question.replied", { requestID: "q_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("idle"); + }); + + test("question.replied keeps permission_pending when permissions exist", async () => { + await eventHandler({ + event: makeEvent("permission.asked", { id: "perm_1" }), + }); + await eventHandler({ + event: makeEvent("question.asked", { id: "q_1" }), + }); + await eventHandler({ + event: makeEvent("question.replied", { requestID: "q_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("permission_pending"); + }); + + test("multiple permissions tracked independently", async () => { + await eventHandler({ + event: makeEvent("permission.asked", { id: "perm_1" }), + }); + await eventHandler({ + event: makeEvent("permission.asked", { id: "perm_2" }), + }); + expect(getRow(verifyDb)!.status).toBe("permission_pending"); + + await eventHandler({ + event: makeEvent("permission.replied", { requestID: "perm_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("permission_pending"); + + await eventHandler({ + event: makeEvent("permission.replied", { requestID: "perm_2" }), + }); + expect(getRow(verifyDb)!.status).toBe("idle"); + }); + + test("todo.updated updates counts", async () => { + await eventHandler({ + event: makeEvent("todo.updated", { + todos: [ + { content: "Task 1", status: "completed" }, + { content: "Task 2", status: "in_progress" }, + { content: "Task 3", status: "pending" }, + ], + }), + }); + const row = getRow(verifyDb); + expect(row!.todo_total).toBe(3); + expect(row!.todo_done).toBe(1); + }); + + test("todo.updated with all completed", async () => { + await eventHandler({ + event: makeEvent("todo.updated", { + todos: [ + { content: "Task 1", status: "completed" }, + { content: "Task 2", status: "completed" }, + ], + }), + }); + const row = getRow(verifyDb); + expect(row!.todo_total).toBe(2); + expect(row!.todo_done).toBe(2); + }); + + test("todo.updated with empty todos", async () => { + await eventHandler({ + event: makeEvent("todo.updated", { todos: [] }), + }); + const row = getRow(verifyDb); + expect(row!.todo_total).toBe(0); + expect(row!.todo_done).toBe(0); + }); + + test("server.instance.disposed deletes row", async () => { + expect(getRow(verifyDb)).not.toBeNull(); + await eventHandler({ + event: makeEvent("server.instance.disposed", {}), + }); + const freshDb = new Database(testDbPath, { readonly: true }); + const row = freshDb.query("SELECT * FROM sessions WHERE pid = ?").get(process.pid); + freshDb.close(); + expect(row).toBeNull(); + }); + + test("heartbeat updates timestamp on event", async () => { + const initialHeartbeat = getRow(verifyDb)!.heartbeat_at; + await new Promise((r) => setTimeout(r, 10)); + + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "idle" }, + }), + }); + expect(getRow(verifyDb)!.heartbeat_at).toBeGreaterThanOrEqual(initialHeartbeat); + }); + + test("upsert updates existing row, never duplicates", async () => { + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "busy" }, + }), + }); + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "idle" }, + }), + }); + await eventHandler({ + event: makeEvent("session.created", { + info: { + id: "ses_2", + projectID: "p1", + directory: "/tmp", + title: "test", + version: "1.0", + }, + }), + }); + + const count = verifyDb.query("SELECT COUNT(*) as cnt FROM sessions").get() as { cnt: number }; + expect(count.cnt).toBe(1); + }); + + test("full status lifecycle: idle -> busy -> retry -> error -> idle", async () => { + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "idle" }, + }), + }); + expect(getRow(verifyDb)!.status).toBe("idle"); + + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "busy" }, + }), + }); + expect(getRow(verifyDb)!.status).toBe("busy"); + + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "retry", message: "limit", next: 1000 }, + }), + }); + expect(getRow(verifyDb)!.status).toBe("retry"); + + await eventHandler({ + event: makeEvent("session.error", { + sessionID: "ses_1", + error: { message: "crash" }, + }), + }); + expect(getRow(verifyDb)!.status).toBe("error"); + + await eventHandler({ + event: makeEvent("session.idle", { sessionID: "ses_1" }), + }); + expect(getRow(verifyDb)!.status).toBe("idle"); + }); + + test("session.status idle clears retry fields", async () => { + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "retry", message: "rate limit", next: 9999 }, + }), + }); + expect(getRow(verifyDb)!.retry_message).toBe("rate limit"); + + await eventHandler({ + event: makeEvent("session.status", { + sessionID: "ses_1", + status: { type: "idle" }, + }), + }); + const row = getRow(verifyDb); + expect(row!.retry_message).toBeNull(); + expect(row!.retry_next).toBeNull(); + }); +}); diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 87ef8e1..a83d0e7 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -16,24 +16,29 @@ function debugLog(msg: string) { } catch {} } -interface CmdlineFlags { +export interface CmdlineFlags { sessionId: string | null; // from -s continueMode: boolean; // from -c / --continue } -function parseCmdlineFlags(): CmdlineFlags { - try { - const args = readFileSync("/proc/self/cmdline", "utf-8").split("\0"); - const sIdx = args.indexOf("-s"); - const sessionId = (sIdx !== -1 && sIdx + 1 < args.length && args[sIdx + 1]) ? args[sIdx + 1] : null; - const continueMode = args.includes("-c") || args.includes("--continue"); - return { sessionId, continueMode }; - } catch { - return { sessionId: null, continueMode: false }; +export function parseCmdlineFlags(providedArgs?: string[]): CmdlineFlags { + let args: string[]; + if (providedArgs) { + args = providedArgs; + } else { + try { + args = readFileSync("/proc/self/cmdline", "utf-8").split("\0"); + } catch { + return { sessionId: null, continueMode: false }; + } } + const sIdx = args.indexOf("-s"); + const sessionId = (sIdx !== -1 && sIdx + 1 < args.length && args[sIdx + 1]) ? args[sIdx + 1] : null; + const continueMode = args.includes("-c") || args.includes("--continue"); + return { sessionId, continueMode }; } -function summarizeEvent(event: { type: string; properties: Record }): string { +export function summarizeEvent(event: { type: string; properties: Record }): string { const p = event.properties; switch (event.type) { case "session.diff": { diff --git a/plugin/tsconfig.json b/plugin/tsconfig.json index 04e9cff..22ba846 100644 --- a/plugin/tsconfig.json +++ b/plugin/tsconfig.json @@ -6,6 +6,7 @@ "lib": ["ESNext"], "types": ["bun-types"], "strict": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, diff --git a/tui-ts/src/components/SessionList.tsx b/tui-ts/src/components/SessionList.tsx index 7801f87..4cd0f37 100644 --- a/tui-ts/src/components/SessionList.tsx +++ b/tui-ts/src/components/SessionList.tsx @@ -136,7 +136,7 @@ export const COLUMN_META: Record = { }, }; -const STATUS_ICONS: Record = { +export const STATUS_ICONS: Record = { permission_pending: "\u25B2", question_pending: "?", error: "\u2717", @@ -145,7 +145,7 @@ const STATUS_ICONS: Record = { busy: "\u25E6", }; -const STATUS_LABELS: Record = { +export const STATUS_LABELS: Record = { permission_pending: "Permission", question_pending: "Question", error: "Error", @@ -154,7 +154,7 @@ const STATUS_LABELS: Record = { busy: "Busy", }; -function statusColor(status: string): string { +export function statusColor(status: string): string { const colors: Record = { permission_pending: theme.warning, question_pending: theme.warning, @@ -166,7 +166,7 @@ function statusColor(status: string): string { return colors[status] || theme.textMuted; } -function relativeTime(timestampMs: number): string { +export function relativeTime(timestampMs: number): string { const diffS = Math.floor((Date.now() - timestampMs) / 1000); if (diffS < 60) return `${diffS}s ago`; const diffM = Math.floor(diffS / 60); @@ -177,7 +177,7 @@ function relativeTime(timestampMs: number): string { return `${diffD}d ago`; } -function todoBar(done: number, total: number): string { +export function todoBar(done: number, total: number): string { if (total === 0) return "\u2014"; const barWidth = 8; const filled = Math.min(Math.round((done / total) * barWidth), barWidth); @@ -185,13 +185,13 @@ function todoBar(done: number, total: number): string { return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${done}/${total}`; } -function truncate(str: string, maxLen: number): string { +export function truncate(str: string, maxLen: number): string { if (str.length <= maxLen) return str; if (maxLen <= 1) return str.slice(0, maxLen); return str.slice(0, maxLen - 1) + "\u2026"; } -function dirName(dir: string): string { +export function dirName(dir: string): string { if (!dir) return "unknown"; const parts = dir.split("/"); return parts[parts.length - 1] || dir; @@ -200,7 +200,7 @@ function dirName(dir: string): string { const SELECTOR_WIDTH = 2; const COL_GAP = 2; -function fitContentWidth(col: ColumnId, sessions: Session[]): number { +export function fitContentWidth(col: ColumnId, sessions: Session[]): number { const meta = COLUMN_META[col]; let maxLen = meta.minWidth; for (const session of sessions) { @@ -215,7 +215,7 @@ function fitContentWidth(col: ColumnId, sessions: Session[]): number { return maxLen; } -function allocateWidths( +export function allocateWidths( columns: ColumnId[], totalWidth: number, sessions: Session[], @@ -246,7 +246,7 @@ function allocateWidths( return widths.map((w) => Math.max(w, 1)); } -function renderCell( +export function renderCell( col: ColumnId, session: Session, width: number, diff --git a/tui-ts/src/components/helpers.test.ts b/tui-ts/src/components/helpers.test.ts new file mode 100644 index 0000000..8337c92 --- /dev/null +++ b/tui-ts/src/components/helpers.test.ts @@ -0,0 +1,421 @@ +import { describe, test, expect } from "bun:test"; +import { + relativeTime, + todoBar, + truncate, + dirName, + allocateWidths, + fitContentWidth, + statusColor, + renderCell, + STATUS_ICONS, + STATUS_LABELS, + COLUMN_META, + DEFAULT_COLUMNS, + ALL_COLUMNS, + type ColumnId, +} from "./SessionList.js"; +import type { Session } from "../db.js"; + +function makeSession(overrides: Partial = {}): Session { + return { + pid: 12345, + session_id: "ses_test", + project_id: "proj_test", + directory: "/home/user/my-project", + title: "Fix authentication", + status: "idle", + retry_message: "", + retry_next: 0, + error_message: "", + tmux_pane: "%1", + tmux_target: "dev", + opencode_version: "1.2.0", + todo_total: 5, + todo_done: 3, + heartbeat_at: Date.now(), + created_at: Date.now() - 3600_000, + updated_at: Date.now() - 60_000, + ...overrides, + }; +} + +describe("relativeTime", () => { + test("seconds ago", () => { + const result = relativeTime(Date.now() - 30_000); + expect(result).toBe("30s ago"); + }); + + test("zero seconds", () => { + const result = relativeTime(Date.now()); + expect(result).toBe("0s ago"); + }); + + test("minutes ago", () => { + const result = relativeTime(Date.now() - 5 * 60_000); + expect(result).toBe("5m ago"); + }); + + test("hours ago", () => { + const result = relativeTime(Date.now() - 3 * 3600_000); + expect(result).toBe("3h ago"); + }); + + test("days ago", () => { + const result = relativeTime(Date.now() - 2 * 86400_000); + expect(result).toBe("2d ago"); + }); + + test("boundary: 59 seconds → seconds", () => { + const result = relativeTime(Date.now() - 59_000); + expect(result).toBe("59s ago"); + }); + + test("boundary: 60 seconds → 1 minute", () => { + const result = relativeTime(Date.now() - 60_000); + expect(result).toBe("1m ago"); + }); + + test("boundary: 59 minutes → minutes", () => { + const result = relativeTime(Date.now() - 59 * 60_000); + expect(result).toBe("59m ago"); + }); + + test("boundary: 60 minutes → 1 hour", () => { + const result = relativeTime(Date.now() - 60 * 60_000); + expect(result).toBe("1h ago"); + }); + + test("boundary: 23 hours → hours", () => { + const result = relativeTime(Date.now() - 23 * 3600_000); + expect(result).toBe("23h ago"); + }); + + test("boundary: 24 hours → 1 day", () => { + const result = relativeTime(Date.now() - 24 * 3600_000); + expect(result).toBe("1d ago"); + }); +}); + +describe("todoBar", () => { + test("zero total shows em dash", () => { + expect(todoBar(0, 0)).toBe("\u2014"); + }); + + test("zero done shows empty bar", () => { + const result = todoBar(0, 8); + expect(result).toContain("["); + expect(result).toContain("]"); + expect(result).toContain("0/8"); + expect(result).not.toContain("█"); + }); + + test("partial completion", () => { + const result = todoBar(3, 5); + expect(result).toContain("3/5"); + expect(result).toContain("█"); + expect(result).toContain("░"); + }); + + test("full completion", () => { + const result = todoBar(5, 5); + expect(result).toContain("5/5"); + expect(result).toContain("████████"); + expect(result).not.toContain("░"); + }); + + test("half completion has ~4 filled blocks", () => { + const result = todoBar(4, 8); + const filledCount = (result.match(/█/g) || []).length; + expect(filledCount).toBe(4); + }); + + test("bar always has 8 total characters inside brackets", () => { + const result = todoBar(2, 10); + const barContent = result.match(/\[(.*?)\]/)?.[1] || ""; + expect(barContent.length).toBe(8); + }); +}); + +describe("truncate", () => { + test("string shorter than maxLen passes through", () => { + expect(truncate("hello", 10)).toBe("hello"); + }); + + test("string exactly maxLen passes through", () => { + expect(truncate("hello", 5)).toBe("hello"); + }); + + test("string longer than maxLen gets ellipsis", () => { + const result = truncate("hello world", 8); + expect(result.length).toBe(8); + expect(result).toBe("hello w\u2026"); + }); + + test("maxLen of 1 takes single char", () => { + expect(truncate("hello", 1)).toBe("h"); + }); + + test("maxLen of 2 truncates with ellipsis", () => { + expect(truncate("hello", 2)).toBe("h\u2026"); + }); + + test("empty string", () => { + expect(truncate("", 5)).toBe(""); + }); +}); + +describe("dirName", () => { + test("extracts last path component", () => { + expect(dirName("/home/user/my-project")).toBe("my-project"); + }); + + test("handles single directory name", () => { + expect(dirName("my-project")).toBe("my-project"); + }); + + test("handles empty string", () => { + expect(dirName("")).toBe("unknown"); + }); + + test("handles trailing slash", () => { + const result = dirName("/home/user/project/"); + expect(result).toBe("/home/user/project/"); + }); + + test("root path", () => { + expect(dirName("/")).toBe("/"); + }); +}); + +describe("STATUS_ICONS", () => { + test("all statuses have icons", () => { + const statuses = ["permission_pending", "question_pending", "error", "retry", "idle", "busy"]; + for (const status of statuses) { + expect(STATUS_ICONS[status]).toBeTruthy(); + } + }); + + test("permission_pending is triangle", () => { + expect(STATUS_ICONS.permission_pending).toBe("\u25B2"); + }); + + test("question_pending is question mark", () => { + expect(STATUS_ICONS.question_pending).toBe("?"); + }); +}); + +describe("STATUS_LABELS", () => { + test("all statuses have labels", () => { + const statuses = ["permission_pending", "question_pending", "error", "retry", "idle", "busy"]; + for (const status of statuses) { + expect(STATUS_LABELS[status]).toBeTruthy(); + } + }); +}); + +describe("statusColor", () => { + test("returns a color string for known statuses", () => { + const statuses = ["permission_pending", "question_pending", "error", "retry", "idle", "busy"]; + for (const status of statuses) { + const color = statusColor(status); + expect(color).toBeTruthy(); + expect(color).toMatch(/^#/); + } + }); + + test("returns muted color for unknown status", () => { + const color = statusColor("unknown_status"); + expect(color).toBeTruthy(); + expect(color).toMatch(/^#/); + }); +}); + +describe("COLUMN_META", () => { + test("all columns in ALL_COLUMNS have metadata", () => { + for (const col of ALL_COLUMNS) { + const meta = COLUMN_META[col]; + expect(meta).toBeTruthy(); + expect(meta.header).toBeTruthy(); + expect(meta.minWidth).toBeGreaterThan(0); + expect(typeof meta.flex).toBe("boolean"); + expect(meta.description).toBeTruthy(); + } + }); + + test("DEFAULT_COLUMNS is a subset of ALL_COLUMNS", () => { + for (const col of DEFAULT_COLUMNS) { + expect(ALL_COLUMNS).toContain(col); + } + }); +}); + +describe("allocateWidths", () => { + test("non-flex columns get minWidth", () => { + const cols: ColumnId[] = ["status", "pid"]; + const widths = allocateWidths(cols, 100, []); + expect(widths[0]).toBeGreaterThanOrEqual(12); + expect(widths[1]).toBeGreaterThanOrEqual(7); + }); + + test("flex columns expand to fill remaining space", () => { + const cols: ColumnId[] = ["status", "title"]; + const widths = allocateWidths(cols, 100, []); + expect(widths[1]).toBeGreaterThan(COLUMN_META.title.minWidth); + }); + + test("multiple flex columns share extra space", () => { + const cols: ColumnId[] = ["project", "title"]; + const widths = allocateWidths(cols, 100, []); + const diff = Math.abs(widths[0] - widths[1]); + expect(diff).toBeLessThanOrEqual( + Math.abs(COLUMN_META.project.minWidth - COLUMN_META.title.minWidth) + 1, + ); + }); + + test("minimum width of 1 guaranteed", () => { + const cols: ColumnId[] = ["status", "project", "title", "todo", "updated", "age"]; + const widths = allocateWidths(cols, 20, []); + for (const w of widths) { + expect(w).toBeGreaterThanOrEqual(1); + } + }); + + test("fitContent columns use actual content width", () => { + const sessions = [makeSession({ tmux_target: "my-long-session-name" })]; + const cols: ColumnId[] = ["tmux"]; + const widths = allocateWidths(cols, 100, sessions); + expect(widths[0]).toBeGreaterThanOrEqual("my-long-session-name".length); + }); +}); + +describe("fitContentWidth", () => { + test("returns minWidth when no sessions", () => { + const width = fitContentWidth("tmux", []); + expect(width).toBe(COLUMN_META.tmux.minWidth); + }); + + test("returns max content width across sessions", () => { + const sessions = [ + makeSession({ tmux_target: "short" }), + makeSession({ pid: 22222, tmux_target: "much-longer-name" }), + ]; + const width = fitContentWidth("tmux", sessions); + expect(width).toBeGreaterThanOrEqual("much-longer-name".length); + }); +}); + +describe("renderCell", () => { + test("status cell shows icon and label", () => { + const session = makeSession({ status: "idle" }); + const cell = renderCell("status", session, 20); + expect(cell.text).toContain(STATUS_ICONS.idle); + expect(cell.text).toContain(STATUS_LABELS.idle); + }); + + test("project cell shows directory basename", () => { + const session = makeSession({ directory: "/home/user/my-project" }); + const cell = renderCell("project", session, 20); + expect(cell.text).toContain("my-project"); + }); + + test("title cell shows session title", () => { + const session = makeSession({ title: "Fix auth" }); + const cell = renderCell("title", session, 20); + expect(cell.text).toContain("Fix auth"); + }); + + test("title cell shows em dash for empty title", () => { + const session = makeSession({ title: "" }); + const cell = renderCell("title", session, 20); + expect(cell.text).toContain("\u2014"); + }); + + test("todo cell shows bar when total > 0", () => { + const session = makeSession({ todo_done: 3, todo_total: 5 }); + const cell = renderCell("todo", session, 20); + expect(cell.text).toContain("3/5"); + }); + + test("todo cell shows em dash when total = 0", () => { + const session = makeSession({ todo_done: 0, todo_total: 0 }); + const cell = renderCell("todo", session, 20); + expect(cell.text).toContain("\u2014"); + }); + + test("pid cell shows process id", () => { + const session = makeSession({ pid: 42 }); + const cell = renderCell("pid", session, 10); + expect(cell.text).toContain("42"); + }); + + test("version cell shows version with v prefix", () => { + const session = makeSession({ opencode_version: "1.2.0" }); + const cell = renderCell("version", session, 15); + expect(cell.text).toContain("v1.2.0"); + }); + + test("version cell shows em dash when no version", () => { + const session = makeSession({ opencode_version: "" }); + const cell = renderCell("version", session, 15); + expect(cell.text).toContain("\u2014"); + }); + + test("tmux cell shows target or pane", () => { + const session = makeSession({ tmux_target: "dev", tmux_pane: "%1" }); + const cell = renderCell("tmux", session, 15); + expect(cell.text).toContain("dev"); + }); + + test("tmux cell prefers target over pane", () => { + const session = makeSession({ tmux_target: "dev", tmux_pane: "%1" }); + const cell = renderCell("tmux", session, 15); + expect(cell.text.trim()).toContain("dev"); + }); + + test("message cell shows error for error status", () => { + const session = makeSession({ + status: "error", + error_message: "API failed", + }); + const cell = renderCell("message", session, 30); + expect(cell.text).toContain("API failed"); + }); + + test("message cell shows retry message for retry status", () => { + const session = makeSession({ + status: "retry", + retry_message: "rate limited", + }); + const cell = renderCell("message", session, 30); + expect(cell.text).toContain("rate limited"); + }); + + test("message cell shows em dash for idle status", () => { + const session = makeSession({ status: "idle" }); + const cell = renderCell("message", session, 30); + expect(cell.text).toContain("\u2014"); + }); + + test("cell text is padded to width", () => { + const session = makeSession(); + const width = 25; + const cell = renderCell("title", session, width); + expect(cell.text.length).toBe(width); + }); + + test("cell text is truncated when content exceeds width", () => { + const session = makeSession({ title: "A very long title that exceeds the column width" }); + const width = 15; + const cell = renderCell("title", session, width); + expect(cell.text.length).toBe(width); + }); + + test("cell color is a hex string", () => { + const session = makeSession(); + for (const col of ALL_COLUMNS) { + const cell = renderCell(col, session, 20); + expect(cell.color).toMatch(/^#/); + } + }); +}); diff --git a/tui-ts/src/db.test.ts b/tui-ts/src/db.test.ts new file mode 100644 index 0000000..e26ca3d --- /dev/null +++ b/tui-ts/src/db.test.ts @@ -0,0 +1,272 @@ +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const testDir = mkdtempSync(join(tmpdir(), "pulse-db-test-")); +const testDbPath = join(testDir, "test.db"); +const schema = readFileSync(join(import.meta.dir, "../../schema.sql"), "utf-8"); + +process.env.PULSE_DB_PATH = testDbPath; + +const { querySessions, hasDbChanged, cleanupStaleSessions, closeDb, dbExists, getDbPath, STALE_THRESHOLD_MS } = await import("./db.ts"); + +function createTestDb(): Database { + const db = new Database(testDbPath); + db.exec(schema); + db.exec("PRAGMA journal_mode = WAL"); + return db; +} + +function insertSession(db: Database, overrides: Record = {}): void { + const now = Date.now(); + const defaults = { + pid: 99999, + session_id: "ses_test", + project_id: "proj_test", + directory: "/tmp/test-project", + title: "Test session", + status: "idle", + retry_message: null, + retry_next: null, + error_message: null, + tmux_pane: null, + tmux_target: null, + opencode_version: "1.0.0", + todo_total: 0, + todo_done: 0, + heartbeat_at: now, + created_at: now, + updated_at: now, + ...overrides, + }; + + db.query(` + INSERT INTO sessions ( + pid, session_id, project_id, directory, title, status, + retry_message, retry_next, error_message, tmux_pane, tmux_target, + opencode_version, todo_total, todo_done, heartbeat_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + defaults.pid, defaults.session_id, defaults.project_id, defaults.directory, + defaults.title, defaults.status, defaults.retry_message, defaults.retry_next, + defaults.error_message, defaults.tmux_pane, defaults.tmux_target, + defaults.opencode_version, defaults.todo_total, defaults.todo_done, + defaults.heartbeat_at, defaults.created_at, defaults.updated_at, + ); +} + +beforeEach(() => { + closeDb(); + if (existsSync(testDbPath)) rmSync(testDbPath); + if (existsSync(testDbPath + "-wal")) rmSync(testDbPath + "-wal"); + if (existsSync(testDbPath + "-shm")) rmSync(testDbPath + "-shm"); +}); + +afterEach(() => { + closeDb(); +}); + +afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +describe("getDbPath", () => { + test("returns PULSE_DB_PATH when set", () => { + expect(getDbPath()).toBe(testDbPath); + }); +}); + +describe("dbExists", () => { + test("returns false when DB file missing", () => { + expect(dbExists()).toBe(false); + }); + + test("returns true when DB file exists", () => { + createTestDb().close(); + expect(dbExists()).toBe(true); + }); +}); + +describe("querySessions", () => { + test("returns empty array when DB does not exist", () => { + expect(querySessions()).toEqual([]); + }); + + test("returns empty array when no sessions", () => { + createTestDb().close(); + expect(querySessions()).toEqual([]); + }); + + test("returns sessions with recent heartbeat", () => { + const db = createTestDb(); + insertSession(db, { pid: 10001 }); + db.close(); + + const sessions = querySessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0].pid).toBe(10001); + expect(sessions[0].session_id).toBe("ses_test"); + }); + + test("filters out stale sessions", () => { + const db = createTestDb(); + insertSession(db, { pid: 10001, heartbeat_at: Date.now() }); + insertSession(db, { + pid: 10002, + session_id: "ses_stale", + heartbeat_at: Date.now() - STALE_THRESHOLD_MS - 1000, + }); + db.close(); + + const sessions = querySessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0].pid).toBe(10001); + }); + + test("sorts by status priority: permission > question > error > retry > idle > busy", () => { + const db = createTestDb(); + const now = Date.now(); + insertSession(db, { pid: 10001, status: "busy", heartbeat_at: now }); + insertSession(db, { pid: 10002, status: "permission_pending", heartbeat_at: now }); + insertSession(db, { pid: 10003, status: "idle", heartbeat_at: now }); + insertSession(db, { pid: 10004, status: "error", heartbeat_at: now }); + insertSession(db, { pid: 10005, status: "question_pending", heartbeat_at: now }); + insertSession(db, { pid: 10006, status: "retry", heartbeat_at: now }); + db.close(); + + const sessions = querySessions(); + expect(sessions).toHaveLength(6); + expect(sessions[0].status).toBe("permission_pending"); + expect(sessions[1].status).toBe("question_pending"); + expect(sessions[2].status).toBe("error"); + expect(sessions[3].status).toBe("retry"); + expect(sessions[4].status).toBe("idle"); + expect(sessions[5].status).toBe("busy"); + }); + + test("COALESCE handles null values", () => { + const db = createTestDb(); + insertSession(db, { + pid: 10001, + session_id: null, + project_id: null, + directory: null, + title: null, + retry_message: null, + error_message: null, + tmux_pane: null, + tmux_target: null, + opencode_version: null, + }); + db.close(); + + const sessions = querySessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0].session_id).toBe(""); + expect(sessions[0].project_id).toBe(""); + expect(sessions[0].directory).toBe(""); + expect(sessions[0].title).toBe(""); + expect(sessions[0].tmux_pane).toBe(""); + expect(sessions[0].tmux_target).toBe(""); + expect(sessions[0].opencode_version).toBe(""); + expect(sessions[0].retry_next).toBe(0); + }); + + test("same-status sessions sorted by updated_at DESC", () => { + const db = createTestDb(); + const now = Date.now(); + insertSession(db, { + pid: 10001, + status: "idle", + title: "older", + heartbeat_at: now, + updated_at: now - 5000, + }); + insertSession(db, { + pid: 10002, + status: "idle", + title: "newer", + heartbeat_at: now, + updated_at: now, + }); + db.close(); + + const sessions = querySessions(); + expect(sessions).toHaveLength(2); + expect(sessions[0].title).toBe("newer"); + expect(sessions[1].title).toBe("older"); + }); +}); + +describe("hasDbChanged", () => { + test("returns false when DB does not exist", () => { + expect(hasDbChanged()).toBe(false); + }); + + test("returns true on first check with existing DB", () => { + createTestDb().close(); + expect(hasDbChanged()).toBe(true); + }); + + test("returns false on second check with no changes", () => { + createTestDb().close(); + hasDbChanged(); + expect(hasDbChanged()).toBe(false); + }); + + test("returns true after external write", () => { + const db = createTestDb(); + hasDbChanged(); + hasDbChanged(); + + insertSession(db); + db.close(); + + expect(hasDbChanged()).toBe(true); + }); +}); + +describe("cleanupStaleSessions", () => { + test("does nothing when DB does not exist", () => { + cleanupStaleSessions(); + }); + + test("does nothing when sessions table is empty", () => { + createTestDb().close(); + cleanupStaleSessions(); + }); + + test("deletes sessions past dead threshold (120s)", () => { + const db = createTestDb(); + const now = Date.now(); + insertSession(db, { pid: 10001, heartbeat_at: now }); + insertSession(db, { pid: 10002, heartbeat_at: now - 121_000 }); + db.close(); + closeDb(); + + cleanupStaleSessions(); + + const verifyDb = new Database(testDbPath, { readonly: true }); + const pids = (verifyDb.query("SELECT pid FROM sessions").all() as { pid: number }[]).map((r) => r.pid); + verifyDb.close(); + + expect(pids).not.toContain(10002); + }); + + test("deletes sessions with non-existent PIDs", () => { + const db = createTestDb(); + insertSession(db, { pid: 2147483647, heartbeat_at: Date.now() }); + db.close(); + closeDb(); + + cleanupStaleSessions(); + + const verifyDb = new Database(testDbPath, { readonly: true }); + const rows = verifyDb.query("SELECT pid FROM sessions").all(); + verifyDb.close(); + + expect(rows).toHaveLength(0); + }); +}); diff --git a/tui-ts/src/theme.test.ts b/tui-ts/src/theme.test.ts new file mode 100644 index 0000000..74ec09c --- /dev/null +++ b/tui-ts/src/theme.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect, afterEach } from "bun:test"; + +const origPulseTheme = process.env.PULSE_THEME; +delete process.env.PULSE_THEME; +process.env.XDG_STATE_HOME = "/tmp/pulse-theme-test-nonexistent"; + +const { getTheme } = await import("./theme.ts"); + +describe("getTheme", () => { + afterEach(() => { + if (origPulseTheme !== undefined) { + process.env.PULSE_THEME = origPulseTheme; + } else { + delete process.env.PULSE_THEME; + } + }); + + test("returns opencode theme by default (no env override)", () => { + delete process.env.PULSE_THEME; + const theme = getTheme(); + expect(theme.primary).toBe("#fab283"); + expect(theme.accent).toBe("#9d7cd8"); + expect(theme.error).toBe("#e06c75"); + }); + + test("respects PULSE_THEME env var", () => { + process.env.PULSE_THEME = "dracula"; + const theme = getTheme(); + expect(theme.primary).toBe("#bd93f9"); + expect(theme.accent).toBe("#8be9fd"); + expect(theme.error).toBe("#ff5555"); + }); + + test("falls back to opencode for unknown theme name", () => { + process.env.PULSE_THEME = "nonexistent-theme-name"; + const theme = getTheme(); + expect(theme.primary).toBe("#fab283"); + }); + + test("all 33 themes have valid hex colors for all fields", () => { + const themeNames = [ + "aura", "ayu", "carbonfox", "catppuccin", "catppuccin-frappe", + "catppuccin-macchiato", "cobalt2", "cursor", "dracula", "everforest", + "flexoki", "github", "gruvbox", "kanagawa", "lucent-orng", "material", + "matrix", "mercury", "monokai", "nightowl", "nord", "one-dark", + "opencode", "orng", "osaka-jade", "palenight", "rosepine", "solarized", + "synthwave84", "tokyonight", "vercel", "vesper", "zenburn", + ]; + + for (const name of themeNames) { + process.env.PULSE_THEME = name; + const theme = getTheme(); + expect(theme.primary).toBeTruthy(); + expect(theme.accent).toBeTruthy(); + expect(theme.error).toBeTruthy(); + expect(theme.warning).toBeTruthy(); + expect(theme.success).toBeTruthy(); + expect(theme.info).toBeTruthy(); + expect(theme.text).toBeTruthy(); + expect(theme.textMuted).toBeTruthy(); + expect(theme.primary).toMatch(/^#[0-9a-fA-F]{3,8}$/); + } + }); + + test("catppuccin variants are distinct themes", () => { + process.env.PULSE_THEME = "catppuccin"; + const cat = getTheme(); + process.env.PULSE_THEME = "catppuccin-frappe"; + const frappe = getTheme(); + process.env.PULSE_THEME = "catppuccin-macchiato"; + const macchiato = getTheme(); + + expect(cat.primary).not.toBe(frappe.primary); + expect(cat.primary).not.toBe(macchiato.primary); + expect(frappe.primary).not.toBe(macchiato.primary); + }); +}); From d4250ef853ef12673ef2a75f355a511745666b6f Mon Sep 17 00:00:00 2001 From: Pete Fritchman Date: Sat, 14 Mar 2026 04:45:24 +0000 Subject: [PATCH 2/6] feat: add config file, CLI flags, and env var support via citty Options (columns, theme, dbPath, debug) can be set three ways: CLI flag > env var > config file (~/.config/opencode/pulse.jsonc) > default. - Add citty for CLI arg parsing with auto-generated help - Add config.ts for unified config resolution across all sources - Plugin reads shared config file for debug and dbPath options - Debug logging now off by default, opt-in via config/env/flag - JSONC (JSON with comments) supported for config files - Theme resolution deferred via Proxy so CLI/config takes effect - db.ts uses setDbPath() instead of direct env var reads --- README.md | 69 +++++++++++-- bun.lock | 6 ++ package.json | 2 + plugin/src/index.ts | 23 ++++- tui-ts/src/cli.tsx | 137 ++++++++++++++------------ tui-ts/src/components/SessionList.tsx | 12 ++- tui-ts/src/config.ts | 100 +++++++++++++++++++ tui-ts/src/db.test.ts | 6 +- tui-ts/src/db.ts | 12 ++- tui-ts/src/theme.ts | 10 +- 10 files changed, 291 insertions(+), 86 deletions(-) create mode 100644 tui-ts/src/config.ts diff --git a/README.md b/README.md index bb882c0..2af061a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ Bun must be installed — pulse uses `bun:sqlite` for database access. pulse [options] Options: - -c, --columns Comma-separated columns (default: status,tmux,todo,updated,age,title) + -c, --columns Comma-separated columns + -t, --theme Theme name + --db-path Path to SQLite database -h, --help Show help ``` @@ -87,9 +89,43 @@ Sessions are sorted by what needs attention most: | ● | Idle | Ready for input | | ◦ | Busy | Working | -### Columns +## Configuration + +Every option can be set three ways: **config file**, **CLI flag**, or **environment variable**. All three use the same set of options. When the same option is set in multiple places, the most specific source wins: + +**CLI flag > environment variable > config file > default** + +| Option | Config key | CLI flag | Env var | Default | +|---------|------------|----------------|------------------|---------| +| Columns | `columns` | `-c, --columns`| `PULSE_COLUMNS` | `status,tmux,todo,updated,age,title` | +| Theme | `theme` | `-t, --theme` | `PULSE_THEME` | *auto-detected from OpenCode* | +| DB path | `dbPath` | `--db-path` | `PULSE_DB_PATH` | `~/.local/share/opencode-pulse/status.db` | +| Debug | `debug` | `--debug` | `PULSE_DEBUG` | `false` | + +### Config File + +Create `~/.config/opencode/pulse.jsonc` (JSON with comments): + +```jsonc +{ + // See available columns below + "columns": "status,project,title,todo,updated", + "theme": "catppuccin", + "debug": true +} +``` + +Columns can also be an array: -Choose which columns to display with `--columns`. The default set balances information density with readability. +```jsonc +{ + "columns": ["status", "project", "title", "todo", "updated"] +} +``` + +All keys are optional — only set what you want to override. Plain `pulse.json` is also supported. + +### Columns | Column | Description | |-----------|------------------------------------------------| @@ -105,8 +141,6 @@ Choose which columns to display with `--columns`. The default set balances infor | `tmux` | Tmux session name | | `message` | Error or retry message (contextual) | -Default: `status,tmux,todo,updated,age,title` - ```bash pulse # default columns pulse --columns status,project,title,updated # compact view @@ -116,17 +150,17 @@ pulse -c status,project,pid,version,tmux # debugging view ### Themes -Pulse auto-detects your OpenCode theme and matches its colors. Override with `PULSE_THEME`: +Pulse auto-detects your OpenCode theme and matches its colors. To override, set `theme` in your config file or use any of the three methods: ```bash -PULSE_THEME=catppuccin pulse +pulse --theme catppuccin ``` Available themes: `aura`, `ayu`, `carbonfox`, `catppuccin`, `catppuccin-frappe`, `catppuccin-macchiato`, `cobalt2`, `cursor`, `dracula`, `everforest`, `flexoki`, `github`, `gruvbox`, `kanagawa`, `lucent-orng`, `material`, `matrix`, `mercury`, `monokai`, `nightowl`, `nord`, `one-dark`, `opencode` *(default)*, `orng`, `osaka-jade`, `palenight`, `rosepine`, `solarized`, `synthwave84`, `tokyonight`, `vercel`, `vesper`, `zenburn` ## Troubleshooting -**DB location:** `~/.local/share/opencode-pulse/status.db` (override with `PULSE_DB_PATH`) +**DB location:** `~/.local/share/opencode-pulse/status.db` (override with `dbPath` in config, `--db-path` flag, or `PULSE_DB_PATH` env var) **Plugin not loading?** Check `~/.local/share/opencode/log/` for errors. @@ -134,7 +168,7 @@ Available themes: `aura`, `ayu`, `carbonfox`, `catppuccin`, `catppuccin-frappe`, **Stale sessions showing?** Pulse automatically cleans up sessions whose OpenCode process has exited. Dead processes are removed on startup and every 60 seconds. -**Debug log:** The plugin logs all received events to `~/.local/share/opencode-pulse/debug.log`. Check this to verify the plugin is receiving events from OpenCode. +**Debug log:** Enable `debug` in your config to have the plugin log all received events to `~/.local/share/opencode-pulse/debug.log`. Useful for verifying the plugin is receiving events from OpenCode. ## How It Works @@ -166,3 +200,20 @@ bun run build bun link pulse ``` + +### Development + +Enable debug logging during development to see every event the plugin receives from OpenCode: + +```json +// ~/.config/opencode/pulse.json +{ + "debug": true +} +``` + +Events are written to `~/.local/share/opencode-pulse/debug.log` with timestamps and PIDs. Rebuild the plugin after any change to `plugin/src/`: + +```bash +bun run build +``` diff --git a/bun.lock b/bun.lock index b6fde37..3806fc4 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,8 @@ "dependencies": { "@opentui/core": "latest", "@opentui/react": "latest", + "citty": "^0.2.1", + "jsonc-parser": "^3.3.1", "react": "^19.2.4", }, "devDependencies": { @@ -133,6 +135,8 @@ "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="], + "citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], @@ -155,6 +159,8 @@ "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], diff --git a/package.json b/package.json index b372de7..0392c27 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "dependencies": { "@opentui/core": "latest", "@opentui/react": "latest", + "citty": "^0.2.1", + "jsonc-parser": "^3.3.1", "react": "^19.2.4" }, "devDependencies": { diff --git a/plugin/src/index.ts b/plugin/src/index.ts index a83d0e7..b8a2622 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,16 +1,35 @@ import { Database } from "bun:sqlite"; -import { readFileSync, appendFileSync } from "fs"; +import { readFileSync, appendFileSync, existsSync } from "fs"; import { homedir } from "os"; import { join } from "path"; +import { parse as parseJsonc } from "jsonc-parser"; import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin"; import type { Event } from "@opencode-ai/sdk"; -const DB_PATH = process.env.PULSE_DB_PATH || join(homedir(), ".local/share/opencode-pulse/status.db"); +const CONFIG_DIR = join(homedir(), ".config", "opencode"); +const CONFIG_PATHS = [join(CONFIG_DIR, "pulse.jsonc"), join(CONFIG_DIR, "pulse.json")]; const SCHEMA_PATH = join(import.meta.dir, "../../schema.sql"); const HEARTBEAT_INTERVAL = 10000; const DEBUG_LOG = join(homedir(), ".local/share/opencode-pulse/debug.log"); +function loadPluginConfig(): { debug?: boolean; dbPath?: string } { + for (const path of CONFIG_PATHS) { + if (!existsSync(path)) continue; + try { + return parseJsonc(readFileSync(path, "utf-8")); + } catch { + continue; + } + } + return {}; +} + +const pluginConfig = loadPluginConfig(); +const DEBUG_ENABLED = process.env.PULSE_DEBUG === "true" || process.env.PULSE_DEBUG === "1" || pluginConfig.debug === true; +const DB_PATH = process.env.PULSE_DB_PATH || pluginConfig.dbPath || join(homedir(), ".local/share/opencode-pulse/status.db"); + function debugLog(msg: string) { + if (!DEBUG_ENABLED) return; try { appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] [pid=${process.pid}] ${msg}\n`); } catch {} diff --git a/tui-ts/src/cli.tsx b/tui-ts/src/cli.tsx index 1ca5d46..0f25f3c 100755 --- a/tui-ts/src/cli.tsx +++ b/tui-ts/src/cli.tsx @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { defineCommand, runMain, renderUsage, type CommandDef, type ArgsDef } from "citty"; import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { @@ -9,82 +10,90 @@ import { type ColumnId, } from "./components/SessionList.js"; import { execAttach } from "./tmux.js"; - -function parseArgs(): { columns: ColumnId[]; help: boolean } { - const args = process.argv.slice(2); - let columns: ColumnId[] | null = null; - let help = false; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--help" || arg === "-h") { - help = true; - } else if (arg === "--columns" || arg === "-c") { - const val = args[++i]; - if (val) { - const parsed = val - .split(",") - .filter((c): c is ColumnId => ALL_COLUMNS.includes(c as ColumnId)); - if (parsed.length > 0) columns = parsed; - } - } else if (arg.startsWith("--columns=")) { - const val = arg.slice("--columns=".length); - const parsed = val - .split(",") - .filter((c): c is ColumnId => ALL_COLUMNS.includes(c as ColumnId)); - if (parsed.length > 0) columns = parsed; +import { resolveConfig } from "./config.js"; +import { setDbPath } from "./db.js"; +import { setThemeName } from "./theme.js"; + +const main = defineCommand({ + meta: { + name: "pulse", + description: "Monitor your OpenCode sessions", + }, + args: { + columns: { + type: "string", + alias: "c", + description: `Comma-separated columns (default: ${DEFAULT_COLUMNS.join(",")})`, + }, + theme: { + type: "string", + alias: "t", + description: "Theme name (same themes as OpenCode, auto-detected by default)", + }, + "db-path": { + type: "string", + description: "Path to SQLite database (default: ~/.local/share/opencode-pulse/status.db)", + }, + debug: { + type: "boolean", + description: "Enable debug logging (plugin writes to ~/.local/share/opencode-pulse/debug.log)", + }, + }, + async run({ args }) { + const config = resolveConfig(args); + setDbPath(config.dbPath); + setThemeName(config.theme); + + type SessionTarget = { tmux_target: string; tmux_pane: string }; + let selectedSession: SessionTarget | null = null; + + const { promise: waitForExit, resolve: signalExit } = + Promise.withResolvers(); + + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + onDestroy: () => signalExit(), + }); + + createRoot(renderer).render( + { + selectedSession = session; + }} + />, + ); + + await waitForExit; + + if (selectedSession) { + execAttach(selectedSession); } - } - - return { columns: columns ?? DEFAULT_COLUMNS, help }; -} - -const { columns, help } = parseArgs(); + }, +}); -if (help) { +async function showUsageWithColumns(cmd: CommandDef) { + const usage = await renderUsage(cmd); const colList = ALL_COLUMNS.map( (c) => ` ${c.padEnd(10)} ${COLUMN_META[c].description}`, ).join("\n"); - console.log(`Usage: pulse [options] - -Options: - -c, --columns Comma-separated columns (default: ${DEFAULT_COLUMNS.join(",")}) - -h, --help Show this help - + console.log(`${usage} Available columns: ${colList} +Config file: + ~/.config/opencode/pulse.jsonc (or pulse.json) + + All CLI options can also be set in the config file: + {"columns": "status,project,title", "theme": "catppuccin", "debug": true} + + Override order: CLI flag > env var > config file > default + Examples: pulse pulse --columns status,project,title,updated pulse -c status,project,todo,message`); - - process.exit(0); } -type SessionTarget = { tmux_target: string; tmux_pane: string }; -let selectedSession: SessionTarget | null = null; - -const { promise: waitForExit, resolve: signalExit } = - Promise.withResolvers(); - -const renderer = await createCliRenderer({ - exitOnCtrlC: false, - onDestroy: () => signalExit(), -}); - -createRoot(renderer).render( - { - selectedSession = session; - }} - />, -); - -await waitForExit; - -if (selectedSession) { - execAttach(selectedSession); -} +runMain(main, { showUsage: showUsageWithColumns }); diff --git a/tui-ts/src/components/SessionList.tsx b/tui-ts/src/components/SessionList.tsx index 4cd0f37..0947839 100644 --- a/tui-ts/src/components/SessionList.tsx +++ b/tui-ts/src/components/SessionList.tsx @@ -12,14 +12,19 @@ import { dbExists, hasDbChanged, closeDb, + getDbPath, } from "../db.js"; import { isInsideTmux } from "../tmux.js"; -import { getTheme } from "../theme.js"; +import { getTheme, type Theme } from "../theme.js"; const POLL_INTERVAL_MS = 500; const CLEANUP_INTERVAL_MS = 60_000; -const theme = getTheme(); +const theme: Theme = new Proxy({} as Theme, { + get(_, prop: string) { + return getTheme()[prop as keyof Theme]; + }, +}); export type ColumnId = | "status" @@ -483,8 +488,7 @@ export function SessionList({ columns, onSelect }: SessionListProps) { Waiting for database at{" "} - {process.env.PULSE_DB_PATH || - "~/.local/share/opencode-pulse/status.db"} + {getDbPath()} {"\u2026"} diff --git a/tui-ts/src/config.ts b/tui-ts/src/config.ts new file mode 100644 index 0000000..54bd411 --- /dev/null +++ b/tui-ts/src/config.ts @@ -0,0 +1,100 @@ +import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { parse as parseJsonc } from "jsonc-parser"; +import { readOpenCodeTheme } from "./theme.js"; +import { + DEFAULT_COLUMNS, + ALL_COLUMNS, + type ColumnId, +} from "./components/SessionList.js"; +import { DEFAULT_DB_PATH } from "./db.js"; + +const CONFIG_DIR = join(homedir(), ".config", "opencode"); +const CONFIG_PATHS = [ + join(CONFIG_DIR, "pulse.jsonc"), + join(CONFIG_DIR, "pulse.json"), +]; + +export interface PulseConfig { + columns: ColumnId[]; + theme: string; + dbPath: string; + debug: boolean; +} + +interface FileConfig { + columns?: string | string[]; + theme?: string; + dbPath?: string; + debug?: boolean; +} + +function loadFileConfig(): FileConfig { + for (const path of CONFIG_PATHS) { + if (!existsSync(path)) continue; + try { + const content = readFileSync(path, "utf-8"); + return parseJsonc(content) as FileConfig; + } catch { + continue; + } + } + return {}; +} + +function parseColumns(value: unknown): ColumnId[] | null { + if (!value) return null; + + if (Array.isArray(value)) { + const parsed = value.filter( + (c): c is ColumnId => + typeof c === "string" && ALL_COLUMNS.includes(c as ColumnId), + ); + return parsed.length > 0 ? parsed : null; + } + + if (typeof value === "string") { + const parsed = value + .split(",") + .map((s) => s.trim()) + .filter((c): c is ColumnId => ALL_COLUMNS.includes(c as ColumnId)); + return parsed.length > 0 ? parsed : null; + } + + return null; +} + +export function resolveConfig(cliArgs: { + columns?: string; + theme?: string; + "db-path"?: string; + debug?: boolean; +}): PulseConfig { + const fileConfig = loadFileConfig(); + + // Precedence: CLI > env > config file > auto-detect > defaults + const columns = + parseColumns(cliArgs.columns) || + parseColumns(process.env.PULSE_COLUMNS) || + parseColumns(fileConfig.columns) || + DEFAULT_COLUMNS; + + const theme = + cliArgs.theme || + process.env.PULSE_THEME || + fileConfig.theme || + readOpenCodeTheme() || + "opencode"; + + const dbPath = + cliArgs["db-path"] || + process.env.PULSE_DB_PATH || + fileConfig.dbPath || + DEFAULT_DB_PATH; + + const debug = cliArgs.debug ?? + (process.env.PULSE_DEBUG === "true" || process.env.PULSE_DEBUG === "1" || fileConfig.debug === true); + + return { columns, theme, dbPath, debug }; +} diff --git a/tui-ts/src/db.test.ts b/tui-ts/src/db.test.ts index e26ca3d..23fd06b 100644 --- a/tui-ts/src/db.test.ts +++ b/tui-ts/src/db.test.ts @@ -8,9 +8,9 @@ const testDir = mkdtempSync(join(tmpdir(), "pulse-db-test-")); const testDbPath = join(testDir, "test.db"); const schema = readFileSync(join(import.meta.dir, "../../schema.sql"), "utf-8"); -process.env.PULSE_DB_PATH = testDbPath; +const { querySessions, hasDbChanged, cleanupStaleSessions, closeDb, dbExists, getDbPath, setDbPath, STALE_THRESHOLD_MS } = await import("./db.ts"); -const { querySessions, hasDbChanged, cleanupStaleSessions, closeDb, dbExists, getDbPath, STALE_THRESHOLD_MS } = await import("./db.ts"); +setDbPath(testDbPath); function createTestDb(): Database { const db = new Database(testDbPath); @@ -73,7 +73,7 @@ afterAll(() => { }); describe("getDbPath", () => { - test("returns PULSE_DB_PATH when set", () => { + test("returns path set via setDbPath", () => { expect(getDbPath()).toBe(testDbPath); }); }); diff --git a/tui-ts/src/db.ts b/tui-ts/src/db.ts index 517df8a..f253202 100644 --- a/tui-ts/src/db.ts +++ b/tui-ts/src/db.ts @@ -3,7 +3,15 @@ import { homedir } from "os"; import { basename, join } from "path"; import { existsSync, readFileSync } from "fs"; -const DEFAULT_DB_PATH = join(homedir(), ".local/share/opencode-pulse/status.db"); +export const DEFAULT_DB_PATH = join(homedir(), ".local/share/opencode-pulse/status.db"); + +let _dbPath: string = DEFAULT_DB_PATH; + +export function setDbPath(path: string): void { + _dbPath = path; + _db?.close(); + _db = null; +} export interface Session { pid: number; @@ -26,7 +34,7 @@ export interface Session { } export function getDbPath(): string { - return process.env.PULSE_DB_PATH || DEFAULT_DB_PATH; + return _dbPath; } export function dbExists(): boolean { diff --git a/tui-ts/src/theme.ts b/tui-ts/src/theme.ts index 0d05908..a1602b8 100644 --- a/tui-ts/src/theme.ts +++ b/tui-ts/src/theme.ts @@ -352,7 +352,13 @@ const themes: Record = { }, }; -function readOpenCodeTheme(): string | null { +let _themeName: string | null = null; + +export function setThemeName(name: string): void { + _themeName = name; +} + +export function readOpenCodeTheme(): string | null { const kvPath = join( process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "opencode", @@ -370,6 +376,6 @@ function readOpenCodeTheme(): string | null { } export function getTheme(): Theme { - const name = process.env.PULSE_THEME || readOpenCodeTheme() || "opencode"; + const name = _themeName || process.env.PULSE_THEME || readOpenCodeTheme() || "opencode"; return themes[name] || themes.opencode; } From 13dab01026e4d22333e4eb9a704797e76da913d1 Mon Sep 17 00:00:00 2001 From: Pete Fritchman Date: Sat, 14 Mar 2026 04:55:16 +0000 Subject: [PATCH 3/6] fix(tui): remove unnecessary "(not in tmux)" header warning Attaching to tmux sessions works fine from outside tmux via attach-session, so the error-colored warning was misleading. --- tui-ts/src/components/SessionList.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tui-ts/src/components/SessionList.tsx b/tui-ts/src/components/SessionList.tsx index 0947839..95d5c29 100644 --- a/tui-ts/src/components/SessionList.tsx +++ b/tui-ts/src/components/SessionList.tsx @@ -14,7 +14,6 @@ import { closeDb, getDbPath, } from "../db.js"; -import { isInsideTmux } from "../tmux.js"; import { getTheme, type Theme } from "../theme.js"; const POLL_INTERVAL_MS = 500; @@ -458,7 +457,6 @@ export function SessionList({ columns, onSelect }: SessionListProps) { } }); - const inTmux = isInsideTmux(); const colWidths = allocateWidths(columns, width, sessions); const header = ( @@ -471,7 +469,7 @@ export function SessionList({ columns, onSelect }: SessionListProps) { {sessions.length} opencode process {sessions.length !== 1 ? "es" : ""} on {hostname()} - {!inTmux ? (not in tmux) : null} + ); From aa98369a98fdcf83050e4b805a09f3abbf1dc091 Mon Sep 17 00:00:00 2001 From: Pete Fritchman Date: Sat, 14 Mar 2026 04:57:40 +0000 Subject: [PATCH 4/6] feat(tui): add Escape key as primary quit binding --- README.md | 2 +- tui-ts/src/components/SessionList.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2af061a..edc9177 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Options: | `j` / `↓` | Move down | | `k` / `↑` | Move up | | `Enter` | Attach to selected session's tmux pane and exit | -| `q` / `Ctrl+C` | Quit | +| `Esc` / `q` / `Ctrl+C` | Quit | Navigation wraps — pressing up on the first item selects the last. diff --git a/tui-ts/src/components/SessionList.tsx b/tui-ts/src/components/SessionList.tsx index 95d5c29..713a868 100644 --- a/tui-ts/src/components/SessionList.tsx +++ b/tui-ts/src/components/SessionList.tsx @@ -437,7 +437,7 @@ export function SessionList({ columns, onSelect }: SessionListProps) { }, [sessions.length, selectedIdx]); useKeyboard((key) => { - if (key.name === "q" || (key.ctrl && key.name === "c")) { + if (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c")) { renderer.destroy(); return; } @@ -475,7 +475,7 @@ export function SessionList({ columns, onSelect }: SessionListProps) { const footer = ( - j/k: navigate enter: attach q: quit + j/k: navigate enter: attach esc: quit ); From 0b6f406733c38ae303d03ccfec03beac72d73af5 Mon Sep 17 00:00:00 2001 From: Pete Fritchman Date: Sat, 14 Mar 2026 05:02:32 +0000 Subject: [PATCH 5/6] perf(tui): defer cleanup and cache prepared statement for faster startup Render session data before running cleanupStaleSessions() so the first frame appears immediately in tmux popups. Cache the querySessions() prepared statement across poll ticks instead of re-preparing every 500ms. --- AGENTS.md | 9 +++++++++ tui-ts/src/components/SessionList.tsx | 6 +++++- tui-ts/src/db.ts | 11 +++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c37a70..a9a4c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,15 @@ open·code-pulse/ - **⚠ MANDATORY: Run `make test` after ANY code change**. All existing tests must pass. Do not submit changes that break tests. - **⚠ MANDATORY: Add tests for new features**. New event types, DB queries, helper functions, and any testable logic must have corresponding unit tests. Test files are co-located: `foo.ts` → `foo.test.ts`. +## STARTUP PERFORMANCE + +The TUI is often launched in a tmux popup where startup latency is directly felt. Time-to-first-data is the critical metric — the user should see session rows as fast as possible. + +**Rules:** +- **Data query before cleanup.** `cleanupStaleSessions()` opens a writable DB, checks `/proc` for each PID, and deletes dead rows. The `querySessions()` WHERE clause already filters stale sessions, so cleanup is invisible to the user. Always fetch and render data first, defer cleanup. +- **Cache prepared statements.** `querySessions()` runs every 500ms. The prepared statement is cached in `_sessionsStmt` and reused across polls. Reset it when the DB connection is closed or reopened. +- **No new blocking I/O before first render.** Any work added to the startup path (config reads, file checks, network calls) must not delay the first `refresh(true)` → render cycle. Defer or parallelize. + ## ANTI-PATTERNS (THIS PROJECT) - **NO sub-package package.json files** — all deps in root package.json diff --git a/tui-ts/src/components/SessionList.tsx b/tui-ts/src/components/SessionList.tsx index 713a868..fdfbf9b 100644 --- a/tui-ts/src/components/SessionList.tsx +++ b/tui-ts/src/components/SessionList.tsx @@ -413,14 +413,18 @@ export function SessionList({ columns, onSelect }: SessionListProps) { }, []); useEffect(() => { - cleanupStaleSessions(); + // Show data first — cleanup is deferred so the UI renders immediately. + // Dead sessions are already filtered by the query's WHERE clause, + // so deferring cleanup has no visible effect on the initial frame. refresh(true); + const cleanupTimeout = setTimeout(cleanupStaleSessions, 50); const pollTimer = setInterval(() => refresh(), POLL_INTERVAL_MS); const cleanupTimer = setInterval( cleanupStaleSessions, CLEANUP_INTERVAL_MS, ); return () => { + clearTimeout(cleanupTimeout); clearInterval(pollTimer); clearInterval(cleanupTimer); closeDb(); diff --git a/tui-ts/src/db.ts b/tui-ts/src/db.ts index f253202..7736bae 100644 --- a/tui-ts/src/db.ts +++ b/tui-ts/src/db.ts @@ -78,11 +78,13 @@ const SESSIONS_QUERY = ` `; let _db: Database | null = null; +let _sessionsStmt: ReturnType | null = null; function getDb(): Database | null { const dbPath = getDbPath(); if (!existsSync(dbPath)) { _db = null; + _sessionsStmt = null; return null; } if (_db) return _db; @@ -91,11 +93,13 @@ function getDb(): Database | null { return _db; } catch { _db = null; + _sessionsStmt = null; return null; } } export function closeDb(): void { + _sessionsStmt = null; _db?.close(); _db = null; } @@ -131,10 +135,13 @@ export function querySessions(): Session[] { const db = getDb(); if (!db) return []; try { + if (!_sessionsStmt) { + _sessionsStmt = db.prepare(SESSIONS_QUERY); + } const cutoff = Date.now() - STALE_THRESHOLD_MS; - const stmt = db.prepare(SESSIONS_QUERY); - return stmt.all(cutoff) as Session[]; + return _sessionsStmt.all(cutoff) as Session[]; } catch { + _sessionsStmt = null; _db?.close(); _db = null; return []; From d0ed41b05c15fc13aa4a8779037fbb4295a2ec27 Mon Sep 17 00:00:00 2001 From: Pete Fritchman Date: Sat, 14 Mar 2026 05:19:35 +0000 Subject: [PATCH 6/6] perf(tui): cache theme resolution and warm DB before renderer Cache the resolved Theme object in getTheme() so the Proxy avoids repeated name resolution and object lookup on every color access. Open the DB connection before createCliRenderer() so the file descriptor is ready when SessionList mounts. --- tui-ts/src/cli.tsx | 3 ++- tui-ts/src/db.ts | 4 ++++ tui-ts/src/theme.ts | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tui-ts/src/cli.tsx b/tui-ts/src/cli.tsx index 0f25f3c..20b165f 100755 --- a/tui-ts/src/cli.tsx +++ b/tui-ts/src/cli.tsx @@ -11,7 +11,7 @@ import { } from "./components/SessionList.js"; import { execAttach } from "./tmux.js"; import { resolveConfig } from "./config.js"; -import { setDbPath } from "./db.js"; +import { setDbPath, warmDb } from "./db.js"; import { setThemeName } from "./theme.js"; const main = defineCommand({ @@ -43,6 +43,7 @@ const main = defineCommand({ const config = resolveConfig(args); setDbPath(config.dbPath); setThemeName(config.theme); + warmDb(); type SessionTarget = { tmux_target: string; tmux_pane: string }; let selectedSession: SessionTarget | null = null; diff --git a/tui-ts/src/db.ts b/tui-ts/src/db.ts index 7736bae..74c2411 100644 --- a/tui-ts/src/db.ts +++ b/tui-ts/src/db.ts @@ -104,6 +104,10 @@ export function closeDb(): void { _db = null; } +export function warmDb(): void { + getDb(); +} + let _lastDataVersion: number | null = null; export function hasDbChanged(): boolean { diff --git a/tui-ts/src/theme.ts b/tui-ts/src/theme.ts index a1602b8..4a899ad 100644 --- a/tui-ts/src/theme.ts +++ b/tui-ts/src/theme.ts @@ -353,9 +353,11 @@ const themes: Record = { }; let _themeName: string | null = null; +let _cachedTheme: Theme | null = null; export function setThemeName(name: string): void { _themeName = name; + _cachedTheme = null; } export function readOpenCodeTheme(): string | null { @@ -376,6 +378,9 @@ export function readOpenCodeTheme(): string | null { } export function getTheme(): Theme { + if (_cachedTheme) return _cachedTheme; const name = _themeName || process.env.PULSE_THEME || readOpenCodeTheme() || "opencode"; - return themes[name] || themes.opencode; + const resolved = themes[name] || themes.opencode; + if (_themeName) _cachedTheme = resolved; + return resolved; }