Skip to content
Merged
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
3 changes: 3 additions & 0 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])
Expand Down
50 changes: 47 additions & 3 deletions mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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,
Expand All @@ -226,6 +269,7 @@ enum ClaudeSubscriptionService {
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
scopedWeekly: scopedWeekly,
fetchedAt: Date()
)
}
Expand Down
10 changes: 10 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand Down
73 changes: 52 additions & 21 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1904,20 +1920,23 @@ 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 {
VStack(spacing: 10) {
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)
Expand All @@ -1934,21 +1953,23 @@ 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 {
VStack(spacing: 10) {
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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}