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
126 changes: 124 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import { TextAttributes } from "@opentui/core"
import { TextAttributes, RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show, createMemo } from "solid-js"
import { For, Match, Switch, Show, createMemo, createResource } from "solid-js"
import { AnthropicUsage } from "@/usage/anthropic"
import { OpenAIUsage } from "@/usage/openai"
import { getUsageColor, clampPercent } from "@/usage/utils"

export type DialogStatusProps = {}

function UsageBar(props: { percent: number; fg: RGBA; bgMuted: RGBA }) {
const width = 20
const clamped = clampPercent(props.percent)
const filled = Math.round((clamped / 100) * width)
const empty = width - filled
return (
<text>
<span style={{ fg: props.fg }}>{"█".repeat(filled)}</span>
<span style={{ fg: props.bgMuted }}>{"░".repeat(empty)}</span>
</text>
)
}

export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()

const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))

const [anthropicUsage] = createResource(() => AnthropicUsage.fetch(), { initialValue: null })

const [openaiUsage] = createResource(() => OpenAIUsage.fetch(), { initialValue: null })

const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
Expand All @@ -36,6 +56,8 @@ export function DialogStatus() {
return result.toSorted((a, b) => a.name.localeCompare(b.name))
})

const colorFor = (percent: number) => getUsageColor(percent, theme)

return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
Expand All @@ -44,6 +66,106 @@ export function DialogStatus() {
</text>
<text fg={theme.textMuted}>esc</text>
</box>

<Show when={anthropicUsage()} fallback={null}>
{(usage) => (
<box>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Anthropic Usage
</text>
<Show when={usage().five_hour}>
{(fiveHour) => (
<box flexDirection="row" gap={1}>
<text fg={theme.text}>5h:</text>
<UsageBar
percent={fiveHour().utilization}
fg={colorFor(fiveHour().utilization)}
bgMuted={theme.textMuted}
/>
<text fg={colorFor(fiveHour().utilization)}>{fiveHour().utilization}%</text>
<text fg={theme.textMuted}>(reset: {AnthropicUsage.formatResetTime(fiveHour().resets_at)})</text>
</box>
)}
</Show>
<Show when={usage().seven_day}>
{(sevenDay) => (
<box flexDirection="row" gap={1}>
<text fg={theme.text}>7d:</text>
<UsageBar
percent={sevenDay().utilization}
fg={colorFor(sevenDay().utilization)}
bgMuted={theme.textMuted}
/>
<text fg={colorFor(sevenDay().utilization)}>{sevenDay().utilization}%</text>
<text fg={theme.textMuted}>(reset: {AnthropicUsage.formatResetTime(sevenDay().resets_at)})</text>
</box>
)}
</Show>
<Show when={usage().seven_day_opus}>
{(opus) => (
<box flexDirection="row" gap={1}>
<text fg={theme.text}>Opus 7d:</text>
<UsageBar percent={opus().utilization} fg={colorFor(opus().utilization)} bgMuted={theme.textMuted} />
<text fg={colorFor(opus().utilization)}>{opus().utilization}%</text>
<text fg={theme.textMuted}>(reset: {AnthropicUsage.formatResetTime(opus().resets_at)})</text>
</box>
)}
</Show>
</box>
)}
</Show>

<Show when={openaiUsage()} fallback={null}>
{(usage) => (
<box>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
OpenAI Usage ({OpenAIUsage.getPlanDisplayName(usage().plan_type)})
</text>
<Show when={usage().rate_limit?.primary_window}>
{(primary) => (
<box flexDirection="row" gap={1}>
<text fg={theme.text}>{OpenAIUsage.formatWindowDuration(primary().limit_window_seconds)}:</text>
<UsageBar
percent={primary().used_percent}
fg={colorFor(primary().used_percent)}
bgMuted={theme.textMuted}
/>
<text fg={colorFor(primary().used_percent)}>{Math.round(primary().used_percent)}%</text>
<text fg={theme.textMuted}>(reset: {OpenAIUsage.formatResetTime(primary().reset_at)})</text>
</box>
)}
</Show>
<Show when={usage().rate_limit?.secondary_window}>
{(secondary) => (
<box flexDirection="row" gap={1}>
<text fg={theme.text}>{OpenAIUsage.formatWindowDuration(secondary().limit_window_seconds)}:</text>
<UsageBar
percent={secondary().used_percent}
fg={colorFor(secondary().used_percent)}
bgMuted={theme.textMuted}
/>
<text fg={colorFor(secondary().used_percent)}>{Math.round(secondary().used_percent)}%</text>
<text fg={theme.textMuted}>(reset: {OpenAIUsage.formatResetTime(secondary().reset_at)})</text>
</box>
)}
</Show>
<Show when={usage().credits}>
{(credits) => (
<box flexDirection="row" gap={1}>
<text fg={theme.text}>Credits:</text>
<Show
when={credits().unlimited}
fallback={<text fg={theme.text}>{OpenAIUsage.formatCredits(credits().balance)}</text>}
>
<text fg={theme.success}>Unlimited</text>
</Show>
</box>
)}
</Show>
</box>
)}
</Show>

<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
<box>
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
Expand Down
85 changes: 84 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { createMemo, For, Show, Switch, Match, createResource, createEffect, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
Expand All @@ -11,6 +11,9 @@ import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
import { AnthropicUsage } from "@/usage/anthropic"
import { OpenAIUsage } from "@/usage/openai"
import { getUsageColor, usageBarString } from "@/usage/utils"

export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
Expand All @@ -25,8 +28,26 @@ export function Sidebar(props: { sessionID: string }) {
diff: true,
todo: true,
lsp: true,
usage: true,
})

const [anthropicUsage, { refetch: refetchAnthropic }] = createResource(() => AnthropicUsage.fetch(), { initialValue: null })
const [openaiUsage, { refetch: refetchOpenAI }] = createResource(() => OpenAIUsage.fetch(), { initialValue: null })

createEffect(on(
() => messages().length,
() => {
refetchAnthropic()
refetchOpenAI()
},
{ defer: true }
))

const colorFor = (percent: number) => getUsageColor(percent, theme)
const usageBar = (percent: number) => usageBarString(percent, 10)

const hasUsageData = createMemo(() => anthropicUsage() || openaiUsage())

// Sort MCP servers alphabetically for consistent display order
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))

