Merge pull request #598 from getagentseal/fix/menubar-plan-quota
Some checks are pending
CI / semgrep (push) Waiting to run

menubar: fix Codex plan tab copy, show model-scoped weekly limits (Fable)
This commit is contained in:
Resham Joshi 2026-07-02 20:02:08 +02:00 committed by GitHub
commit 8e55b5ff55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 189 additions and 24 deletions

View file

@ -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: [])

View file

@ -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()
)
}

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}
}