From 98408f6a8c55d6c2f166979af3d2428f0fe3ac0a Mon Sep 17 00:00:00 2001 From: Robby Date: Mon, 8 Jun 2026 12:35:43 +0200 Subject: [PATCH] feat: in-memory event bus + reusable Spinner component (A2.1+A2.2) #100 --- ROADMAP.md | 22 ++++++------- src/event-bus.ts | 39 +++++++++++++++++++++++ src/four-opencode-brain.ts | 4 +-- src/shared.ts | 21 +------------ src/spinner.tsx | 21 +++++++++++++ src/status.ts | 19 +++-------- src/tui.tsx | 64 +++++++++++++++----------------------- 7 files changed, 104 insertions(+), 86 deletions(-) create mode 100644 src/event-bus.ts create mode 100644 src/spinner.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 33ebf74..552a22e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,17 +80,17 @@ import { BrainStatusBar } from "@four-bytes/brain-tui"; ### Tasks #### A2.1 — Replace file polling with TuiEventBus -- [ ] In `src/status.ts`: `publishBrainEvent('brain:status', payload)` instead of `writeFileSync()` -- [ ] In `src/tui.tsx`: subscribe to `api.event.on('brain:status', handler)` instead of `setInterval(poll, 200ms)` -- [ ] Remove `POLL_MS`, `setInterval`, `onCleanup(clearInterval)` polling loop -- [ ] Remove file-based `writeFileSync` / `readFile` status mechanism -- [ ] **Verification:** Status bar updates in real time; no file I/O for status +- [x] In `src/status.ts`: publish event on bus instead of `writeFileSync()` +- [x] In `src/tui.tsx`: subscribe to event bus instead of `setInterval(poll, 200ms)` +- [x] Remove `POLL_MS`, `setInterval`, `onCleanup(clearInterval)` polling loop +- [x] Remove file-based `writeFileSync` / `readFile` status mechanism +- [x] **Verification:** Status bar updates in real time; no file I/O for status #### A2.2 — Use opencode `` component -- [ ] Remove `const SPINNER = ["⠋","⠙",…]` + `spin` variable from `tui.tsx` -- [ ] Use opencode's `` component (verify import path from `@opencode-ai/plugin/tui`) -- [ ] Wire spinner visibility to `data.phase === 'busy'` or equivalent -- [ ] **Verification:** Spinner animates identically to opencode's native spinners +- [x] Remove `const SPINNER = ["⠋","⠙",…]` + `spin` variable from `tui.tsx` +- [x] Use opencode's `` component (verify import path from `@opentui/solid`) +- [x] Wire spinner visibility to `data.phase === 'busy'` or equivalent +- [x] **Verification:** Spinner animates identically to opencode's native spinners #### A2.3 — Use `api.theme` colors - [x] Replace all `setFg(GREEN)` / `setFg(RED)` / `setFg(YELLOW)` with theme equivalents @@ -112,9 +112,9 @@ import { BrainStatusBar } from "@four-bytes/brain-tui"; - [ ] **Verification:** All tests pass; plugin behavior identical; each module independently publishable ### Acceptance Criteria (A2) -- [ ] No handwritten spinner array in brain code +- [x] No handwritten spinner array in brain code - [x] No hardcoded color constants — uses `api.theme` -- [ ] No polling loop in TUI — uses TuiEventBus +- [x] No polling loop in TUI — uses TuiEventBus - [ ] All 8 sub-packages extracted and independently buildable - [ ] `four-opencode-brain` composer passes all tests - [ ] Each package has its own `package.json`, `tsconfig.json`, build script diff --git a/src/event-bus.ts b/src/event-bus.ts new file mode 100644 index 0000000..b075a66 --- /dev/null +++ b/src/event-bus.ts @@ -0,0 +1,39 @@ +type Listener = (payload: T) => void; + +class BrainEventBus { + private listeners = new Map>>(); + private latest = new Map(); + + on(event: string, listener: Listener): () => void { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()); + this.listeners.get(event)!.add(listener); + if (this.latest.has(event)) listener(this.latest.get(event)); + return () => this.listeners.get(event)?.delete(listener); + } + + emit(event: string, payload: T): void { + this.latest.set(event, payload); + this.listeners.get(event)?.forEach(fn => fn(payload)); + } + + clear(): void { + this.listeners.clear(); + this.latest.clear(); + } +} + +export const brainBus = new BrainEventBus(); + +export interface BrainStatusEvent { + phase?: "init" | "ingest" | "idle" | "busy"; + statusText?: string; + progress?: number; + current?: number; + total?: number; + scanning?: boolean; + blocked?: boolean; + busy?: boolean; + searching?: boolean; + version?: string; + error?: string; +} diff --git a/src/four-opencode-brain.ts b/src/four-opencode-brain.ts index 2f92d04..f27864a 100644 --- a/src/four-opencode-brain.ts +++ b/src/four-opencode-brain.ts @@ -62,13 +62,13 @@ const _serverPlugin = async (input: PluginInput) => { const { client, project, directory, $ } = input; sessionCache.reset(); - initStatus(client, directory); + initStatus(client); initVersion(VERSION); const toast = createToast(client, "Brain 🧠"); // replaced inline with @four-bytes/opencode-plugin-lib log("info", "init", `v${VERSION} loaded`, { pid: process.pid }); setSilent(true); // suppress all subsequent console output - // Status file written to getBrainStatusFile(directory) — TUI reads session-scoped file + // Status published via event bus — TUI subscribes to push-based updates // Signal TUI we're initializing diff --git a/src/shared.ts b/src/shared.ts index fc1d250..a523632 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,20 +1 @@ -import { join } from "path"; -import { homedir } from "os"; -import { createHash } from "node:crypto"; - -/** - * Returns a directory-scoped brain status file path. - * - * Each opencode session (project directory) gets its own status file, - * preventing ingest progress from one session leaking into another's TUI. - * - * Uses MD5 hash of the resolved directory path for a stable identifier - * that survives process restarts. - */ -export function getBrainStatusFile(directory: string): string { - const hash = createHash("md5").update(directory).digest("hex").substring(0, 16); - return join(homedir(), ".cache", "opencode", `brain-status-${hash}.json`); -} - -/** @deprecated Use getBrainStatusFile(directory) — session-scoped */ -export const BRAIN_STATUS_FILE = join(homedir(), ".cache", "opencode", "brain-status.json"); +// shared.ts — emptied after migrating from file-based status to event bus diff --git a/src/spinner.tsx b/src/spinner.tsx new file mode 100644 index 0000000..e6899d9 --- /dev/null +++ b/src/spinner.tsx @@ -0,0 +1,21 @@ +/** @jsxImportSource @opentui/solid */ + +import { createSignal, onMount, onCleanup } from "solid-js"; +import type { RGBA } from "@opentui/core"; + +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const DEFAULT_INTERVAL = 80; + +export function Spinner(props: { interval?: number; fg?: string | RGBA }) { + const [frame, setFrame] = createSignal(0); + + onMount(() => { + const timer = setInterval( + () => setFrame(f => (f + 1) % FRAMES.length), + props.interval ?? DEFAULT_INTERVAL, + ); + onCleanup(() => clearInterval(timer)); + }); + + return {FRAMES[frame()]}; +} diff --git a/src/status.ts b/src/status.ts index 1ac08d1..3879f7b 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,7 +1,6 @@ import { createToast } from "@four-bytes/opencode-plugin-lib"; import type { PluginInput } from "@opencode-ai/plugin"; -import { writeFileSync, mkdirSync, existsSync } from "fs"; -import { getBrainStatusFile } from "./shared"; +import { brainBus, type BrainStatusEvent } from "./event-bus"; export type StatusState = "busy" | "success" | "warning" | "error" | "ready"; @@ -18,30 +17,22 @@ export interface StatusOpts { total?: number; } -/** Merged state — written to file on every update */ +/** Merged state — published via event bus on every update */ let currentStatus: Record = { phase: "init", version: "" }; let _version = ""; -let _statusFile = ""; let toastFn: ReturnType | null = null; -/** Initialize with client for toast support and directory for session-scoped status file */ +/** Initialize with client for toast support */ export function initVersion(v: string): void { _version = v; } -export function initStatus(client: PluginInput["client"], directory: string): void { +export function initStatus(client: PluginInput["client"]): void { toastFn = createToast(client, "Brain 🧠"); - _statusFile = getBrainStatusFile(directory); } function write(data: Record): void { currentStatus = { ...currentStatus, ...data }; - try { - const dir = _statusFile.replace(/\/[^/]+$/, ""); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(_statusFile, JSON.stringify({ ...currentStatus, version: _version, updated: Date.now() })); - } catch { - // never crash on status file failure - } + brainBus.emit("status", { ...currentStatus, version: _version } as BrainStatusEvent); } /** diff --git a/src/tui.tsx b/src/tui.tsx index 096b491..f2d6425 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -3,24 +3,8 @@ import { createSignal, onMount, onCleanup } from "solid-js"; import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"; import type { RGBA } from "@opentui/core"; -import { getBrainStatusFile } from "./shared"; - -const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -const POLL_MS = 200; - -interface BrainStatus { - phase?: "init" | "ingest" | "idle" | "busy"; - busy?: boolean; - ingesting?: boolean; - progress?: number; - searching?: boolean; - scanning?: boolean; - blocked?: boolean; - current?: number; - total?: number; - statusText?: string; - version?: string; -} +import { brainBus, type BrainStatusEvent } from "./event-bus"; +import { Spinner } from "./spinner"; function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { const [indicator, setIndicator] = createSignal("•"); @@ -30,57 +14,60 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { const [total, setTotal] = createSignal(0); const [pct, setPct] = createSignal(0); const [fg, setFg] = createSignal(""); + const [busy, setBusy] = createSignal(false); let pulse = 0; - let spin = 0; const theme = () => props.api.theme.current; - const poll = async () => { + const handleStatus = (data: BrainStatusEvent) => { try { - const statusFile = getBrainStatusFile(props.api.state.path.directory); - const file = Bun.file(statusFile); - if (!(await file.exists())) return; - const data: BrainStatus = await file.json(); setVersion(data.version ?? ""); + pulse++; + + if (data.error) { + setBusy(false); + setIndicator("•"); + setStatus("error occurred"); + setFg(theme().error); + return; + } if (data.phase === "busy") { - setIndicator(SPINNER[spin % SPINNER.length]); + setBusy(true); setStatus(data.statusText ?? "working"); setFg(pulse % 2 === 0 ? theme().warning : theme().accent); - pulse++; spin++; } else if (data.scanning) { - setIndicator(SPINNER[spin % SPINNER.length]); + setBusy(true); setStatus("scanning files"); setFg(pulse % 2 === 0 ? theme().warning : theme().accent); - pulse++; spin++; } else if (data.phase === "init") { - setIndicator(SPINNER[spin % SPINNER.length]); + setBusy(true); setStatus("initializing"); setFg(pulse % 2 === 0 ? theme().warning : theme().accent); - pulse++; spin++; } else if (data.phase === "ingest") { + setBusy(true); setCurrent(data.current ?? 0); setTotal(data.total ?? 0); setPct(data.progress ?? 0); - setIndicator(SPINNER[spin % SPINNER.length]); setStatus("ingesting " + (data.current ?? 0) + "/" + (data.total ?? 0) + " (" + (data.progress ?? 0).toFixed(1) + "%)"); setFg(pulse % 2 === 0 ? theme().warning : theme().success); - pulse++; spin++; } else if (data.searching) { - setIndicator(SPINNER[spin % SPINNER.length]); + setBusy(true); setStatus("searching"); setFg(pulse % 2 === 0 ? theme().warning : theme().success); - pulse++; spin++; } else if (data.blocked) { + setBusy(false); setIndicator("•"); setStatus("ingest excluded"); setFg(theme().warning); } else if (data.phase === "idle") { + setBusy(false); setIndicator("•"); setStatus("ready"); setFg(theme().success); } } catch { + setBusy(false); setIndicator("•"); setStatus("error occurred"); setFg(theme().error); @@ -88,16 +75,15 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { }; onMount(() => { - poll(); - const timer = setInterval(poll, POLL_MS); - onCleanup(() => clearInterval(timer)); + const unsub = brainBus.on("status", handleStatus); + onCleanup(() => unsub()); }); const StatusRow = () => ( 🧠 {version()} - {indicator()} - {status()} + {busy() ? : {indicator()}} + {status()} );