-
Notifications
You must be signed in to change notification settings - Fork 0
feat: migrate to scoped bus API (forService/forSession) #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,161 +1,134 @@ | ||
| /** @jsxImportSource @opentui/solid */ | ||
|
|
||
| import { createSignal, createEffect, onMount, onCleanup } from "solid-js"; | ||
| import { createSignal, onMount, onCleanup, Show } from "solid-js"; | ||
| import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"; | ||
| import type { RGBA } from "@opentui/core"; | ||
| import { BusTui } from "@four-bytes/opencode-plugin-lib/tui"; | ||
| import { ProgressBar } from "@four-bytes/opencode-plugin-lib/tui-components"; | ||
| import type { BrainStatusEvent } from "./event-bus"; | ||
| import { Spinner } from "./spinner"; | ||
| function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi; sessionId?: string }) { | ||
| const [indicator, setIndicator] = createSignal("•"); | ||
| const [status, setStatus] = createSignal("connecting…"); | ||
|
|
||
| function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; sessionId?: string }) { | ||
| const [statusText, setStatusText] = createSignal("connecting…"); | ||
| const [version, setVersion] = createSignal(""); | ||
| const [current, setCurrent] = createSignal(0); | ||
| const [total, setTotal] = createSignal(0); | ||
| const [pct, setPct] = createSignal(0); | ||
| const [fg, setFg] = createSignal<string | RGBA>(""); | ||
| const [busy, setBusy] = createSignal(false); | ||
| let pulse = 0; | ||
| const [hasError, setHasError] = createSignal(false); | ||
| let lastPoll = Date.now(); | ||
|
|
||
| const theme = () => props.api.theme.current; | ||
| const connecting = () => (!version() || Date.now() - lastPoll > 2000) && !busy(); | ||
| const showProgress = () => busy() && current() > 0 && total() > 0; | ||
|
|
||
| const handleStatus = (data: BrainStatusEvent) => { | ||
| try { | ||
| lastPoll = Date.now(); | ||
| setVersion(data.version ?? ""); | ||
| pulse++; | ||
| setHasError(false); | ||
|
|
||
| if (data.status === "error") { | ||
| setBusy(false); | ||
| setIndicator("•"); | ||
| setStatus(data.error || data.statusText || "error occurred"); | ||
| setStatusText(data.error || data.statusText || "error occurred"); | ||
| setFg(theme().error); | ||
| setHasError(true); | ||
| } else if (data.status === "init") { | ||
| setBusy(true); | ||
| setStatus(data.statusText ?? "initializing…"); | ||
| setStatusText(data.statusText ?? "initializing…"); | ||
| setCurrent(0); | ||
| setTotal(0); | ||
| setFg(theme().warning); | ||
| } else if (data.status === "busy") { | ||
| setBusy(true); | ||
| setCurrent(data.current ?? 0); | ||
| setTotal(data.total ?? 0); | ||
| setStatus(data.statusText ?? "working…"); | ||
| setFg(pulse % 2 === 0 ? theme().warning : theme().accent); | ||
| setStatusText(data.statusText ?? "working…"); | ||
| setFg(theme().warning); | ||
| } else { | ||
| setCurrent(0); | ||
| setTotal(0); | ||
| setBusy(false); | ||
| setIndicator("•"); | ||
| setStatus("ready"); | ||
| setStatusText("ready"); | ||
| setFg(theme().success); | ||
| } | ||
| } catch { | ||
| setBusy(false); | ||
| setIndicator("•"); | ||
| setStatus("error occurred"); | ||
| setStatusText("error occurred"); | ||
| setFg(theme().error); | ||
| setHasError(true); | ||
| } | ||
| }; | ||
|
|
||
| onMount(() => { | ||
| // Bus is a signal so createEffect below can react when BusTui resolves. | ||
| const [bus, setBus] = createSignal<BusTui | null>(null); | ||
| let unsub: (() => void) | null = null; | ||
| let sessionUnsub: (() => void) | null = null; | ||
| let unmounted = false; | ||
|
|
||
| onCleanup(() => { | ||
| unmounted = true; | ||
| unsub?.(); | ||
| sessionUnsub?.(); | ||
| bus()?.close(); | ||
| }); | ||
|
|
||
| // Real-time WebSocket subscription via plugin bus. | ||
| // HTTP fallback removed — bus is the only transport; status server stays | ||
| // for any external probes (e.g. /status endpoint) but TUI never polls it. | ||
| BusTui.connect() | ||
| .then((b) => { | ||
| if (unmounted) { b.close(); return; } | ||
| setBus(b); | ||
| // Always subscribe to brain/status — server publishes here during ingest, | ||
| // before any chat message creates a session. Once sessionId is known, | ||
| // also subscribe to the per-session channel (server switches to it | ||
| // after first chat.message). Prevents missing pre-session status updates. | ||
| unsub = b.subscribe("brain/status", (envelope) => { | ||
| const data = envelope.payload as BrainStatusEvent & { sessionId?: string }; | ||
| // Only process if no session ID OR matches current session | ||
| if (data.sessionId && props.sessionId && data.sessionId !== props.sessionId) return; | ||
| handleStatus(data); | ||
| // Scoped subscription: forService("brain") + forSession(sid) replaces | ||
| // the old brain/{sid} channel. No sessionId filter needed — the bus | ||
| // only delivers events for the scoped session (or unscoped when sid missing). | ||
| const scoped = b.forService("brain"); | ||
| const brainBus = props.sessionId ? scoped.forSession(props.sessionId) : scoped; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Subscription scope is fixed at mount and does not track later Prompt for AI agents |
||
| unsub = brainBus.subscribe("status", (envelope) => { | ||
| handleStatus(envelope.payload as BrainStatusEvent); | ||
| }); | ||
| }) | ||
| .catch((err) => { | ||
| console.warn("[brain TUI] BusTui connect failed:", (err as Error).message); | ||
| }); | ||
|
|
||
| // React to props.sessionId changes — sessionId is set later by the host | ||
| // (after first chat.message), so it is often undefined at mount time. | ||
| // Tracks bus() so the effect re-runs once BusTui.connect() resolves. | ||
| createEffect(() => { | ||
| const sid = props.sessionId; | ||
| const b = bus(); | ||
| sessionUnsub?.(); | ||
| if (!sid || !b) return; | ||
| sessionUnsub = b.subscribe(`brain/${sid}`, (envelope) => { | ||
| const data = envelope.payload as BrainStatusEvent & { sessionId?: string }; | ||
| if (data.sessionId && data.sessionId !== sid) return; | ||
| handleStatus(data); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| const StatusRow = () => ( | ||
| <box width="100%" flexDirection="row"> | ||
| <text fg={theme().textMuted}>🧠 {version()} </text> | ||
| {busy() ? <Spinner fg={fg()} /> : <text fg={connecting() ? theme().error : fg()}>{indicator()}</text>} | ||
| <text fg={connecting() ? theme().error : theme().textMuted}> {connecting() ? "connecting…" : status()}</text> | ||
| {busy() && current() > 0 && total() > 0 && ( | ||
| <text> </text> | ||
| )} | ||
| {busy() && current() > 0 && total() > 0 && ( | ||
| <ProgressBar current={current()} total={total()} showLabel={true} fillBg="#aaa" fillFg="#000" width="auto" /> | ||
| )} | ||
| </box> | ||
| ); | ||
| const indicatorColor = () => connecting() ? theme().error : (hasError() ? theme().error : fg()); | ||
| const textColor = () => connecting() ? theme().error : theme().textMuted; | ||
|
|
||
| return ( | ||
| <box width="100%"> | ||
| {props.centered ? ( | ||
| <box> | ||
| <text> </text> | ||
| <box width="100%" flexDirection="row" justifyContent="center"> | ||
| <text fg={theme().text}><b>Brain</b></text> | ||
| <text> </text> | ||
| <StatusRow /> | ||
| </box> | ||
| </box> | ||
| ) : ( | ||
| <box width="100%"> | ||
| <Show when={props.variant === "sidebar"}> | ||
| {/* Sidebar: header row + status row (two-column: text left, bar right) */} | ||
| <box width="100%" flexDirection="column"> | ||
| <box flexDirection="row"> | ||
| <text fg={theme().text}><b>Brain</b></text> | ||
| <text fg={theme().textMuted}> 🧠 {version()}</text> | ||
| </box> | ||
| <box width="100%" flexDirection="row"> | ||
| {busy() ? <Spinner fg={fg()} /> : <text fg={connecting() ? theme().error : fg()}>{indicator()}</text>} | ||
| <text fg={connecting() ? theme().error : theme().textMuted}> {connecting() ? "connecting…" : status()}</text> | ||
| {busy() && current() > 0 && total() > 0 && ( | ||
| <text> </text> | ||
| )} | ||
| {busy() && current() > 0 && total() > 0 && ( | ||
| <ProgressBar current={current()} total={total()} showLabel={true} fillBg="#aaa" fillFg="#000" width="auto" /> | ||
| )} | ||
| <box width="100%" flexDirection="row" justifyContent="space-between"> | ||
| <box flexDirection="row" flexShrink={1}> | ||
| {busy() ? <Spinner fg={fg()} /> : <text fg={indicatorColor()}>•</text>} | ||
| <text fg={textColor()}> {connecting() ? "connecting…" : statusText()}</text> | ||
| <Show when={showProgress()}> | ||
| <text fg={theme().textMuted}> {current()}/{total()}</text> | ||
| </Show> | ||
| </box> | ||
| <Show when={showProgress()}> | ||
| <box flexShrink={0}> | ||
| <ProgressBar current={current()} total={total()} showLabel={false} width="auto" barWidth={12} fillBg="#aaa" fillFg="#000" /> | ||
| </box> | ||
| </Show> | ||
| </box> | ||
| </box> | ||
| )} | ||
| </Show> | ||
| <Show when={props.variant === "home"}> | ||
| {/* Home: single compact row, centered */} | ||
| <box width="100%" flexDirection="row" justifyContent="center"> | ||
| <text fg={theme().textMuted}>🧠 {version()} </text> | ||
| {busy() ? <Spinner fg={fg()} /> : <text fg={indicatorColor()}>•</text>} | ||
| <text fg={textColor()}> {connecting() ? "connecting…" : statusText()}</text> | ||
| <Show when={showProgress()}> | ||
| <text fg={theme().textMuted}> </text> | ||
| <ProgressBar current={current()} total={total()} showLabel={false} width="auto" barWidth={10} fillBg="#aaa" fillFg="#000" /> | ||
| </Show> | ||
| </box> | ||
| </Show> | ||
| </box> | ||
| ); | ||
| } | ||
|
|
@@ -164,10 +137,10 @@ export { BrainStatusBar }; | |
|
|
||
| const tui: TuiPlugin = (api) => { | ||
| api.slots.register({ | ||
| order: 60, // below deepseek-meter (55) | ||
| order: 60, | ||
| slots: { | ||
| sidebar_content: (_ctx: any, props: any) => <BrainStatusBar api={api} sessionId={props.session_id} />, | ||
| home_bottom: () => <BrainStatusBar api={api} centered />, | ||
| sidebar_content: (_ctx: any, props: any) => <BrainStatusBar api={api} variant="sidebar" sessionId={props.session_id} />, | ||
| home_bottom: () => <BrainStatusBar api={api} variant="home" />, | ||
| }, | ||
| }); | ||
| return Promise.resolve(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type safety violation:
sessionIdis not inBrainStatusEventinterface.Line 135 adds
sessionIdto the payload and casts it toBrainStatusEvent, but the interface (defined insrc/event-bus.ts:5-12) does not include asessionIdfield. This bypasses TypeScript's type checking and could cause runtime issues if consumers expect only the declared fields.Proposed fix: Add sessionId to BrainStatusEvent interface
Update
src/event-bus.tsto include the sessionId field:export interface BrainStatusEvent { status?: "init" | "busy" | "ready" | "error"; statusText?: string; current?: number; total?: number; version?: string; error?: string; + sessionId?: string; }Then the cast on line 135 will be type-safe.
Prompt for AI Agents