diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 8471b87..b041b2c 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1059,6 +1059,9 @@ final class AppStore { if let pct = usage.sevenDaySonnetPercent { details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt)) } + for scoped in usage.scopedWeekly { + details.append(.init(label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt)) + } } let plan = subscription?.tier.displayName return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: []) diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift index f97641d..4516dd0 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -140,9 +140,8 @@ enum ClaudeSubscriptionService { case 200: clearUsageBlock() do { - let decoded = try JSONDecoder().decode(UsageResponse.self, from: data) let tier = try ClaudeCredentialStore.subscriptionTier() - return mapResponse(decoded, rawTier: tier) + return try parseUsage(data, rawTier: tier) } catch { throw FetchError.usageDecodeFailed } @@ -191,17 +190,26 @@ enum ClaudeSubscriptionService { // MARK: - Response mapping + /// Decodes a usage endpoint response body. Internal so tests can feed the + /// captured JSON shape without a network round trip. + static func parseUsage(_ data: Data, rawTier: String?) throws -> SubscriptionUsage { + let decoded = try JSONDecoder().decode(UsageResponse.self, from: data) + return mapResponse(decoded, rawTier: rawTier) + } + private struct UsageResponse: Decodable { let fiveHour: Window? let sevenDay: Window? let sevenDayOpus: Window? let sevenDaySonnet: Window? + let limits: [Limit]? enum CodingKeys: String, CodingKey { case fiveHour = "five_hour" case sevenDay = "seven_day" case sevenDayOpus = "seven_day_opus" case sevenDaySonnet = "seven_day_sonnet" + case limits } } @@ -214,8 +222,43 @@ enum ClaudeSubscriptionService { } } + /// Entry in the `limits` array. Model-scoped weekly buckets (like Fable) + /// only appear here, not as named top-level windows. + private struct Limit: Decodable { + let kind: String? + let percent: Double? + let resetsAt: String? + let scope: Scope? + + enum CodingKeys: String, CodingKey { + case kind, percent, scope + case resetsAt = "resets_at" + } + + struct Scope: Decodable { + let model: Model? + struct Model: Decodable { + let displayName: String? + enum CodingKeys: String, CodingKey { + case displayName = "display_name" + } + } + } + } + private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage { - SubscriptionUsage( + let scopedWeekly = (r.limits ?? []).compactMap { limit -> SubscriptionUsage.ScopedWindow? in + guard limit.kind == "weekly_scoped", + let name = limit.scope?.model?.displayName, + let percent = limit.percent + else { return nil } + return SubscriptionUsage.ScopedWindow( + label: name, + percent: percent, + resetsAt: parseDate(limit.resetsAt) + ) + } + return SubscriptionUsage( tier: SubscriptionUsage.tier(from: rawTier), rawTier: rawTier, fiveHourPercent: r.fiveHour?.utilization, @@ -226,6 +269,7 @@ enum ClaudeSubscriptionService { sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt), sevenDaySonnetPercent: r.sevenDaySonnet?.utilization, sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt), + scopedWeekly: scopedWeekly, fetchedAt: Date() ) } diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift index 350983f..211e7f1 100644 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift @@ -21,6 +21,15 @@ struct SubscriptionUsage: Sendable, Equatable { } } + /// A model-scoped weekly limit from the `limits` array (e.g. the Fable + /// bucket). The label is the API's `scope.model.display_name`, so new + /// model buckets show up without a client update. + struct ScopedWindow: Sendable, Equatable { + let label: String + let percent: Double + let resetsAt: Date? + } + let tier: Tier let rawTier: String? let fiveHourPercent: Double? @@ -31,6 +40,7 @@ struct SubscriptionUsage: Sendable, Equatable { let sevenDayOpusResetsAt: Date? let sevenDaySonnetPercent: Double? let sevenDaySonnetResetsAt: Date? + let scopedWeekly: [ScopedWindow] let fetchedAt: Date static func tier(from raw: String?) -> Tier { diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index f2fe3e2..9e7cd70 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -1704,7 +1704,10 @@ private struct PlanInsight: View { Group { switch store.subscriptionLoadState { case .notBootstrapped, .dormant: - PlanConnectView { Task { await store.bootstrapSubscription() } } + PlanConnectView( + title: "Connect Claude subscription", + message: "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." + ) { Task { await store.bootstrapSubscription() } } case .bootstrapping: PlanLoadingView() case .loading: @@ -1714,7 +1717,10 @@ private struct PlanInsight: View { PlanLoadingView() } case .noCredentials: - PlanNoCredentialsView() + PlanNoCredentialsView( + title: "No Claude credentials found", + message: "Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again." + ) { Task { await store.bootstrapSubscription() } } case .failed: PlanFailedView(error: store.subscriptionError) case .transientFailure: @@ -1724,7 +1730,11 @@ private struct PlanInsight: View { PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.") } case let .terminalFailure(reason): - PlanReconnectView(reason: reason) { Task { await store.bootstrapSubscription() } } + PlanReconnectView( + title: "Reconnect Claude", + reason: reason, + fallback: "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect." + ) { Task { await store.bootstrapSubscription() } } case .loaded: if let usage { loadedBody(usage: usage) @@ -1763,6 +1773,9 @@ private struct PlanInsight: View { if let p = usage.sevenDaySonnetPercent { UtilizationRow(label: "7-day Sonnet", percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"]) } + ForEach(usage.scopedWeekly, id: \.label) { scoped in + UtilizationRow(label: "7-day \(scoped.label)", percent: scoped.percent, resetsAt: scoped.resetsAt, projection: projections["scoped_\(scoped.label)"]) + } } OptimizeSavingsBadge(payload: store.payload) @@ -1774,12 +1787,15 @@ private struct PlanInsight: View { private func recomputeProjections(usage: SubscriptionUsage) async { var result: [String: WindowProjection] = [:] - let inputs: [(String, Double?, Date?, TimeInterval)] = [ + var inputs: [(String, Double?, Date?, TimeInterval)] = [ ("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, Self.fiveHourSeconds), ("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt, Self.sevenDaySeconds), ("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, Self.sevenDaySeconds), ("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, Self.sevenDaySeconds), ] + for scoped in usage.scopedWeekly { + inputs.append(("scoped_\(scoped.label)", scoped.percent, scoped.resetsAt, Self.sevenDaySeconds)) + } for (key, percent, resetsAt, windowSeconds) in inputs { if let projection = await project(key: key, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) { result[key] = projection @@ -1844,24 +1860,24 @@ private struct PlanLoadingView: View { } private struct PlanNoCredentialsView: View { - @Environment(AppStore.self) private var store + let title: String + let message: String + let onRetry: () -> Void var body: some View { VStack(spacing: 10) { Image(systemName: "key.slash") .font(.system(size: 24)) .foregroundStyle(.tertiary) - Text("No Claude credentials found") + Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) - Text("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.") + Text(.init(message)) .font(.system(size: 10.5)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 280) - Button("Try Again") { - Task { await store.bootstrapSubscription() } - } + Button("Try Again", action: onRetry) .controlSize(.small) .buttonStyle(.borderedProminent) .tint(Theme.brandAccent) @@ -1904,9 +1920,12 @@ private struct PlanFailedView: View { } /// Shown the very first time a user opens the Plan tab. Clicking Connect is the -/// only path to triggering the macOS keychain prompt for Claude Code credentials — -/// the menubar app does not touch the keychain at startup. +/// only path to triggering the provider's credential read (for Claude, the +/// macOS keychain prompt) — the menubar app does not touch credentials at +/// startup. private struct PlanConnectView: View { + let title: String + let message: String let onConnect: () -> Void var body: some View { @@ -1914,10 +1933,10 @@ private struct PlanConnectView: View { Image(systemName: "link.circle") .font(.system(size: 26)) .foregroundStyle(Theme.brandAccent) - Text("Connect Claude subscription") + Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) - Text("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.") + Text(.init(message)) .font(.system(size: 10.5)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -1934,10 +1953,12 @@ private struct PlanConnectView: View { /// Shown when the refresh token has been invalidated (typically because the user /// re-authenticated on another device). Clicking the button re-runs bootstrap, -/// which reads Claude's credentials source again and writes a fresh copy to our -/// own keychain item. +/// which reads the provider's credentials source again and writes a fresh copy +/// to our own keychain item. private struct PlanReconnectView: View { + let title: String let reason: String? + let fallback: String let onReconnect: () -> Void var body: some View { @@ -1945,10 +1966,10 @@ private struct PlanReconnectView: View { Image(systemName: "arrow.triangle.2.circlepath.circle") .font(.system(size: 24)) .foregroundStyle(.red) - Text("Reconnect Claude") + Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) - Text(reason ?? "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.") + Text(reason ?? fallback) .font(.system(size: 10.5)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -1978,7 +1999,10 @@ private struct CodexPlanInsight: View { Group { switch store.codexLoadState { case .notBootstrapped, .dormant: - PlanConnectView { Task { await store.bootstrapCodex() } } + PlanConnectView( + title: "Connect ChatGPT subscription", + message: "CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically." + ) { Task { await store.bootstrapCodex() } } case .bootstrapping: PlanLoadingView() case .loading: @@ -1988,7 +2012,10 @@ private struct CodexPlanInsight: View { PlanLoadingView() } case .noCredentials: - PlanNoCredentialsView() + PlanNoCredentialsView( + title: "No Codex credentials found", + message: "Sign in with Codex first: run `codex login` in your terminal. Then click Try Again." + ) { Task { await store.bootstrapCodex() } } case .failed: PlanFailedView(error: store.codexError) case .transientFailure: @@ -1998,7 +2025,11 @@ private struct CodexPlanInsight: View { PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.") } case let .terminalFailure(reason): - PlanReconnectView(reason: reason) { Task { await store.bootstrapCodex() } } + PlanReconnectView( + title: "Reconnect Codex", + reason: reason, + fallback: "Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect." + ) { Task { await store.bootstrapCodex() } } case .loaded: if let usage = store.codexUsage { loadedBody(usage: usage) diff --git a/mac/Tests/CodeBurnMenubarTests/ClaudeSubscriptionParsingTests.swift b/mac/Tests/CodeBurnMenubarTests/ClaudeSubscriptionParsingTests.swift new file mode 100644 index 0000000..4ad729d --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/ClaudeSubscriptionParsingTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Claude usage response parsing") +struct ClaudeSubscriptionParsingTests { + // Shape captured from the live oauth/usage endpoint (2026-07): named + // windows plus a `limits` array carrying model-scoped weekly buckets. + private let liveShape = """ + { + "five_hour": { "utilization": 13.0, "resets_at": "2026-07-02T22:09:59.599633+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null }, + "seven_day": { "utilization": 63.0, "resets_at": "2026-07-03T06:59:59.599658+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null }, + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": null, + "extra_usage": { "is_enabled": false }, + "limits": [ + { "kind": "session", "group": "session", "percent": 13, "severity": "normal", "resets_at": "2026-07-02T22:09:59.456907+00:00", "scope": null, "is_active": false }, + { "kind": "weekly_all", "group": "weekly", "percent": 63, "severity": "normal", "resets_at": "2026-07-03T06:59:59.456926+00:00", "scope": null, "is_active": false }, + { "kind": "weekly_scoped", "group": "weekly", "percent": 94, "severity": "critical", "resets_at": "2026-07-03T06:59:59.457220+00:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": true } + ] + } + """ + + @Test("model-scoped weekly bucket surfaces with its display name") + func scopedWeeklyParsed() throws { + let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x") + #expect(usage.scopedWeekly.count == 1) + let fable = try #require(usage.scopedWeekly.first) + #expect(fable.label == "Fable") + #expect(fable.percent == 94) + #expect(fable.resetsAt != nil) + } + + @Test("named windows still map alongside limits") + func namedWindowsUnaffected() throws { + let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x") + #expect(usage.fiveHourPercent == 13.0) + #expect(usage.sevenDayPercent == 63.0) + #expect(usage.sevenDayOpusPercent == nil) + #expect(usage.sevenDaySonnetPercent == nil) + #expect(usage.tier == .max20x) + } + + @Test("session and weekly_all limits are not duplicated as scoped rows") + func unscopedKindsSkipped() throws { + let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: nil) + #expect(!usage.scopedWeekly.contains { $0.percent == 13 || $0.percent == 63 }) + } + + @Test("response without a limits array parses with no scoped windows") + func missingLimitsIsBackCompat() throws { + let old = """ + { + "five_hour": { "utilization": 40.0, "resets_at": "2026-07-02T22:00:00+00:00" }, + "seven_day": { "utilization": 12.5, "resets_at": "2026-07-03T07:00:00+00:00" } + } + """ + let usage = try ClaudeSubscriptionService.parseUsage(Data(old.utf8), rawTier: "pro") + #expect(usage.scopedWeekly.isEmpty) + #expect(usage.fiveHourPercent == 40.0) + #expect(usage.tier == .pro) + } + + @Test("weekly_scoped without a model display name is skipped") + func scopedWithoutNameSkipped() throws { + let body = """ + { + "limits": [ + { "kind": "weekly_scoped", "percent": 50, "resets_at": "2026-07-03T07:00:00+00:00", "scope": { "model": null } } + ] + } + """ + let usage = try ClaudeSubscriptionService.parseUsage(Data(body.utf8), rawTier: nil) + #expect(usage.scopedWeekly.isEmpty) + } +}