Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Spinner>` component
- [ ] Remove `const SPINNER = ["⠋","⠙",…]` + `spin` variable from `tui.tsx`
- [ ] Use opencode's `<Spinner />` 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 `<Spinner />` 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
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/event-bus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
type Listener<T> = (payload: T) => void;

class BrainEventBus {
private listeners = new Map<string, Set<Listener<any>>>();
private latest = new Map<string, any>();

on<T>(event: string, listener: Listener<T>): () => 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<T>(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;
}
4 changes: 2 additions & 2 deletions src/four-opencode-brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 1 addition & 20 deletions src/shared.ts
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions src/spinner.tsx
Original file line number Diff line number Diff line change
@@ -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 <text fg={props.fg}>{FRAMES[frame()]}</text>;
}
19 changes: 5 additions & 14 deletions src/status.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<string, unknown> = { phase: "init", version: "" };
let _version = "";
let _statusFile = "";

let toastFn: ReturnType<typeof createToast> | 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<string, unknown>): 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);
}

/**
Expand Down
64 changes: 25 additions & 39 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("•");
Expand All @@ -30,74 +14,76 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) {
const [total, setTotal] = createSignal(0);
const [pct, setPct] = createSignal(0);
const [fg, setFg] = createSignal<string | RGBA>("");
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);
}
};

onMount(() => {
poll();
const timer = setInterval(poll, POLL_MS);
onCleanup(() => clearInterval(timer));
const unsub = brainBus.on("status", handleStatus);
onCleanup(() => unsub());
});

const StatusRow = () => (
<box flexDirection="row">
<text fg={theme().textMuted}>🧠 {version()} </text>
<text fg={fg()}>{indicator()}</text>
<text fg={theme().textMuted}> {status()}</text>
{busy() ? <Spinner fg={fg()} /> : <text fg={fg()}>{indicator()}</text>}
<text fg={busy() ? fg() : theme().textMuted}> {status()}</text>
</box>
);

Expand Down
Loading