Skip to content
Open
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
209 changes: 209 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/quantum-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* 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<State>(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)}`
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 (
<Show when={state().configured}>
<box>
<box flexDirection="row" gap={1} onMouseDown={props.onToggle}>
<text fg={theme.text}>{props.expanded ? "▼" : "▶"}</text>
<box flexDirection="row" flexGrow={1} justifyContent="space-between">
<text fg={theme.text}>
<b>qBraid</b>
<Show when={hasActivity()}>
<span style={{ fg: theme.success }}> ●</span>
</Show>
</text>
<Show when={state().credits}>
<text fg={lowCredits() ? theme.warning : theme.textMuted}>{creditsText()}</text>
</Show>
</box>
</box>

<Show when={props.expanded}>
<JobsSection jobs={state().jobs} />
<ComputeSection compute={state().compute} />
</Show>
</box>
</Show>
)
}

function JobsSection(props: {
jobs: State["jobs"]
}) {
const { theme } = useTheme()

return (
<Show
when={props.jobs}
fallback={<text fg={theme.textMuted}> Loading jobs...</text>}
>
{(jobs) => (
<>
<Show
when={jobs().active.length > 0}
fallback={
<text fg={theme.textMuted}>
{" "}No active jobs
<Show when={jobs().recentDone > 0}>
<span style={{ fg: theme.textMuted }}> · {jobs().recentDone} done today</span>
</Show>
</text>
}
>
<For each={jobs().active.slice(0, 4)}>
{(job) => <JobRow job={job} />}
</For>
<Show when={jobs().active.length > 4}>
<text fg={theme.textMuted}>{" "}+{jobs().active.length - 4} more</text>
</Show>
</Show>
<Show when={jobs().recentFailed > 0}>
<text fg={theme.error}>{" "}{jobs().recentFailed} failed</text>
</Show>
</>
)}
</Show>
)
}

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 (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.text}>
{" "}<span style={{ fg: dot() }}>●</span> {shortDevice(props.job.device)}
</text>
<text fg={theme.textMuted}>{label()}</text>
</box>
)
}

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 (
<Show when={label()}>
{(l) => (
<text fg={theme.textMuted}>
{" "}<span style={{ fg: l().fg }}>▸</span> {l().text}
</text>
)}
</Show>
)
}

// --- 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 (
<Show when={visible()}>
<text fg={theme.text}>
<span style={{ fg: indicator().fg }}>⚛</span>{" "}
{activeCount() > 0 ? `${activeCount()} QPU` : "qBraid"}
</text>
</Show>
)
}
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -66,6 +67,7 @@ export function Footer() {
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<QuantumFooterIndicator />
<text fg={theme.text}>
<span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}>•</span> {lsp().length} LSP
</text>
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -98,6 +100,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<QuantumSidebarSection
expanded={expanded.qbraid}
onToggle={() => setExpanded("qbraid", !expanded.qbraid)}
/>
<Show when={mcpEntries().length > 0}>
<box>
<box
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { Telemetry } from "@/telemetry"
import { QuantumPoller, QuantumState } from "@/quantum"

export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
Expand All @@ -34,6 +35,12 @@ export async function InstanceBootstrap() {
Log.Default.warn("telemetry initialization failed", { error })
})

// Initialize qBraid quantum state polling (credits, jobs, compute)
QuantumPoller.init()
QuantumState.refreshAll().catch((error) => {
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)
Expand Down
56 changes: 54 additions & 2 deletions packages/opencode/src/quantum/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof QuantumDeviceSchema>
export type QuantumJob = z.infer<typeof QuantumJobSchema>
export type JobResult = z.infer<typeof JobResultSchema>
export type CreditsBalance = z.infer<typeof CreditsBalanceSchema>
export type ComputeStatus = z.infer<typeof ComputeStatusSchema>

export interface CostEstimate {
deviceId: string
Expand Down Expand Up @@ -287,9 +303,45 @@ export async function listJobs(

/**
* Get account credit balance.
* Uses /billing/credits/balance which returns qbraidCredits + awsCredits.
*/
export async function getCredits(signal?: AbortSignal): Promise<{ balance: number }> {
return request<{ balance: number }>("GET", "/user/credits", undefined, signal)
export async function getCredits(signal?: AbortSignal): Promise<CreditsBalance> {
const data = await request<unknown>("GET", "/billing/credits/balance", undefined, signal)
if (typeof data === "object" && data !== null && "data" in data) {
return CreditsBalanceSchema.parse((data as Record<string, unknown>).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<ComputeStatus> {
try {
const data = await request<unknown>("GET", "/compute/servers/status", undefined, signal)
if (typeof data === "object" && data !== null && "data" in data) {
return ComputeStatusSchema.parse((data as Record<string, unknown>).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 listActiveJobs(signal?: AbortSignal): Promise<QuantumJob[]> {
const params = new URLSearchParams()
params.set("limit", "10")
const endpoint = `/quantum/jobs?${params.toString()}`
const data = await request<unknown>("GET", endpoint, undefined, signal)
const arr = Array.isArray(data)
? data
: (data as { jobs?: unknown[] }).jobs ?? []
return arr.map((j: unknown) => QuantumJobSchema.parse(j))
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/quantum/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@

export { QUANTUM_TOOLS } from "./tools"
export * as QuantumClient from "./client"
export * as QuantumState from "./state"
export * as QuantumPoller from "./poller"
Loading