diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa4..5394bc25f88f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = { fontSmoothing: true, glassOpacity: 80, planModeEnabled: false, + showPlanUsageInSidebar: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 040e63b80229..90490db57cdd 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -89,7 +89,36 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { "const lines = createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "get_usage") {', + ' if (process.env.T3_PROBE_HANG_USAGE === "true") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " session: {", + " total_cost_usd: 0,", + " total_api_duration_ms: 0,", + " total_duration_ms: 0,", + " total_lines_added: 0,", + " total_lines_removed: 0,", + " model_usage: {},", + " },", + ' subscription_type: "pro",', + " rate_limits_available: true,", + " rate_limits: {", + ' seven_day: { utilization: 12, resets_at: "2026-08-28T01:00:00.000Z" },', + ' seven_day_fable: { utilization: 18, resets_at: "2026-08-28T01:00:00.000Z" },', + " },", + " behaviors: null,", + " },", + " },", + ' }) + "\\n");', + " return;", + " }", + ' if (message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -127,6 +156,36 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { subscriptionType: "pro", tokenSource: "oauth", apiProvider: undefined, + rateLimits: { + seven_day: { utilization: 12, resets_at: "2026-08-28T01:00:00.000Z" }, + seven_day_fable: { utilization: 18, resets_at: "2026-08-28T01:00:00.000Z" }, + }, + slashCommands: [ + { + name: "review", + description: "Review changes", + input: { hint: "[path]" }, + }, + ], + }); + + const capabilitiesWithHungUsage = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { + ...process.env, + T3_PROBE_INVOCATION_PATH: invocationPath, + T3_PROBE_HANG_USAGE: "true", + }, + workspaceCwd, + 25, + ); + + assert.deepEqual(capabilitiesWithHungUsage, { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + apiProvider: undefined, + rateLimits: undefined, slashCommands: [ { name: "review", diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..1116d9f76b0f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,3 +1,5 @@ +import * as NodeTimersPromises from "node:timers/promises"; + import { type ClaudeSettings, type ModelCapabilities, @@ -42,6 +44,7 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { normalizeClaudePlanUsage } from "../providerPlanUsage.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -583,6 +586,27 @@ function apiProviderAuthMetadata( // account info. The previous 8s budget expired mid-init, so the probe returned // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +const USAGE_PROBE_TIMEOUT_MS = 2_000; +const CAPABILITIES_PROBE_RETURN_BUFFER_MS = 100; + +async function optionalPromiseWithin(promise: Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + void promise.catch(() => undefined); + return null; + } + + const timeoutAbort = new AbortController(); + try { + return await Promise.race([ + promise.catch(() => null), + NodeTimersPromises.setTimeout(timeoutMs, null, { signal: timeoutAbort.signal }).catch( + () => null, + ), + ]); + } finally { + timeoutAbort.abort(); + } +} /** * Keep workspace-scoped command discovery intact while isolating the periodic @@ -641,6 +665,7 @@ type ClaudeCapabilitiesProbe = { * the subscription/token fields are absent and auth is external AWS creds. */ readonly apiProvider: string | undefined; + readonly rateLimits?: unknown; readonly slashCommands: ReadonlyArray; }; @@ -724,7 +749,8 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * message is ever written to the subprocess stdin. This means the Claude * Code subprocess completes its local initialization IPC (returning * account info and slash commands) but never starts an API request to - * Anthropic. We read the init data and then abort the subprocess. + * Anthropic. We read the init data, request account usage over the SDK's + * control channel when available, and then abort the subprocess. * * This is used as a fallback when `claude auth status` does not include * subscription type information. @@ -733,6 +759,7 @@ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, + usageTimeoutMs = USAGE_PROBE_TIMEOUT_MS, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -742,6 +769,7 @@ const probeClaudeCapabilities = ( claudeEnvironment, ); return yield* Effect.tryPromise(async () => { + const startedAt = performance.now(); const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. @@ -765,11 +793,24 @@ const probeClaudeCapabilities = ( readonly apiProvider?: string; } | undefined; + const remainingProbeMs = + CAPABILITIES_PROBE_TIMEOUT_MS - + (performance.now() - startedAt) - + CAPABILITIES_PROBE_RETURN_BUFFER_MS; + const boundedUsageTimeoutMs = Math.min(usageTimeoutMs, remainingProbeMs); + const usage = + boundedUsageTimeoutMs > 0 + ? await optionalPromiseWithin( + q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), + boundedUsageTimeoutMs, + ) + : null; return { email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, + rateLimits: usage?.rate_limits as unknown, slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -953,6 +994,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + const planUsage = normalizeClaudePlanUsage(capabilities.rateLimits, checkedAt); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -960,6 +1002,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( models, slashCommands: dedupedSlashCommands, skills, + ...(planUsage ? { planUsage } : {}), probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 93730046dc49..a32a9b103b0d 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -33,6 +33,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; +import { normalizeCodexPlanUsage } from "../providerPlanUsage.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -45,6 +46,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse | undefined; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -395,24 +397,27 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, + rateLimits: undefined, version, models: appendCustomCodexModels([], input.customModels ?? []), skills: [], } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + client.request("account/rateLimits/read", undefined).pipe(Effect.option), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + rateLimits: Option.getOrUndefined(rateLimits), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -600,6 +605,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); + const planUsage = normalizeCodexPlanUsage(snapshot.rateLimits, checkedAt); return buildServerProvider({ presentation: CODEX_PRESENTATION, @@ -607,6 +613,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, models: snapshot.models, skills: snapshot.skills, + ...(planUsage ? { planUsage } : {}), slashCommands: [ { name: "feedback", diff --git a/apps/server/src/provider/providerPlanUsage.test.ts b/apps/server/src/provider/providerPlanUsage.test.ts new file mode 100644 index 000000000000..91a497766705 --- /dev/null +++ b/apps/server/src/provider/providerPlanUsage.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeClaudePlanUsage, normalizeCodexPlanUsage } from "./providerPlanUsage.ts"; + +const checkedAt = "2026-08-24T18:00:00.000Z"; + +describe("provider plan usage", () => { + it("normalizes Codex primary and weekly windows", () => { + expect( + normalizeCodexPlanUsage( + { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 31, windowDurationMins: 300, resetsAt: 1_777_000_000 }, + secondary: { usedPercent: 72, windowDurationMins: 10_080, resetsAt: 1_777_500_000 }, + }, + }, + checkedAt, + ), + ).toEqual({ + checkedAt, + windows: [ + { + id: "codex:primary", + label: "5-hour", + usedPercent: 31, + resetsAt: "2026-04-24T03:06:40.000Z", + windowDurationMinutes: 300, + }, + { + id: "codex:secondary", + label: "Weekly", + usedPercent: 72, + resetsAt: "2026-04-29T22:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ], + }); + }); + + it("keeps named Codex buckets distinct and clamps percentages", () => { + const usage = normalizeCodexPlanUsage( + { + rateLimitsByLimitId: { + codex: { limitName: "Codex", primary: { usedPercent: -1 } }, + codex_fast: { limitName: "Fast", primary: { usedPercent: 120 } }, + }, + }, + checkedAt, + ); + + expect(usage?.windows).toEqual([ + { + id: "codex:primary", + label: "Primary", + usedPercent: 0, + resetsAt: null, + }, + { + id: "codex_fast:primary", + label: "Fast · Primary", + usedPercent: 100, + resetsAt: null, + }, + ]); + }); + + it("normalizes Claude all-models and forward-compatible Fable windows", () => { + expect( + normalizeClaudePlanUsage( + { + seven_day: { utilization: 10, resets_at: "2026-08-29T01:00:00Z" }, + seven_day_fable: { utilization: 18, resets_at: "2026-08-29T01:00:00Z" }, + extra_usage: { utilization: 70 }, + }, + checkedAt, + ), + ).toEqual({ + checkedAt, + windows: [ + { + id: "seven_day", + label: "All models", + usedPercent: 10, + resetsAt: "2026-08-29T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + { + id: "seven_day_fable", + label: "Fable", + usedPercent: 18, + resetsAt: "2026-08-29T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ], + }); + }); + + it("omits unavailable and malformed usage", () => { + expect(normalizeCodexPlanUsage({}, checkedAt)).toBeUndefined(); + expect(normalizeClaudePlanUsage(null, checkedAt)).toBeUndefined(); + expect( + normalizeClaudePlanUsage({ seven_day: { utilization: "10" } }, checkedAt), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerPlanUsage.ts b/apps/server/src/provider/providerPlanUsage.ts new file mode 100644 index 000000000000..45ed1b561479 --- /dev/null +++ b/apps/server/src/provider/providerPlanUsage.ts @@ -0,0 +1,173 @@ +import type { ProviderPlanUsage, ProviderPlanUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +type UnknownRecord = Readonly>; + +function asRecord(value: unknown): UnknownRecord | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as UnknownRecord) + : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function normalizedPercent(value: unknown): number | null { + const percent = asFiniteNumber(value); + return percent === null ? null : Math.max(0, Math.min(100, percent)); +} + +function isoFromUnixSeconds(value: unknown): string | null { + const seconds = asFiniteNumber(value); + if (seconds === null) return null; + return Option.match(DateTime.make(seconds * 1_000), { + onNone: () => null, + onSome: DateTime.formatIso, + }); +} + +function isoFromString(value: unknown): string | null { + if (typeof value !== "string") return null; + return Option.match(DateTime.make(value), { + onNone: () => null, + onSome: DateTime.formatIso, + }); +} + +function codexDurationLabel(durationMinutes: number | null, role: "primary" | "secondary") { + if (durationMinutes === 5 * 60) return "5-hour"; + if (durationMinutes === 7 * 24 * 60) return "Weekly"; + if (durationMinutes !== null && durationMinutes > 0 && durationMinutes % 60 === 0) { + return `${durationMinutes / 60}-hour`; + } + return role === "primary" ? "Primary" : "Secondary"; +} + +function codexWindows( + bucket: UnknownRecord, + bucketId: string, + includeBucketLabel: boolean, +): ProviderPlanUsageWindow[] { + const bucketLabel = + typeof bucket.limitName === "string" && bucket.limitName.trim() + ? bucket.limitName.trim() + : bucketId; + const windows: ProviderPlanUsageWindow[] = []; + + for (const role of ["primary", "secondary"] as const) { + const window = asRecord(bucket[role]); + if (!window) continue; + const usedPercent = normalizedPercent(window.usedPercent); + if (usedPercent === null) continue; + const durationMinutes = asFiniteNumber(window.windowDurationMins); + const windowLabel = codexDurationLabel(durationMinutes, role); + windows.push({ + id: `${bucketId}:${role}`, + label: includeBucketLabel ? `${bucketLabel} · ${windowLabel}` : windowLabel, + usedPercent, + resetsAt: isoFromUnixSeconds(window.resetsAt), + ...(durationMinutes !== null && durationMinutes >= 0 + ? { windowDurationMinutes: Math.floor(durationMinutes) } + : {}), + }); + } + + return windows; +} + +/** Normalize Codex app-server's multi-bucket rate-limit response. */ +export function normalizeCodexPlanUsage( + value: unknown, + checkedAt: string, +): ProviderPlanUsage | undefined { + const response = asRecord(value); + if (!response) return undefined; + const byLimitId = asRecord(response.rateLimitsByLimitId); + const entries = byLimitId + ? Object.entries(byLimitId).flatMap(([id, bucket]) => { + const record = asRecord(bucket); + return record ? ([[id, record]] as const) : []; + }) + : []; + const fallback = asRecord(response.rateLimits); + const buckets = + entries.length > 0 + ? entries + : fallback + ? ([[typeof fallback.limitId === "string" ? fallback.limitId : "codex", fallback]] as const) + : []; + const windows = buckets.flatMap(([id, bucket]) => { + const limitName = typeof bucket.limitName === "string" ? bucket.limitName.trim() : ""; + const isDefaultBucket = id.toLowerCase() === "codex" || limitName.toLowerCase() === "codex"; + return codexWindows(bucket, id, buckets.length > 1 && !isDefaultBucket); + }); + return windows.length > 0 ? { checkedAt, windows } : undefined; +} + +const CLAUDE_WINDOW_LABELS: Readonly> = { + five_hour: "Current session", + seven_day: "All models", + seven_day_fable: "Fable", + seven_day_opus: "Opus", + seven_day_sonnet: "Sonnet", + seven_day_oauth_apps: "OAuth apps", +}; + +const CLAUDE_WINDOW_ORDER = [ + "five_hour", + "seven_day", + "seven_day_fable", + "seven_day_opus", + "seven_day_sonnet", + "seven_day_oauth_apps", +] as const; + +function claudeWindowLabel(id: string): string { + const known = CLAUDE_WINDOW_LABELS[id]; + if (known) return known; + return id + .replace(/^seven_day_/, "") + .split("_") + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" "); +} + +/** Normalize Claude Agent SDK's structured `/usage` rate-limit windows. */ +export function normalizeClaudePlanUsage( + value: unknown, + checkedAt: string, +): ProviderPlanUsage | undefined { + const rateLimits = asRecord(value); + if (!rateLimits) return undefined; + const orderedKeys = [ + ...CLAUDE_WINDOW_ORDER, + ...Object.keys(rateLimits) + .filter((key) => !CLAUDE_WINDOW_ORDER.includes(key as (typeof CLAUDE_WINDOW_ORDER)[number])) + .sort(), + ]; + const windows: ProviderPlanUsageWindow[] = []; + + for (const id of orderedKeys) { + if (id === "extra_usage") continue; + const window = asRecord(rateLimits[id]); + if (!window) continue; + const usedPercent = normalizedPercent(window.utilization); + if (usedPercent === null) continue; + windows.push({ + id, + label: claudeWindowLabel(id), + usedPercent, + resetsAt: isoFromString(window.resets_at), + ...(id === "five_hour" + ? { windowDurationMinutes: 5 * 60 } + : id.startsWith("seven_day") + ? { windowDurationMinutes: 7 * 24 * 60 } + : {}), + }); + } + + return windows.length > 0 ? { checkedAt, windows } : undefined; +} diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index adbe110d9408..629c328b3410 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ModelCapabilities, + ProviderPlanUsage, ServerProvider, ServerProviderAuth, ServerProviderSkill, @@ -221,6 +222,7 @@ export function buildServerProvider(input: { models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; + planUsage?: ProviderPlanUsage; probe: ProviderProbeResult; }): ServerProviderDraft { const versionAdvisory = input.driver @@ -249,6 +251,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.planUsage ? { planUsage: input.planUsage } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..b82870ac1f1c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -510,6 +510,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), + ...(settings.showPlanUsageInSidebar !== DEFAULT_UNIFIED_SETTINGS.showPlanUsageInSidebar + ? ["Plan usage indicator"] + : []), ...(settings.enableLegacyTokenStreaming !== DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming ? ["Stream token by token"] @@ -576,6 +579,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, + settings.showPlanUsageInSidebar, settings.showSkillsInSlashMenu, settings.timestampFormat, settings.wordWrap, @@ -653,6 +657,7 @@ export function useSettingsRestore(onRestored?: () => void) { wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, + showPlanUsageInSidebar: DEFAULT_UNIFIED_SETTINGS.showPlanUsageInSidebar, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, @@ -2107,6 +2112,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + showPlanUsageInSidebar: DEFAULT_UNIFIED_SETTINGS.showPlanUsageInSidebar, + }) + } + /> + ) : null + } + control={ + + updateSettings({ showPlanUsageInSidebar: Boolean(checked) }) + } + aria-label="Show plan usage in sidebar" + /> + } + /> + void }) { + const showPlanUsage = useClientSettings((settings) => settings.showPlanUsageInSidebar); + const { environments } = useEnvironments(); + const entries = useMemo(() => collectSidebarPlanUsage(environments), [environments]); + const highestPercent = showPlanUsage ? highestPlanUsagePercent(entries) : null; + const roundedPercent = highestPercent === null ? null : Math.round(highestPercent); + const tone = highestPercent === null ? "muted" : sidebarPlanUsageTone(highestPercent); + const showEnvironment = environments.length > 1; + const systemLocale = + typeof window === "undefined" ? null : (window.desktopBridge?.getSystemLocale?.() ?? null); + const label = + roundedPercent === null ? "Usage" : `Usage, highest plan utilization ${roundedPercent}%`; + + return ( + + + svg]:text-current!", + tone === "danger" && + "text-destructive hover:text-destructive [&>svg]:text-current!", + )} + onClick={onClick} + size={roundedPercent === null ? "icon" : "default"} + > + + {roundedPercent === null ? null : ( + {roundedPercent}% + )} + + } + /> + + {showPlanUsage && entries.length > 0 ? ( +
+
Plan usage
+ {entries.map((entry) => { + const reset = formatPlanUsageReset(entry.resetsAt, systemLocale); + return ( +
+
+
+ {entry.providerLabel} · {entry.windowLabel} +
+ {showEnvironment || reset ? ( +
+ {[ + showEnvironment ? entry.environmentLabel : null, + reset ? `Resets ${reset}` : null, + ] + .filter(Boolean) + .join(" · ")} +
+ ) : null} +
+ + {Math.round(entry.usedPercent)}% + +
+ ); + })} +
+ ) : ( + "Usage" + )} +
+
+
+ ); +} + export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); const canGoBack = useCanGoBack(); @@ -216,11 +297,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { onClick={handlePullRequestsClick} /> ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> + )} diff --git a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts new file mode 100644 index 000000000000..a47952b9c5e7 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts @@ -0,0 +1,113 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + collectSidebarPlanUsage, + formatPlanUsageReset, + highestPlanUsagePercent, + sidebarPlanUsageTone, +} from "./SidebarPlanUsage.logic.ts"; + +function provider(overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude", + enabled: true, + installed: true, + version: "2.1.219", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-24T18:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + }; +} + +describe("sidebar plan usage", () => { + it("collects every provider window with environment context", () => { + const entries = collectSidebarPlanUsage([ + { + environmentId: "environment-local", + label: "Local", + serverConfig: { + providers: [ + provider({ + planUsage: { + checkedAt: "2026-08-24T18:01:00.000Z", + windows: [ + { id: "seven_day", label: "All models", usedPercent: 10, resetsAt: null }, + { id: "seven_day_fable", label: "Fable", usedPercent: 18, resetsAt: null }, + ], + }, + }), + ], + }, + }, + ]); + + expect( + entries.map(({ providerLabel, windowLabel, usedPercent }) => ({ + providerLabel, + windowLabel, + usedPercent, + })), + ).toEqual([ + { providerLabel: "Claude", windowLabel: "All models", usedPercent: 10 }, + { providerLabel: "Claude", windowLabel: "Fable", usedPercent: 18 }, + ]); + expect(highestPlanUsagePercent(entries)).toBe(18); + }); + + it("uses stable environment ids in keys when labels collide", () => { + const usageProvider = provider({ + planUsage: { + checkedAt: "2026-08-24T18:01:00.000Z", + windows: [{ id: "seven_day", label: "All models", usedPercent: 10, resetsAt: null }], + }, + }); + const entries = collectSidebarPlanUsage([ + { + environmentId: "environment-a", + label: "Local", + serverConfig: { providers: [usageProvider] }, + }, + { + environmentId: "environment-b", + label: "Local", + serverConfig: { providers: [usageProvider] }, + }, + ]); + + expect(new Set(entries.map((entry) => entry.key)).size).toBe(2); + }); + + it("formats reset times with the host locale", () => { + const resetsAt = "2026-08-24T22:00:00.000Z"; + expect(formatPlanUsageReset(resetsAt, "en-GB")).toBe( + new Intl.DateTimeFormat("en-GB", { + weekday: "short", + hour: "numeric", + minute: "2-digit", + }).format(new Date(resetsAt)), + ); + }); + + it("uses the requested warning thresholds", () => { + expect(sidebarPlanUsageTone(69.9)).toBe("muted"); + expect(sidebarPlanUsageTone(70)).toBe("warning"); + expect(sidebarPlanUsageTone(89.9)).toBe("warning"); + expect(sidebarPlanUsageTone(90)).toBe("danger"); + }); + + it("reports no percentage before a provider has returned plan usage", () => { + expect( + collectSidebarPlanUsage([ + { environmentId: "environment-local", label: "Local", serverConfig: null }, + ]), + ).toEqual([]); + expect(highestPlanUsagePercent([])).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts new file mode 100644 index 000000000000..dbffd9b4ac1a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts @@ -0,0 +1,70 @@ +import type { ServerProvider } from "@t3tools/contracts"; + +import { resolveTimestampLocale } from "../../timestampFormat"; + +export interface SidebarPlanUsageEnvironment { + readonly environmentId: string; + readonly label: string; + readonly serverConfig: { + readonly providers: ReadonlyArray; + } | null; +} + +export interface SidebarPlanUsageEntry { + readonly key: string; + readonly environmentLabel: string; + readonly providerLabel: string; + readonly windowLabel: string; + readonly usedPercent: number; + readonly resetsAt: string | null; + readonly checkedAt: string; +} + +export type SidebarPlanUsageTone = "muted" | "warning" | "danger"; + +export function collectSidebarPlanUsage( + environments: ReadonlyArray, +): SidebarPlanUsageEntry[] { + return environments.flatMap((environment) => + (environment.serverConfig?.providers ?? []).flatMap((provider) => { + const planUsage = provider.planUsage; + if (!planUsage) return []; + return planUsage.windows.map((window) => ({ + key: `${environment.environmentId}:${provider.instanceId}:${window.id}`, + environmentLabel: environment.label, + providerLabel: provider.displayName ?? provider.driver, + windowLabel: window.label, + usedPercent: window.usedPercent, + resetsAt: window.resetsAt, + checkedAt: planUsage.checkedAt, + })); + }), + ); +} + +export function formatPlanUsageReset( + resetsAt: string | null, + systemLocale: string | null | undefined, +): string | null { + if (!resetsAt) return null; + const reset = new Date(resetsAt); + if (Number.isNaN(reset.getTime())) return null; + return new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { + weekday: "short", + hour: "numeric", + minute: "2-digit", + }).format(reset); +} + +export function highestPlanUsagePercent( + entries: ReadonlyArray, +): number | null { + if (entries.length === 0) return null; + return entries.reduce((highest, entry) => Math.max(highest, entry.usedPercent), 0); +} + +export function sidebarPlanUsageTone(usedPercent: number): SidebarPlanUsageTone { + if (usedPercent >= 90) return "danger"; + if (usedPercent >= 70) return "warning"; + return "muted"; +} diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37..5a440b301fe3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -5,6 +5,13 @@ the providers' local session history and shows API-equivalent token cost, proces savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token cost shown here. +On web and desktop, enable **Plan usage indicator** under **Settings → General** to put the +highest provider-reported subscription utilization beside the Usage icon. The number is muted below +70%, amber from 70%, and red from 90%. Hover it to see every reported limit, including separate +weekly model limits such as Claude Fable when the provider supplies them. Plan limits refresh with +provider health checks and disappear when a provider or account cannot report subscription usage. +Codex and Claude currently report plan limits; other providers keep the normal icon-only link. + Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the headline and chart, and refreshing rescans every connected environment. diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c4..c432a8a400de 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -45,6 +45,35 @@ describe("ServerProvider", () => { expect(parsed.skills).toEqual([]); expect(parsed.versionAdvisory).toBeUndefined(); expect(parsed.updateState).toBeUndefined(); + expect(parsed.planUsage).toBeUndefined(); + }); + + it("decodes provider plan usage windows", () => { + const parsed = decodeServerProvider({ + ...baseProviderSnapshot, + planUsage: { + checkedAt: "2026-08-24T18:00:00.000Z", + windows: [ + { + id: "weekly", + label: "Weekly", + usedPercent: 72, + resetsAt: "2026-08-28T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ], + }, + }); + + expect(parsed.planUsage?.windows).toEqual([ + { + id: "weekly", + label: "Weekly", + usedPercent: 72, + resetsAt: "2026-08-28T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ]); }); it("defaults one-click update support when decoding older advisory snapshots", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..d053a1a73fa5 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -61,6 +61,23 @@ export const ServerProviderAuth = Schema.Struct({ }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; +/** One subscription-plan quota window reported by a provider. */ +export const ProviderPlanUsageWindow = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + usedPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), + resetsAt: Schema.NullOr(IsoDateTime), + windowDurationMinutes: Schema.optional(NonNegativeInt), +}); +export type ProviderPlanUsageWindow = typeof ProviderPlanUsageWindow.Type; + +/** Provider-authored plan utilization, sampled during the provider health probe. */ +export const ProviderPlanUsage = Schema.Struct({ + checkedAt: IsoDateTime, + windows: Schema.Array(ProviderPlanUsageWindow), +}); +export type ProviderPlanUsage = typeof ProviderPlanUsage.Type; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -192,6 +209,7 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + planUsage: Schema.optionalKey(ProviderPlanUsage), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..815a905f7469 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -110,6 +110,13 @@ describe("ClientSettings sidebar", () => { ); }); + it("keeps provider plan usage hidden until the user opts in", () => { + expect(decodeClientSettings({}).showPlanUsageInSidebar).toBe(false); + expect(decodeClientSettingsPatch({ showPlanUsageInSidebar: true }).showPlanUsageInSidebar).toBe( + true, + ); + }); + it("allows auto-settle by inactivity to be disabled", () => { expect( decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..c15cafff8c2b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -226,6 +226,7 @@ export const ClientSettingsSchema = Schema.Struct({ // commands) for users who still rely on the old workflow. planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + showPlanUsageInSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the // old keys, so everyone, including prior beta opt-outs, resets to the new @@ -915,6 +916,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), + showPlanUsageInSidebar: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),