Skip to content
Closed
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
8 changes: 8 additions & 0 deletions packages/app/src/components/dialog-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsQuota } from "./settings-quota"

export const DialogSettings: Component = () => {
const language = useLanguage()
Expand Down Expand Up @@ -45,6 +46,10 @@ export const DialogSettings: Component = () => {
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
<Tabs.Trigger value="quota">
<Icon name="status" />
{language.t("settings.quota.title")}
</Tabs.Trigger>
</div>
</div>
</div>
Expand All @@ -67,6 +72,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="quota" class="no-scrollbar">
<SettingsQuota />
</Tabs.Content>
</Tabs>
</Dialog>
)
Expand Down
117 changes: 96 additions & 21 deletions packages/app/src/components/settings-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { showToast } from "@opencode-ai/ui/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { createMemo, createSignal, onMount, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
Expand All @@ -13,6 +13,32 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"

// Quota types from SDK
interface UsageWindow {
usedPercent: number | null
remainingPercent: number | null
windowSeconds: number | null
resetAfterSeconds: number | null
resetAt: number | null
resetAtFormatted: string | null
resetAfterFormatted: string | null
valueLabel: string | null
}

interface ProviderUsage {
windows: Record<string, UsageWindow>
}

interface QuotaResult {
providerId: string
providerName: string
ok: boolean
configured: boolean
usage: ProviderUsage | null
error: string | null
fetchedAt: number
}

type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]

Expand All @@ -34,6 +60,42 @@ export const SettingsProviders: Component = () => {
const globalSync = useGlobalSync()
const providers = useProviders()

const [quotaData, setQuotaData] = createSignal<QuotaResult[]>([])
const [quotaLoading, setQuotaLoading] = createSignal(false)

onMount(async () => {
setQuotaLoading(true)
try {
const response = await globalSDK.client.global.quota()
if (response.data) {
setQuotaData(response.data)
}
} catch (e) {
console.error("Failed to fetch quota:", e)
} finally {
setQuotaLoading(false)
}
})

const getQuotaForProvider = (providerId: string): QuotaResult | undefined => {
return quotaData().find((q) => q.providerId === providerId)
}

const formatQuota = (quota: QuotaResult): string => {
if (!quota.ok || !quota.usage?.windows) return ""
const windows = Object.entries(quota.usage.windows)
if (windows.length === 0) return ""
const parts: string[] = []
for (const [name, win] of windows) {
if (win.usedPercent !== null) {
parts.push(`${name}: ${win.usedPercent.toFixed(1)}%`)
} else if (win.remainingPercent !== null) {
parts.push(`${name}: ${win.remainingPercent.toFixed(1)}%`)
}
}
return parts.join(", ")
}

const connected = createMemo(() => {
return providers
.connected()
Expand Down Expand Up @@ -147,27 +209,40 @@ export const SettingsProviders: Component = () => {
}
>
<For each={connected()}>
{(item) => (
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
{(item) => {
const quota = () => getQuotaForProvider(item.id)
const quotaDisplay = () => {
const q = quota()
if (!q) return ""
return formatQuota(q)
}
return (
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
</div>
<Show when={quotaDisplay()}>
<span class="text-12-regular text-text-weak pl-8">{quotaDisplay()}</span>
</Show>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
)}
)
}}
</For>
</Show>
</SettingsList>
Expand Down
141 changes: 141 additions & 0 deletions packages/app/src/components/settings-quota.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Progress } from "@opencode-ai/ui/progress"
import { createSignal, onMount, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { SettingsList } from "./settings-list"

interface UsageWindow {
usedPercent: number | null
remainingPercent: number | null
windowSeconds: number | null
resetAfterSeconds: number | null
resetAt: number | null
resetAtFormatted: string | null
resetAfterFormatted: string | null
valueLabel: string | null
}

interface ProviderUsage {
windows: Record<string, UsageWindow>
}

interface QuotaResult {
providerId: string
providerName: string
ok: boolean
configured: boolean
usage: ProviderUsage | null
error: string | null
fetchedAt: number
}

export const SettingsQuota: Component = () => {
const language = useLanguage()
const globalSDK = useGlobalSDK()

const [quotaData, setQuotaData] = createSignal<QuotaResult[]>([])
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal<string | null>(null)

onMount(async () => {
setLoading(true)
setError(null)
try {
const response = await globalSDK.client.global.quota()
if (response.data) {
setQuotaData(response.data)
}
} catch (e) {
console.error("Failed to fetch quota:", e)
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
})

const formatQuota = (quota: QuotaResult): { label: string; percent: number; remaining: number }[] => {
if (!quota.ok || !quota.usage?.windows) return []
const windows = Object.entries(quota.usage.windows)
if (windows.length === 0) return []
const parts: { label: string; percent: number; remaining: number }[] = []
for (const [name, win] of windows) {
if (win.usedPercent !== null) {
parts.push({ label: name, percent: win.usedPercent, remaining: 100 - win.usedPercent })
} else if (win.remainingPercent !== null) {
parts.push({ label: name, percent: 100 - win.remainingPercent, remaining: win.remainingPercent })
}
}
return parts
}

return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.quota.title")}</h2>
</div>
</div>

<div class="flex flex-col gap-8 max-w-[720px] flex-1">
<Show
when={!loading()}
fallback={
<SettingsList>
<div class="py-4 text-14-regular text-text-weak">Loading...</div>
</SettingsList>
}
>
<Show when={error()}>
<SettingsList>
<div class="py-4 text-14-regular text-text-weak">{error()}</div>
</SettingsList>
</Show>

<Show when={!error()}>
<Show
when={quotaData().length > 0}
fallback={
<SettingsList>
<div class="py-4 text-14-regular text-text-weak">{language.t("settings.quota.empty")}</div>
</SettingsList>
}
>
<SettingsList>
<For each={quotaData()}>
{(quota) => {
const items = () => formatQuota(quota)
return (
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-3 min-w-0 w-full">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={quota.providerId} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong truncate">{quota.providerName}</span>
</div>
<Show when={items().length > 0}>
<div class="flex flex-col gap-3 pl-8">
<For each={items()}>
{(item) => (
<Progress value={item.percent} maxValue={100} showValueLabel>
{item.label}
</Progress>
)}
</For>
</div>
</Show>
<Show when={quota.error}>
<span class="text-12-regular text-text-weak pl-8">{quota.error}</span>
</Show>
</div>
</div>
)
}}
</For>
</SettingsList>
</Show>
</Show>
</Show>
</div>
</div>
)
}
4 changes: 4 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,10 @@ export const dict = {
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Model settings will be configurable here.",

"settings.quota.title": "Quota",
"settings.quota.empty": "No providers with quota available",

"settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,10 @@ export const dict = {
"settings.models.title": "模型",
"settings.models.description": "模型设置将在此处可配置。",

"settings.quota.title": "配额",
"settings.quota.empty": "没有可用的配额信息",


"settings.agents.title": "智能体",
"settings.agents.description": "智能体设置将在此处可配置。",

Expand Down
Loading
Loading