Expand Down Expand Up @@ -96,6 +117,68 @@ export function Sidebar(props: { sessionID: string }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={hasUsageData()}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => setExpanded("usage", !expanded.usage)}
>
<text fg={theme.text}>{expanded.usage ? "▼" : "▶"}</text>
<text fg={theme.text}>
<b>Usage</b>
</text>
</box>
<Show when={expanded.usage}>
<Show when={anthropicUsage()}>
{(usage) => (
<>
<text fg={theme.textMuted}>Anthropic</text>
<Show when={usage().five_hour}>
{(w) => (
<text fg={theme.textMuted}>
5h <span style={{ fg: colorFor(w().utilization) }}>{usageBar(w().utilization)}</span>
{" "}{w().utilization}% ({AnthropicUsage.formatResetTime(w().resets_at)})
</text>
)}
</Show>
<Show when={usage().seven_day}>
{(w) => (
<text fg={theme.textMuted}>
7d <span style={{ fg: colorFor(w().utilization) }}>{usageBar(w().utilization)}</span>
{" "}{w().utilization}% ({AnthropicUsage.formatResetTime(w().resets_at)})
</text>
)}
</Show>
</>
)}
</Show>
<Show when={openaiUsage()}>
{(usage) => (
<>
<text fg={theme.textMuted}>OpenAI ({OpenAIUsage.getPlanDisplayName(usage().plan_type)})</text>
<Show when={usage().rate_limit?.primary_window}>
{(w) => (
<text fg={theme.textMuted}>
{OpenAIUsage.formatWindowDuration(w().limit_window_seconds)} <span style={{ fg: colorFor(w().used_percent) }}>{usageBar(w().used_percent)}</span>
{" "}{Math.round(w().used_percent)}% ({OpenAIUsage.formatResetTime(w().reset_at)})
</text>
)}
</Show>
<Show when={usage().rate_limit?.secondary_window}>
{(w) => (
<text fg={theme.textMuted}>
{OpenAIUsage.formatWindowDuration(w().limit_window_seconds)} <span style={{ fg: colorFor(w().used_percent) }}>{usageBar(w().used_percent)}</span>
{" "}{Math.round(w().used_percent)}% ({OpenAIUsage.formatResetTime(w().reset_at)})
</text>
)}
</Show>
</>
)}
</Show>
</Show>
</box>
</Show>
<Show when={mcpEntries().length > 0}>
<box>
<box
Expand Down
76 changes: 76 additions & 0 deletions packages/opencode/src/usage/anthropic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Auth } from "@/auth"
import z from "zod"

export namespace AnthropicUsage {
const UsageWindow = z.object({
utilization: z.number(),
resets_at: z.string().nullable(),
})

export const UsageData = z.object({
five_hour: UsageWindow.nullish(),
seven_day: UsageWindow.nullish(),
seven_day_opus: UsageWindow.nullish(),
})
export type UsageData = z.infer<typeof UsageData>

export async function fetch(): Promise<UsageData | null> {
const auth = await Auth.get("anthropic")
if (!auth || auth.type !== "oauth") {
return null
}

try {
const response = await globalThis.fetch("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
Authorization: `Bearer ${auth.access}`,
"anthropic-beta": "oauth-2025-04-20",
Accept: "application/json",
},
signal: AbortSignal.timeout(10_000),
})

if (!response.ok) {
console.error(`Anthropic usage API error: ${response.status}`)
return null
}

const data = await response.json()
const parsed = UsageData.safeParse(data)
if (!parsed.success) {
console.error("Failed to parse Anthropic usage data:", parsed.error)
return null
}

return parsed.data
} catch (error) {
console.error("Failed to fetch Anthropic usage:", error)
return null
}
}

export function formatResetTime(isoString: string | null): string {
if (!isoString) return "N/A"
const date = new Date(isoString)
const now = new Date()
const diffMs = date.getTime() - now.getTime()

if (diffMs <= 0) return "refreshing"

const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)

if (diffHours >= 24) {
const days = Math.floor(diffHours / 24)
return `${days}d ${diffHours % 24}h`
}
if (diffHours > 0) {
return `${diffHours}h ${diffMins % 60}m`
}
if (diffMins > 0) {
return `${diffMins}m`
}
return "soon"
}
}
Loading