diff --git a/AGENTS.md b/AGENTS.md index add28c3..a9a4c72 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,21 @@ 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`. + +## 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) @@ -102,6 +117,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/README.md b/README.md index bb882c0..edc9177 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 ``` @@ -60,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. @@ -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 9a6c006..0392c27 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"], @@ -30,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.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..b8a2622 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,39 +1,63 @@ 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 {} } -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/cli.tsx b/tui-ts/src/cli.tsx index 1ca5d46..20b165f 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,91 @@ 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, warmDb } 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); + warmDb(); + + 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 7801f87..fdfbf9b 100644 --- a/tui-ts/src/components/SessionList.tsx +++ b/tui-ts/src/components/SessionList.tsx @@ -12,14 +12,18 @@ 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" @@ -136,7 +140,7 @@ export const COLUMN_META: Record = { }, }; -const STATUS_ICONS: Record = { +export const STATUS_ICONS: Record = { permission_pending: "\u25B2", question_pending: "?", error: "\u2717", @@ -145,7 +149,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 +158,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 +170,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 +181,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 +189,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 +204,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 +219,7 @@ function fitContentWidth(col: ColumnId, sessions: Session[]): number { return maxLen; } -function allocateWidths( +export function allocateWidths( columns: ColumnId[], totalWidth: number, sessions: Session[], @@ -246,7 +250,7 @@ function allocateWidths( return widths.map((w) => Math.max(w, 1)); } -function renderCell( +export function renderCell( col: ColumnId, session: Session, width: number, @@ -409,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(); @@ -433,7 +441,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; } @@ -453,7 +461,6 @@ export function SessionList({ columns, onSelect }: SessionListProps) { } }); - const inTmux = isInsideTmux(); const colWidths = allocateWidths(columns, width, sessions); const header = ( @@ -466,13 +473,13 @@ export function SessionList({ columns, onSelect }: SessionListProps) { {sessions.length} opencode process {sessions.length !== 1 ? "es" : ""} on {hostname()} - {!inTmux ? (not in tmux) : null} + ); const footer = ( - j/k: navigate enter: attach q: quit + j/k: navigate enter: attach esc: quit ); @@ -483,8 +490,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/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/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 new file mode 100644 index 0000000..23fd06b --- /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"); + +const { querySessions, hasDbChanged, cleanupStaleSessions, closeDb, dbExists, getDbPath, setDbPath, STALE_THRESHOLD_MS } = await import("./db.ts"); + +setDbPath(testDbPath); + +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 path set via setDbPath", () => { + 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/db.ts b/tui-ts/src/db.ts index 517df8a..74c2411 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 { @@ -70,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; @@ -83,15 +93,21 @@ function getDb(): Database | null { return _db; } catch { _db = null; + _sessionsStmt = null; return null; } } export function closeDb(): void { + _sessionsStmt = null; _db?.close(); _db = null; } +export function warmDb(): void { + getDb(); +} + let _lastDataVersion: number | null = null; export function hasDbChanged(): boolean { @@ -123,10 +139,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 []; 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); + }); +}); diff --git a/tui-ts/src/theme.ts b/tui-ts/src/theme.ts index 0d05908..4a899ad 100644 --- a/tui-ts/src/theme.ts +++ b/tui-ts/src/theme.ts @@ -352,7 +352,15 @@ const themes: Record = { }, }; -function readOpenCodeTheme(): string | null { +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 { const kvPath = join( process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "opencode", @@ -370,6 +378,9 @@ function readOpenCodeTheme(): string | null { } export function getTheme(): Theme { - const name = process.env.PULSE_THEME || readOpenCodeTheme() || "opencode"; - return themes[name] || themes.opencode; + if (_cachedTheme) return _cachedTheme; + const name = _themeName || process.env.PULSE_THEME || readOpenCodeTheme() || "opencode"; + const resolved = themes[name] || themes.opencode; + if (_themeName) _cachedTheme = resolved; + return resolved; }