From 0a8959f024211644323cbccdfa4b0d2129d0b65b Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Sun, 22 Feb 2026 16:45:36 -0600 Subject: [PATCH 01/12] feat(quantum): add sidebar dashboard with credits, jobs, and compute status Add quantum resource dashboard to the TUI sidebar and footer: - QuantumState: centralized reactive store tracking credits balance, active jobs, and compute instance status via Bus events - QuantumPoller: Scheduler-based adaptive polling (credits/compute 60s, jobs 30s when active) with auto-detection of API key mid-session - QuantumSidebarSection: collapsible sidebar panel showing credit balance, active job rows with status dots and elapsed time, compute status - QuantumFooterIndicator: compact footer indicator (active QPU count) - Extended client.ts with getCredits (billing/credits/balance endpoint), getComputeStatus, listActiveJobs, and Zod schemas for validation - Fixed getCredits return type mismatch in estimate_cost tool - Tools (submit, cancel, get_result) trigger state refresh for immediate sidebar updates after quantum operations - Wired poller and initial refresh into InstanceBootstrap --- .../cli/cmd/tui/component/quantum-status.tsx | 210 ++++++++++++++++++ .../src/cli/cmd/tui/routes/session/footer.tsx | 2 + .../cli/cmd/tui/routes/session/sidebar.tsx | 6 + packages/opencode/src/project/bootstrap.ts | 7 + packages/opencode/src/quantum/client.ts | 59 ++++- packages/opencode/src/quantum/index.ts | 2 + packages/opencode/src/quantum/poller.ts | 76 +++++++ packages/opencode/src/quantum/state.ts | 172 ++++++++++++++ packages/opencode/src/quantum/tools.ts | 23 +- 9 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx create mode 100644 packages/opencode/src/quantum/poller.ts create mode 100644 packages/opencode/src/quantum/state.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx b/packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx new file mode 100644 index 00000000000..5125c79cac3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx @@ -0,0 +1,210 @@ +/** + * Quantum Status Components + * + * Two components for displaying qBraid quantum resource status: + * - QuantumSidebarSection: collapsible section for the sidebar panel + * - QuantumFooterIndicator: compact single indicator for the footer bar + * + * Both subscribe to QuantumState bus events for live updates. + * Design: minimal when idle, progressive disclosure when resources are active. + */ + +import { createSignal, onMount, onCleanup, Show, For } from "solid-js" +import { useTheme } from "../context/theme" +import { Bus } from "@/bus" +import * as QuantumState from "@/quantum/state" +import type { State, JobSummary } from "@/quantum/state" + +function useQuantumState() { + const [state, setState] = createSignal(QuantumState.get()) + + onMount(() => { + const unsub = Bus.subscribe(QuantumState.Event.Updated, () => { + setState({ ...QuantumState.get() }) + }) + onCleanup(unsub) + }) + + return state +} + +function formatCredits(n: number): string { + if (n >= 1000) return `$${(n / 100).toFixed(0)}` + if (n >= 10) return `$${(n / 100).toFixed(2)}` + return `$${(n / 100).toFixed(2)}` +} + +function formatElapsed(createdAt: number): string { + const ms = Date.now() - createdAt + if (ms < 60_000) return `${Math.floor(ms / 1000)}s` + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` + return `${Math.floor(ms / 3_600_000)}h` +} + +function shortDevice(device: string): string { + // "aws_ionq_aria" → "IonQ Aria", "ibm_brisbane" → "IBM Brisbane" + const parts = device.replace(/^(aws_|ibm_|google_)/, "").split("_") + return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ").slice(0, 16) +} + +// --- Sidebar Section --- + +export function QuantumSidebarSection(props: { expanded: boolean; onToggle: () => void }) { + const { theme } = useTheme() + const state = useQuantumState() + + const hasActivity = () => { + const s = state() + if (!s.configured) return false + const activeJobs = s.jobs?.active.length ?? 0 + const computeActive = s.compute?.status === "running" || s.compute?.status === "starting" + return activeJobs > 0 || computeActive + } + + const creditsText = () => { + const c = state().credits + if (!c) return "" + return formatCredits(c.qbraid) + } + + const lowCredits = () => { + const c = state().credits + return c != null && c.qbraid < 500 // less than $5 + } + + return ( + + + + {props.expanded ? "▼" : "▶"} + + + qBraid + + + + + + {creditsText()} + + + + + + + + + + + ) +} + +function JobsSection(props: { + jobs: State["jobs"] +}) { + const { theme } = useTheme() + + return ( + Loading jobs...} + > + {(jobs) => ( + <> + 0} + fallback={ + + {" "}No active jobs + 0}> + · {jobs().recentDone} done today + + + } + > + + {(job) => } + + 4}> + {" "}+{jobs().active.length - 4} more + + + 0}> + {" "}{jobs().recentFailed} failed + + + )} + + ) +} + +function JobRow(props: { job: JobSummary }) { + const { theme } = useTheme() + const dot = () => { + const s = props.job.status + if (s === "RUNNING") return theme.success + if (s === "QUEUED" || s === "INITIALIZING") return theme.warning + if (s === "FAILED" || s === "CANCELLED") return theme.error + return theme.textMuted + } + const label = () => { + if (props.job.status === "RUNNING") return formatElapsed(props.job.createdAt) + return props.job.status.toLowerCase() + } + + return ( + + + {" "} {shortDevice(props.job.device)} + + {label()} + + ) +} + +function ComputeSection(props: { compute: State["compute"] }) { + const { theme } = useTheme() + + const label = () => { + const c = props.compute + if (!c) return null + if (c.status === "running") return { text: `${c.profile ?? "instance"} running`, fg: theme.success } + if (c.status === "starting") return { text: "starting...", fg: theme.warning } + return null + } + + return ( + + {(l) => ( + + {" "} {l().text} + + )} + + ) +} + +// --- Footer Indicator --- + +export function QuantumFooterIndicator() { + const { theme } = useTheme() + const state = useQuantumState() + + const visible = () => state().configured + const activeCount = () => state().jobs?.active.length ?? 0 + + const indicator = () => { + const n = activeCount() + if (n > 0) return { fg: theme.success, text: `⚛ ${n} QPU` } + return { fg: theme.textMuted, text: "⚛ qBraid" } + } + + return ( + + + {" "} + {activeCount() > 0 ? `${activeCount()} QPU` : "qBraid"} + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff372..6cf7a4339d9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -5,6 +5,7 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/dialog-model" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { QuantumFooterIndicator } from "../../component/quantum-status" export function Footer() { const { theme } = useTheme() @@ -66,6 +67,7 @@ export function Footer() { {permissions().length > 1 ? "s" : ""} + 0 ? theme.success : theme.textMuted }}>• {lsp().length} LSP diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4ffe91558ed..4059d3fd3bb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,6 +11,7 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { QuantumSidebarSection } from "../../component/quantum-status" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -21,6 +22,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) const [expanded, setExpanded] = createStore({ + qbraid: true, mcp: true, diff: true, todo: true, @@ -98,6 +100,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent + setExpanded("qbraid", !expanded.qbraid)} + /> 0}> { + Log.Default.warn("quantum state initialization failed", { error }) + }) + Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { await Project.setInitialized(Instance.project.id) diff --git a/packages/opencode/src/quantum/client.ts b/packages/opencode/src/quantum/client.ts index 2be977dcb4b..a70570dbbdd 100644 --- a/packages/opencode/src/quantum/client.ts +++ b/packages/opencode/src/quantum/client.ts @@ -285,11 +285,66 @@ export async function listJobs( return arr.map((j: unknown) => QuantumJobSchema.parse(j)) } +// --- Zod schemas for credits / compute --- + +const CreditsBalanceSchema = z.object({ + qbraidCredits: z.number().default(0), + awsCredits: z.number().default(0), + autoRecharge: z.boolean().optional(), + organizationId: z.string().optional(), + userId: z.string().optional(), +}) + +const ComputeStatusSchema = z.object({ + status: z.enum(["running", "stopped", "starting", "stopping", "error"]).catch("stopped"), + profile: z.string().optional(), + uptime: z.number().optional(), +}) + +export type CreditsBalance = z.infer +export type ComputeStatus = z.infer + /** * Get account credit balance. + * Uses /billing/credits/balance which returns qbraidCredits + awsCredits. + */ +export async function getCredits(signal?: AbortSignal): Promise { + const data = await request("GET", "/billing/credits/balance", undefined, signal) + if (typeof data === "object" && data !== null && "data" in data) { + return CreditsBalanceSchema.parse((data as Record).data) + } + return CreditsBalanceSchema.parse(data) +} + +/** + * Get compute server status. + * Returns the current state of the user's JupyterHub compute server. + */ +export async function getComputeStatus(signal?: AbortSignal): Promise { + try { + const data = await request("GET", "/compute/servers/status", undefined, signal) + if (typeof data === "object" && data !== null && "data" in data) { + return ComputeStatusSchema.parse((data as Record).data) + } + return ComputeStatusSchema.parse(data) + } catch { + return { status: "stopped" } + } +} + +/** + * List active/recent jobs (limited to 10 most recent). + * Convenience wrapper for sidebar polling. */ -export async function getCredits(signal?: AbortSignal): Promise<{ balance: number }> { - return request<{ balance: number }>("GET", "/user/credits", undefined, signal) +export async function listActiveJobs(signal?: AbortSignal): Promise { + const params = new URLSearchParams() + params.set("limit", "10") + const endpoint = `/quantum/jobs?${params.toString()}` + const data = await request("GET", endpoint, undefined, signal) + const arr = Array.isArray(data) + ? data + : (data as { jobs?: unknown[] }).jobs ?? [] + return arr.map((j: unknown) => QuantumJobSchema.parse(j)) } /** diff --git a/packages/opencode/src/quantum/index.ts b/packages/opencode/src/quantum/index.ts index 6c94f21d3fe..6703ddd67a5 100644 --- a/packages/opencode/src/quantum/index.ts +++ b/packages/opencode/src/quantum/index.ts @@ -11,3 +11,5 @@ export { QUANTUM_TOOLS } from "./tools" export * as QuantumClient from "./client" +export * as QuantumState from "./state" +export * as QuantumPoller from "./poller" diff --git a/packages/opencode/src/quantum/poller.ts b/packages/opencode/src/quantum/poller.ts new file mode 100644 index 00000000000..c18d0268fed --- /dev/null +++ b/packages/opencode/src/quantum/poller.ts @@ -0,0 +1,76 @@ +/** + * Quantum State Poller + * + * Registers a Scheduler task that refreshes quantum state on an interval. + * Uses adaptive polling rates: credits/compute every 60s, jobs every 30s + * when active. Skipped entirely if no qBraid API key is configured. + * + * Timestamps are stored in Instance.state() for per-instance isolation. + */ + +import { Scheduler } from "../scheduler" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import * as QuantumState from "./state" + +const log = Log.create({ service: "quantum:poller" }) + +const CREDITS_INTERVAL = 60_000 +const JOBS_INTERVAL = 30_000 +const COMPUTE_INTERVAL = 60_000 + +const state = Instance.state(() => ({ + lastCredits: 0, + lastJobs: 0, + lastCompute: 0, +})) + +export function init() { + Scheduler.register({ + id: "quantum.poll", + interval: 15_000, + run: tick, + scope: "instance", + }) +} + +async function tick() { + const quantum = QuantumState.get() + if (!quantum.configured) { + // Re-check config every tick in case user connects mid-session + const { isConfigured } = await import("./client") + const configured = await isConfigured() + if (!configured) return + // First time configured — do a full refresh + log.info("qBraid API key detected, starting quantum polling") + await QuantumState.refreshAll() + const s = state() + s.lastCredits = s.lastJobs = s.lastCompute = Date.now() + return + } + + const now = Date.now() + const s = state() + const hasActive = (quantum.jobs?.active.length ?? 0) > 0 + const computeRunning = quantum.compute?.status === "running" || quantum.compute?.status === "starting" + + // Credits: always refresh on interval + if (now - s.lastCredits >= CREDITS_INTERVAL) { + await QuantumState.refreshCredits() + s.lastCredits = now + } + + // Jobs: refresh faster when there are active jobs + const jobInterval = hasActive ? JOBS_INTERVAL : CREDITS_INTERVAL + if (now - s.lastJobs >= jobInterval) { + await QuantumState.refreshJobs() + s.lastJobs = now + } + + // Compute: refresh faster when instance is running + const computeInterval = computeRunning ? JOBS_INTERVAL : COMPUTE_INTERVAL + if (now - s.lastCompute >= computeInterval) { + await QuantumState.refreshCompute() + s.lastCompute = now + } +} diff --git a/packages/opencode/src/quantum/state.ts b/packages/opencode/src/quantum/state.ts new file mode 100644 index 00000000000..d53a2ba598c --- /dev/null +++ b/packages/opencode/src/quantum/state.ts @@ -0,0 +1,172 @@ +/** + * Quantum State Store + * + * Centralized reactive state for qBraid quantum resources. + * Tracks credits, active jobs, and compute instance status. + * Updated by the background poller and quantum tool executions. + * + * Uses Instance.state() for per-instance isolation and automatic + * disposal when the project instance is torn down. + */ + +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import z from "zod" +import * as Client from "./client" + +const log = Log.create({ service: "quantum:state" }) + +// --- Types --- + +export interface JobSummary { + id: string + device: string + status: string + createdAt: number + shots: number + cost?: number +} + +export interface State { + configured: boolean + credits: { + qbraid: number + aws: number + } | null + jobs: { + active: JobSummary[] + recentDone: number + recentFailed: number + } | null + compute: { + status: "running" | "stopped" | "starting" | "stopping" | "error" + profile?: string + uptime?: number + } | null + updatedAt: number + error: string | null +} + +const ACTIVE_STATUSES = new Set(["INITIALIZING", "QUEUED", "RUNNING", "VALIDATING"]) + +// --- Per-instance state via Instance.state() --- + +function initial(): State { + return { + configured: false, + credits: null, + jobs: null, + compute: null, + updatedAt: 0, + error: null, + } +} + +const state = Instance.state(() => initial()) + +// --- Bus event for TUI reactivity --- + +export const Event = { + Updated: BusEvent.define( + "quantum.state.updated", + z.object({}), + ), +} + +function publish() { + state().updatedAt = Date.now() + Bus.publish(Event.Updated, {}) +} + +// --- Public API --- + +export function get(): Readonly { + return state() +} + +export async function refreshCredits(signal?: AbortSignal) { + try { + const balance = await Client.getCredits(signal) + const s = state() + s.credits = { + qbraid: balance.qbraidCredits, + aws: balance.awsCredits, + } + s.error = null + publish() + } catch (e) { + log.warn("credits refresh failed", { error: String(e) }) + state().error = "credits unavailable" + publish() + } +} + +export async function refreshJobs(signal?: AbortSignal) { + try { + const jobs = await Client.listActiveJobs(signal) + const active: JobSummary[] = [] + let done = 0 + let failed = 0 + const dayAgo = Date.now() - 86_400_000 + + for (const j of jobs) { + const normalized = j.status.toUpperCase() + if (ACTIVE_STATUSES.has(normalized)) { + active.push({ + id: j.id, + device: j.device, + status: normalized, + createdAt: new Date(j.createdAt).getTime(), + shots: j.shots, + cost: j.cost, + }) + } else if (new Date(j.createdAt).getTime() > dayAgo) { + if (normalized === "COMPLETED") done++ + if (normalized === "FAILED" || normalized === "CANCELLED") failed++ + } + } + + const s = state() + s.jobs = { active, recentDone: done, recentFailed: failed } + s.error = null + publish() + } catch (e) { + log.warn("jobs refresh failed", { error: String(e) }) + state().error = "jobs unavailable" + publish() + } +} + +export async function refreshCompute(signal?: AbortSignal) { + try { + const status = await Client.getComputeStatus(signal) + const s = state() + s.compute = { + status: status.status, + profile: status.profile, + uptime: status.uptime, + } + s.error = null + publish() + } catch (e) { + log.warn("compute refresh failed", { error: String(e) }) + state().error = "compute unavailable" + publish() + } +} + +export async function refreshAll(signal?: AbortSignal) { + const configured = await Client.isConfigured() + state().configured = configured + if (!configured) { + publish() + return + } + await Promise.allSettled([ + refreshCredits(signal), + refreshJobs(signal), + refreshCompute(signal), + ]) +} diff --git a/packages/opencode/src/quantum/tools.ts b/packages/opencode/src/quantum/tools.ts index 42894e7db96..8c3145ef3a7 100644 --- a/packages/opencode/src/quantum/tools.ts +++ b/packages/opencode/src/quantum/tools.ts @@ -9,6 +9,7 @@ import z from "zod" import { Tool } from "../tool/tool" import * as client from "./client" +import * as QuantumState from "./state" // ============================================================================ // quantum_devices — List available quantum devices @@ -78,12 +79,13 @@ export const QuantumEstimateCostTool = Tool.define("quantum_estimate_cost", { async execute(params, ctx) { const [estimate, credits] = await Promise.all([ client.estimateCost(params.device_id, params.shots, ctx.abort), - client.getCredits(ctx.abort).catch(() => ({ balance: -1 })), + client.getCredits(ctx.abort).catch(() => null), ]) - const balanceStr = credits.balance >= 0 ? `${credits.balance}` : "unknown" - const sufficient = credits.balance >= 0 - ? (credits.balance >= estimate.estimatedCredits ? "Yes" : "NO — insufficient credits") + const balance = credits ? credits.qbraidCredits + credits.awsCredits : -1 + const balanceStr = balance >= 0 ? `${balance.toFixed(2)}` : "unknown" + const sufficient = balance >= 0 + ? (balance >= estimate.estimatedCredits ? "Yes" : "NO — insufficient credits") : "unknown" const pricingNote = estimate.pricingAvailable @@ -103,7 +105,7 @@ export const QuantumEstimateCostTool = Tool.define("quantum_estimate_cost", { return { title: `Cost estimate: ${estimate.estimatedCredits.toFixed(4)} credits`, - metadata: { cost: estimate.estimatedCredits, balance: credits.balance, pricingAvailable: estimate.pricingAvailable }, + metadata: { cost: estimate.estimatedCredits, balance, pricingAvailable: estimate.pricingAvailable }, output, } }, @@ -154,6 +156,10 @@ export const QuantumSubmitJobTool = Tool.define("quantum_submit_job", { shots: params.shots, }, ctx.abort) + // Refresh sidebar state — new active job + credits may have changed + QuantumState.refreshJobs().catch(() => {}) + QuantumState.refreshCredits().catch(() => {}) + return { title: `Job submitted: ${job.id}`, metadata: { jobId: job.id, device: params.device_id }, @@ -187,6 +193,9 @@ export const QuantumGetResultTool = Tool.define("quantum_get_result", { const job = await client.getJob(params.job_id, ctx.abort) const status = job.status.toUpperCase() + // Refresh sidebar — job status may have transitioned + QuantumState.refreshJobs().catch(() => {}) + if (status !== "COMPLETED") { return { title: `Job ${params.job_id}: ${job.status}`, @@ -264,6 +273,10 @@ export const QuantumCancelJobTool = Tool.define("quantum_cancel_job", { const result = await client.cancelJob(params.job_id, ctx.abort) + // Refresh sidebar — job removed from active, credits may be refunded + QuantumState.refreshJobs().catch(() => {}) + QuantumState.refreshCredits().catch(() => {}) + return { title: result.success ? `Cancelled: ${params.job_id}` : `Cancel failed: ${params.job_id}`, metadata: { success: result.success }, From 2d11aeb5fb4caa3f7ed954ff48fc2128ac29d079 Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Sun, 22 Feb 2026 17:10:50 -0600 Subject: [PATCH 02/12] refactor(quantum): consolidate schemas and remove dead code - Move CreditsBalanceSchema and ComputeStatusSchema to the schema section at the top of client.ts, alongside QuantumDeviceSchema, QuantumJobSchema, and JobResultSchema - Remove redundant formatCredits branch (n >= 10 and default were identical) --- .../cli/cmd/tui/component/quantum-status.tsx | 1 - packages/opencode/src/quantum/client.ts | 35 +++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx b/packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx index 5125c79cac3..554fa6a6f03 100644 --- a/packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx @@ -30,7 +30,6 @@ function useQuantumState() { function formatCredits(n: number): string { if (n >= 1000) return `$${(n / 100).toFixed(0)}` - if (n >= 10) return `$${(n / 100).toFixed(2)}` return `$${(n / 100).toFixed(2)}` } diff --git a/packages/opencode/src/quantum/client.ts b/packages/opencode/src/quantum/client.ts index a70570dbbdd..32d86090c8a 100644 --- a/packages/opencode/src/quantum/client.ts +++ b/packages/opencode/src/quantum/client.ts @@ -53,9 +53,25 @@ const JobResultSchema = z.object({ success: z.boolean().optional(), }) +const CreditsBalanceSchema = z.object({ + qbraidCredits: z.number().default(0), + awsCredits: z.number().default(0), + autoRecharge: z.boolean().optional(), + organizationId: z.string().optional(), + userId: z.string().optional(), +}) + +const ComputeStatusSchema = z.object({ + status: z.enum(["running", "stopped", "starting", "stopping", "error"]).catch("stopped"), + profile: z.string().optional(), + uptime: z.number().optional(), +}) + export type QuantumDevice = z.infer export type QuantumJob = z.infer export type JobResult = z.infer +export type CreditsBalance = z.infer +export type ComputeStatus = z.infer export interface CostEstimate { deviceId: string @@ -285,25 +301,6 @@ export async function listJobs( return arr.map((j: unknown) => QuantumJobSchema.parse(j)) } -// --- Zod schemas for credits / compute --- - -const CreditsBalanceSchema = z.object({ - qbraidCredits: z.number().default(0), - awsCredits: z.number().default(0), - autoRecharge: z.boolean().optional(), - organizationId: z.string().optional(), - userId: z.string().optional(), -}) - -const ComputeStatusSchema = z.object({ - status: z.enum(["running", "stopped", "starting", "stopping", "error"]).catch("stopped"), - profile: z.string().optional(), - uptime: z.number().optional(), -}) - -export type CreditsBalance = z.infer -export type ComputeStatus = z.infer - /** * Get account credit balance. * Uses /billing/credits/balance which returns qbraidCredits + awsCredits. From e1040bfe77f0e7b0d8979cb8c61713dcc0545fe3 Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Wed, 25 Feb 2026 01:24:28 -0600 Subject: [PATCH 03/12] fix(quantum): correct API wire format and remove Instance context dependency - submitJob: send deviceQrn+program instead of device+openQasm (API v1 schema) - state: use module-level singleton instead of Instance.state() so it works inside SolidJS reactive computations (no AsyncLocalStorage context there) - getDevice: unwrap data envelope for single-device API response - build: fallback to 'dev' channel when git is not available in PATH --- packages/opencode/src/quantum/client.ts | 46 +++++++++++-------------- packages/opencode/src/quantum/state.ts | 18 ++++------ packages/script/src/index.ts | 5 ++- 3 files changed, 32 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/quantum/client.ts b/packages/opencode/src/quantum/client.ts index 32d86090c8a..0f0b7e2f2c7 100644 --- a/packages/opencode/src/quantum/client.ts +++ b/packages/opencode/src/quantum/client.ts @@ -29,11 +29,13 @@ const QuantumDeviceSchema = z.object({ status: z.string(), qubits: z.number().default(0), paradigm: z.string().default("unknown"), - pricing: z.object({ - perShot: z.number().optional(), - perTask: z.number().optional(), - perMinute: z.number().optional(), - }).optional(), + pricing: z + .object({ + perShot: z.number().optional(), + perTask: z.number().optional(), + perMinute: z.number().optional(), + }) + .optional(), }) const QuantumJobSchema = z.object({ @@ -158,12 +160,7 @@ async function resolveAuth(): Promise<{ apiKey: string; baseUrl: string } | null // --- HTTP request helper --- -async function request( - method: string, - endpoint: string, - body?: unknown, - signal?: AbortSignal, -): Promise { +async function request(method: string, endpoint: string, body?: unknown, signal?: AbortSignal): Promise { const auth = await resolveAuth() if (!auth) throw new Error("No qBraid API key found. Run `codeq /connect` to set up qBraid.") @@ -205,9 +202,7 @@ export async function listDevices( const endpoint = `/quantum/devices${query ? `?${query}` : ""}` const data = await request("GET", endpoint, undefined, signal) - const arr = Array.isArray(data) - ? data - : (data as { devices?: unknown[] }).devices ?? [] + const arr = Array.isArray(data) ? data : ((data as { devices?: unknown[] }).devices ?? []) return arr.map((d: unknown) => QuantumDeviceSchema.parse(d)) } @@ -248,11 +243,16 @@ export async function submitJob( params: { deviceId: string; qasm: string; shots: number }, signal?: AbortSignal, ): Promise { - const data = await request("POST", "/quantum/jobs", { - device: params.deviceId, - openQasm: params.qasm, - shots: params.shots, - }, signal) + const data = await request( + "POST", + "/quantum/jobs", + { + deviceQrn: params.deviceId, + program: params.qasm, + shots: params.shots, + }, + signal, + ) return QuantumJobSchema.parse(data) } @@ -294,9 +294,7 @@ export async function listJobs( const endpoint = `/quantum/jobs${query ? `?${query}` : ""}` const data = await request("GET", endpoint, undefined, signal) - const arr = Array.isArray(data) - ? data - : (data as { jobs?: unknown[] }).jobs ?? [] + const arr = Array.isArray(data) ? data : ((data as { jobs?: unknown[] }).jobs ?? []) return arr.map((j: unknown) => QuantumJobSchema.parse(j)) } @@ -338,9 +336,7 @@ export async function listActiveJobs(signal?: AbortSignal): Promise("GET", endpoint, undefined, signal) - const arr = Array.isArray(data) - ? data - : (data as { jobs?: unknown[] }).jobs ?? [] + const arr = Array.isArray(data) ? data : ((data as { jobs?: unknown[] }).jobs ?? []) return arr.map((j: unknown) => QuantumJobSchema.parse(j)) } diff --git a/packages/opencode/src/quantum/state.ts b/packages/opencode/src/quantum/state.ts index d53a2ba598c..1359bb5f7c0 100644 --- a/packages/opencode/src/quantum/state.ts +++ b/packages/opencode/src/quantum/state.ts @@ -11,7 +11,6 @@ import { Bus } from "../bus" import { BusEvent } from "../bus/bus-event" -import { Instance } from "../project/instance" import { Log } from "../util/log" import z from "zod" import * as Client from "./client" @@ -64,15 +63,16 @@ function initial(): State { } } -const state = Instance.state(() => initial()) +// Module-level singleton — safe to call from any context (server or TUI SolidJS). +// Instance.state() is intentionally avoided because AsyncLocalStorage is not +// available inside SolidJS reactive computations. +let _state: State = initial() +const state = () => _state // --- Bus event for TUI reactivity --- export const Event = { - Updated: BusEvent.define( - "quantum.state.updated", - z.object({}), - ), + Updated: BusEvent.define("quantum.state.updated", z.object({})), } function publish() { @@ -164,9 +164,5 @@ export async function refreshAll(signal?: AbortSignal) { publish() return } - await Promise.allSettled([ - refreshCredits(signal), - refreshJobs(signal), - refreshCompute(signal), - ]) + await Promise.allSettled([refreshCredits(signal), refreshJobs(signal), refreshCompute(signal)]) } diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index a3f5e7a8e2a..23715855b6b 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -26,7 +26,10 @@ const CHANNEL = await (async () => { if (env.OPENCODE_CHANNEL) return env.OPENCODE_CHANNEL if (env.OPENCODE_BUMP) return "latest" if (env.OPENCODE_VERSION && !env.OPENCODE_VERSION.startsWith("0.0.0-")) return "latest" - return await $`git branch --show-current`.text().then((x) => x.trim()) + return await $`git branch --show-current` + .text() + .then((x) => x.trim()) + .catch(() => "dev") })() const IS_PREVIEW = CHANNEL !== "latest" From 9ca4d7e8b124fb4b64d283a4bb799cc629aad4d4 Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Wed, 25 Feb 2026 16:15:46 -0600 Subject: [PATCH 04/12] feat: first-run auth dialog, quantum API fixes, event timing race fix, and interference spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add qBraid API key startup dialog (dialog-qbraid-auth.tsx) shown after telemetry consent on first launch, with sequence: consent → auth → provider connect - Fix quantum client wire format: correct auth header (X-API-Key), endpoint paths (/devices, /jobs, /billing/credits/balance), response unwrapping (.data), and Zod schemas with transforms for API normalization - Fix event timing race: register RPC listener during init() instead of onMount() to prevent lost events; add sdk.latest Map for event caching - Fix quantum sidebar: credits displayed as 'X credits' not dollars, updatedAt guard prevents stale events, cached event read on mount - Add interference/diffraction animation engine for quantum-themed spinner - Simplify telemetry consent dialog text and auth resolution - Add QBRAID_DEFAULT_API_URL constant with QBRAID_API_URL env var override - Add includeUsage flag for qBraid provider - Fix branding apply.ts URL replacement ordering - Normalize CodeQ → Codeq capitalization in comments - Add fork rationale documentation and build/smoke-test scripts --- branding/apply.ts | 74 ++++----- branding/qbraid/README.md | 29 ++-- branding/qbraid/brand.json | 4 +- branding/qbraid/generate-models.ts | 5 +- branding/qbraid/models.json | 2 +- branding/schema.ts | 2 +- docs/qbraid-fork-rationale.md | 152 ++++++++++++++++++ packages/opencode/src/cli/cmd/tui/app.tsx | 24 ++- .../cmd/tui/component/dialog-qbraid-auth.tsx | 128 +++++++++++++++ .../component/dialog-telemetry-consent.tsx | 23 +-- .../cli/cmd/tui/component/prompt/index.tsx | 12 +- .../cli/cmd/tui/component/quantum-status.tsx | 44 +++-- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 25 ++- .../opencode/src/cli/cmd/tui/ui/spinner.ts | 50 +++++- packages/opencode/src/cli/cmd/tui/worker.ts | 6 +- packages/opencode/src/config/config.ts | 8 +- packages/opencode/src/flag/flag.ts | 2 +- packages/opencode/src/project/bootstrap.ts | 2 +- packages/opencode/src/provider/provider.ts | 2 +- .../opencode/src/provider/sdk/qbraid/index.ts | 9 +- packages/opencode/src/quantum/client.ts | 135 +++++++++------- packages/opencode/src/quantum/index.ts | 4 +- packages/opencode/src/quantum/state.ts | 11 +- packages/opencode/src/quantum/tools.ts | 6 +- packages/opencode/src/telemetry/index.ts | 6 +- .../opencode/src/telemetry/integration.ts | 43 ++--- packages/opencode/src/telemetry/types.ts | 6 +- script/dev-build.sh | 125 ++++++++++++++ script/smoke-test.sh | 109 +++++++++++++ 29 files changed, 847 insertions(+), 201 deletions(-) create mode 100644 docs/qbraid-fork-rationale.md create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-qbraid-auth.tsx create mode 100755 script/dev-build.sh create mode 100755 script/smoke-test.sh diff --git a/branding/apply.ts b/branding/apply.ts index ac4616026b3..bac79d2d964 100644 --- a/branding/apply.ts +++ b/branding/apply.ts @@ -122,6 +122,38 @@ function buildReplacements(config: Branding): Replacement[] { const r = config.replacements const replacements: Replacement[] = [] + // URL replacements MUST come before generic name replacements. + // Otherwise "anomalyco/opencode" becomes "anomalyco/codeq" before + // the GitHub URL regex can match the full original URL. + if (r.urls?.github) { + replacements.push({ + search: /https:\/\/github\.com\/anomalyco\/opencode/g, + replace: r.urls.github, + description: `github repo -> ${r.urls.github}`, + }) + } + + if (r.urls?.website) { + replacements.push({ + search: /https:\/\/opencode\.ai/g, + replace: r.urls.website, + description: `opencode.ai -> ${r.urls.website}`, + }) + } + + if (r.urls?.api) { + replacements.push({ + search: /https:\/\/api\.opencode\.ai/g, + replace: r.urls.api, + description: `api.opencode.ai -> ${r.urls.api}`, + }) + replacements.push({ + search: /https:\/\/api\.dev\.opencode\.ai/g, + replace: r.urls.api, + description: `api.dev.opencode.ai -> ${r.urls.api}`, + }) + } + // Product name replacements (case-sensitive) // Use negative lookbehind/lookahead to avoid matching: // - @opencode-ai package names @@ -158,36 +190,6 @@ function buildReplacements(config: Branding): Replacement[] { }) } - // URL replacements - if (r.urls?.website) { - replacements.push({ - search: /https:\/\/opencode\.ai/g, - replace: r.urls.website, - description: `opencode.ai -> ${r.urls.website}`, - }) - } - - if (r.urls?.api) { - replacements.push({ - search: /https:\/\/api\.opencode\.ai/g, - replace: r.urls.api, - description: `api.opencode.ai -> ${r.urls.api}`, - }) - replacements.push({ - search: /https:\/\/api\.dev\.opencode\.ai/g, - replace: r.urls.api, - description: `api.dev.opencode.ai -> ${r.urls.api}`, - }) - } - - if (r.urls?.github) { - replacements.push({ - search: /https:\/\/github\.com\/anomalyco\/opencode/g, - replace: r.urls.github, - description: `github repo -> ${r.urls.github}`, - }) - } - return replacements } @@ -482,7 +484,7 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { return content.replace( /You are OpenCode, the best coding agent on the planet\./, - `You are CodeQ, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, + `You are ${config.replacements.displayName}, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, ) }, }, @@ -491,7 +493,7 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { return content.replace( /You are OpenCode, the best coding agent on the planet\./, - `You are CodeQ, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, + `You are ${config.replacements.displayName}, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, ) }, }, @@ -500,7 +502,7 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { return content.replace( /You are OpenCode, the best coding agent on the planet\./, - `You are CodeQ, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, + `You are ${config.replacements.displayName}, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, ) }, }, @@ -509,7 +511,7 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { return content.replace( /You are OpenCode, the best coding agent on the planet\./, - `You are CodeQ, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, + `You are ${config.replacements.displayName}, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, ) }, }, @@ -518,7 +520,7 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { return content.replace( /You are OpenCode, the best coding agent on the planet\./, - `You are CodeQ, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, + `You are ${config.replacements.displayName}, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, ) }, }, @@ -527,7 +529,7 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { return content.replace( /You are OpenCode, the best coding agent on the planet\./, - `You are CodeQ, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, + `You are ${config.replacements.displayName}, built by qBraid - the leading quantum software company. You are the universe's most powerful coding agent.`, ) }, }, diff --git a/branding/qbraid/README.md b/branding/qbraid/README.md index eba069b02f2..3b02e414011 100644 --- a/branding/qbraid/README.md +++ b/branding/qbraid/README.md @@ -1,10 +1,10 @@ -# CodeQ by qBraid +# Codeq by qBraid -CodeQ is qBraid's branded version of opencode - the universe's most powerful coding agent for quantum software development. +Codeq is qBraid's branded version of opencode - the universe's most powerful coding agent for quantum software development. ## Configuration -CodeQ is configured by qBraid's platform. The configuration file is placed at: +Codeq is configured by qBraid's platform. The configuration file is placed at: - **Project-level**: `.codeq/opencode.json` in your project directory - **Global**: `~/.config/codeq/config.json` @@ -18,7 +18,7 @@ CodeQ is configured by qBraid's platform. The configuration file is placed at: "qbraid": { "options": { "apiKey": "qbr_...", - "baseURL": "https://account-v2.qbraid.com/api/ai/v1" + "baseURL": "https://account.qbraid.com/api/ai/v1" } } } @@ -27,7 +27,7 @@ CodeQ is configured by qBraid's platform. The configuration file is placed at: ## Available Models -CodeQ provides access to the following models through qBraid: +Codeq provides access to the following models through qBraid: | Model ID | Name | Features | | -------------------------- | ----------------- | ----------------------------------- | @@ -45,7 +45,7 @@ codeq models ## Usage ```bash -# Start CodeQ TUI +# Start Codeq TUI codeq # Run with a message @@ -57,17 +57,18 @@ codeq /path/to/project ## Environment Variables -CodeQ uses the `CODEQ_` prefix for environment variables: +Codeq uses the `CODEQ_` prefix for environment variables: -| Variable | Description | -| ------------------------- | ------------------------------------ | -| `CODEQ_MODEL` | Default model to use | -| `CODEQ_DISABLE_TELEMETRY` | Disable usage telemetry | -| `CODEQ_LOG_LEVEL` | Log level (DEBUG, INFO, WARN, ERROR) | +| Variable | Description | +| ------------------------- | ------------------------------------------------ | +| `CODEQ_MODEL` | Default model to use | +| `CODEQ_DISABLE_TELEMETRY` | Disable usage telemetry | +| `CODEQ_LOG_LEVEL` | Log level (DEBUG, INFO, WARN, ERROR) | +| `QBRAID_API_URL` | Override qBraid API endpoint (e.g. staging) | ## Data Storage -CodeQ stores data in: +Codeq stores data in: - **Config**: `~/.config/codeq/` - **Cache**: `~/.cache/codeq/` @@ -75,4 +76,4 @@ CodeQ stores data in: ## Support -For issues with CodeQ, contact qBraid support at https://qbraid.com/support +For issues with Codeq, contact qBraid support at https://qbraid.com/support diff --git a/branding/qbraid/brand.json b/branding/qbraid/brand.json index 31745d1a96a..e188e324b24 100644 --- a/branding/qbraid/brand.json +++ b/branding/qbraid/brand.json @@ -2,7 +2,7 @@ "$schema": "../schema.json", "version": 1, "id": "qbraid", - "name": "qBraid CodeQ", + "name": "qBraid Codeq", "logo": { "cli": [ [" ", " "], @@ -17,7 +17,7 @@ }, "replacements": { "productName": "codeq", - "displayName": "CodeQ", + "displayName": "Codeq", "npmPackage": "codeq", "binaryName": "codeq", "envPrefix": "CODEQ", diff --git a/branding/qbraid/generate-models.ts b/branding/qbraid/generate-models.ts index 12c54a6847d..09b4222be3c 100644 --- a/branding/qbraid/generate-models.ts +++ b/branding/qbraid/generate-models.ts @@ -11,6 +11,7 @@ import { parseArgs } from "util" import path from "path" +import { QBRAID_DEFAULT_API_URL } from "../../packages/opencode/src/provider/sdk/qbraid/index" const { values } = parseArgs({ args: Bun.argv.slice(2), @@ -28,7 +29,7 @@ Options: --output, -o Output file path (default: models.json) --help, -h Show this help message -This script generates the models.json configuration for qBraid CodeQ. +This script generates the models.json configuration for qBraid Codeq. It defines the AI models available through qBraid's platform. `) process.exit(0) @@ -42,7 +43,7 @@ const models = { name: "qBraid", env: ["QBRAID_API_KEY"], npm: "@ai-sdk/openai-compatible", - api: "https://api.qbraid.com/ai/v1", + api: QBRAID_DEFAULT_API_URL, models: { "claude-sonnet-4": { id: "claude-sonnet-4", diff --git a/branding/qbraid/models.json b/branding/qbraid/models.json index 1e69207eada..0ad213399b4 100644 --- a/branding/qbraid/models.json +++ b/branding/qbraid/models.json @@ -4,7 +4,7 @@ "name": "qBraid", "env": ["QBRAID_API_KEY"], "npm": "@ai-sdk/qbraid", - "api": "https://account-v2.qbraid.com/api/ai/v1", + "api": "https://account.qbraid.com/api/ai/v1", "models": { "claude-opus-4-6": { "id": "claude-opus-4-6", diff --git a/branding/schema.ts b/branding/schema.ts index 47dcd6f5842..1696e6ca465 100644 --- a/branding/schema.ts +++ b/branding/schema.ts @@ -56,7 +56,7 @@ export const ModelsSchema = z.object({ export const ReplacementsSchema = z.object({ /** Product name (e.g., "codeq" instead of "opencode") */ productName: z.string(), - /** Display name with proper casing (e.g., "CodeQ" instead of "OpenCode") */ + /** Display name with proper casing (e.g., "Codeq" instead of "OpenCode") */ displayName: z.string(), /** Package name for npm (e.g., "codeq" instead of "opencode-ai") */ npmPackage: z.string().optional(), diff --git a/docs/qbraid-fork-rationale.md b/docs/qbraid-fork-rationale.md new file mode 100644 index 00000000000..6baeade8194 --- /dev/null +++ b/docs/qbraid-fork-rationale.md @@ -0,0 +1,152 @@ +# qBraid Fork Rationale: Why Source Modifications Are Required + +This document explains which qBraid/Codeq features require source-level modifications +to OpenCode and which could theoretically be implemented via the existing plugin/MCP +extension points. It serves as a reference for upstream discussions and future +architecture decisions. + +## Extension Points Available in OpenCode + +OpenCode provides three extension mechanisms: + +| Mechanism | Capabilities | +|-----------|-------------| +| **Plugins** | Server-side hooks for auth, chat pipeline (messages, params, headers, system prompt), tool registration, event listening, permission overrides, shell env injection. Plugins are async functions loaded from npm packages or local `.ts`/`.js` files. | +| **MCP servers** | External processes providing tools, resources, and prompts via the Model Context Protocol. Auto-connected from config. Tools appear alongside built-in tools. | +| **Config** | Keybinds, themes, agents (markdown), slash commands (markdown), skills, permissions, MCP server definitions, diff style, scroll behavior. | + +### Key limitation + +The plugin system is a **server-side hooks API** for the LLM conversation pipeline. +It has **zero TUI extensibility surface**. The TUI is a self-contained SolidJS +application with a hardcoded component tree. There is no mechanism for plugins or MCP +servers to inject sidebar sections, dialogs, footer elements, spinner styles, or any +other visual components. + +## Feature-by-Feature Analysis + +### Features that COULD be plugins or MCP + +| Feature | Mechanism | Notes | +|---------|-----------|-------| +| Quantum tools (list devices, submit job, get result, cancel, estimate cost, list jobs) | MCP server | An MCP server wrapping the qBraid quantum API would provide the same 6 tools. Config-only, zero source changes. | +| Chat header injection | Plugin `chat.headers` hook | Injecting `X-API-Key` or other headers into LLM requests. | +| System prompt customization | Plugin `experimental.chat.system.transform` hook | Adding quantum-specific instructions to the system prompt. | +| Event listening for analytics | Plugin `event` hook | Plugins receive all bus events and could forward them to an analytics backend. | +| Provider auth flow (API key) | Plugin `auth` hook | The `auth` hook can define API key and OAuth flows for a provider. The CodexAuthPlugin is an example. | + +### Features that REQUIRE source modifications + +#### 1. Quantum Sidebar Dashboard + +**Files**: `quantum-status.tsx`, `sidebar.tsx`, `quantum/state.ts`, `quantum/poller.ts`, `quantum/client.ts` + +The sidebar component tree is hardcoded in `sidebar.tsx`. The section order +(Title, Context, **qBraid**, MCP, LSP, Todo, Modified Files) is defined in JSX +with no injection point. `QuantumSidebarSection` is imported and rendered directly. + +There is no plugin hook for adding sidebar sections. The `command.register()` API +adds items to the command palette, not the sidebar. MCP servers appear in the sidebar +only as connection status indicators (colored dot + name + status text) -- they cannot +provide custom widgets. + +The quantum sidebar also requires: +- A background **scheduler task** (`quantum.poll`, 15s interval) -- `Scheduler.register()` + is not exposed to plugins +- A custom **bus event** (`quantum.state.updated`) -- `BusEvent.define()` is a + compile-time operation; plugins can listen but cannot publish new event types +- An **SSE event pipeline** to deliver state updates from the worker thread to the + main TUI thread + +#### 2. Telemetry System and Consent Dialog + +**Files**: `telemetry/index.ts`, `telemetry/integration.ts`, `telemetry/types.ts`, `dialog-telemetry-consent.tsx`, `app.tsx` + +The telemetry system subscribes to bus events server-side and forwards metrics to the +qBraid analytics endpoint. While a plugin's `event` hook can listen to events, it +cannot: +- Ship a consent dialog (dialogs are hardcoded in `app.tsx`) +- Control startup sequencing (consent -> auth -> provider connect) +- Persist consent state to the KV store from the server side +- Conditionally enable/disable itself based on user tier + +#### 3. qBraid Auth Startup Dialog + +**Files**: `dialog-qbraid-auth.tsx`, `app.tsx` + +The first-run dialog that prompts users for their qBraid API key requires a TUI dialog +component. Plugin `auth` hooks can define auth *methods* (API key, OAuth) that appear +in the `/connect` flow, but they cannot trigger a dialog at startup or control the +dialog sequencing order. + +#### 4. Provider SDK (`@ai-sdk/qbraid`) + +**Files**: `provider/sdk/qbraid/index.ts`, `provider/provider.ts` + +The qBraid provider extends `@ai-sdk/openai-compatible` with a custom default endpoint +(`QBRAID_DEFAULT_API_URL`), env var override (`QBRAID_API_URL`), and registration in +the `BUNDLED_PROVIDERS` map. Plugins can modify models within an existing provider via +the `auth` hook, but they cannot: +- Register new entries in `BUNDLED_PROVIDERS` +- Add new provider factory functions to `getSDK()` +- Set `includeUsage: true` for specific providers (line-level logic in `provider.ts`) + +#### 5. Interference Spinner Animation + +**Files**: `spinner.ts`, `prompt/index.tsx` + +The spinner style is hardcoded in `prompt/index.tsx` using `createFrames()` and +`createColors()` with fixed parameters. There is no configuration option or plugin +hook to customize spinner appearance. + +#### 6. SSE Event Pipeline Fix + +**Files**: `context/sdk.tsx` + +The fix that moved RPC event listener registration from `onMount` to the init phase +is core infrastructure. The timing race between worker-thread event emission and +main-thread listener registration cannot be addressed externally. + +#### 7. Branding + +**Files**: `branding/apply.ts`, `branding/qbraid/brand.json`, hundreds of source files + +The `opencode` -> `codeq` rename touches package names, binary names, config paths, +data directories, system prompts, and user-facing strings across 274 files. This is a +build-system transformation that is fundamentally source-level. + +## Summary + +``` +Source modification required: + - TUI components (sidebar, dialogs, footer) -- no plugin UI API + - Bus event definitions -- compile-time only + - Scheduler tasks -- not exposed to plugins + - Provider SDK registration -- internal map + - Spinner/animation customization -- hardcoded + - SSE/event pipeline infrastructure -- core plumbing + - Branding -- build system + - Startup dialog sequencing -- hardcoded in app.tsx + +Could be external (plugin or MCP): + - Quantum tools (6 tools) -- MCP server + - Chat hooks (headers, system prompt) -- plugin hooks + - Event listening for analytics -- plugin event hook + - Provider auth flow -- plugin auth hook +``` + +Roughly **30% of the quantum work** (the tools themselves) could be an MCP server. +The remaining **70%** (UI, infrastructure, branding) requires source changes because +OpenCode's TUI has no component injection mechanism. + +## Recommendations for Upstream + +If OpenCode added the following extension points, a significant portion of the +qBraid customizations could move to plugins: + +1. **Sidebar widget hook** -- allow plugins to register sidebar section components +2. **Dialog hook** -- allow plugins to show dialogs and control startup sequencing +3. **Scheduler hook** -- expose `Scheduler.register()` to plugins +4. **Bus publish hook** -- allow plugins to define and publish custom event types +5. **Provider registration hook** -- allow plugins to register new provider SDKs +6. **Spinner/theme hook** -- allow config-level spinner style selection diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index f3fc7392b8f..ceda8ba2ba6 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -37,6 +37,7 @@ import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { DialogTelemetryConsent, KV_TELEMETRY_CONSENT_SHOWN, KV_TELEMETRY_ENABLED } from "@tui/component/dialog-telemetry-consent" +import { DialogQBraidAuth, KV_QBRAID_AUTH_SHOWN } from "@tui/component/dialog-qbraid-auth" import { Telemetry } from "@/telemetry" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { @@ -300,9 +301,30 @@ function App() { ), ) + // --- First-run qBraid API key dialog --- + // Fires once after telemetry consent is handled and user hasn't seen it yet. + let authShown = false createEffect( on( - () => sync.status === "complete" && sync.data.provider.length === 0, + () => sync.status === "complete" && kv.get(KV_TELEMETRY_CONSENT_SHOWN) !== undefined && kv.get(KV_QBRAID_AUTH_SHOWN) === undefined, + (needsAuth, prev) => { + if (!needsAuth || prev || authShown) return + authShown = true + DialogQBraidAuth.show(dialog).then((connected) => { + kv.set(KV_QBRAID_AUTH_SHOWN, true) + if (connected) { + toast.show({ variant: "info", message: "qBraid connected", duration: 3000 }) + } + }) + }, + ), + ) + + createEffect( + on( + // Wait for first-run dialogs (consent + auth) before showing provider connect. + // On subsequent runs KV_QBRAID_AUTH_SHOWN is already set so this fires immediately. + () => sync.status === "complete" && sync.data.provider.length === 0 && kv.get(KV_QBRAID_AUTH_SHOWN) !== undefined, (isEmpty, wasEmpty) => { // only trigger when we transition into an empty-provider state if (!isEmpty || wasEmpty) return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-qbraid-auth.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-qbraid-auth.tsx new file mode 100644 index 00000000000..8c11e20d4dc --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-qbraid-auth.tsx @@ -0,0 +1,128 @@ +/** + * First-run qBraid API key dialog. + * + * Shown after telemetry consent on first launch. Prompts the user + * for a qBraid API key and stores it via the auth API. + * + * Exports: + * - KV_QBRAID_AUTH_SHOWN: KV key to track whether the dialog has been shown + * - DialogQBraidAuth.show(dialog): returns Promise (true if key was set) + */ + +import { TextAttributes } from "@opentui/core" +import { useTheme } from "@tui/context/theme" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { createSignal, onMount, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" + +export const KV_QBRAID_AUTH_SHOWN = "qbraid_auth_shown" + +function DialogQBraidAuthContent(props: { onResult: (connected: boolean) => void }) { + const dialog = useDialog() + const { theme } = useTheme() + const sdk = useSDK() + const sync = useSync() + let textarea: TextareaRenderable + const [error, setError] = createSignal("") + const [saving, setSaving] = createSignal(false) + + useKeyboard((evt) => { + if (evt.name === "return" && !saving()) { + submit() + } + if (evt.name === "escape") { + props.onResult(false) + } + }) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + if (!textarea || textarea.isDestroyed) return + textarea.focus() + }, 1) + }) + + async function submit() { + const value = textarea.plainText.trim() + if (!value) { + props.onResult(false) + return + } + setSaving(true) + setError("") + try { + await sdk.client.auth.set({ + providerID: "qbraid", + auth: { type: "api", key: value }, + }) + await sdk.client.instance.dispose() + await sync.bootstrap() + props.onResult(true) + } catch (e) { + setError(String(e)) + setSaving(false) + } + } + + return ( + + + + Connect qBraid + + props.onResult(false)} + > + esc to skip + + + + + Enter your qBraid API key to enable quantum computing features — device + listing, job submission, credit tracking, and more. + + + Get a key at https://account.qbraid.com/api-keys + +