mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
Merge pull request #598 from getagentseal/fix/menubar-plan-quota
Some checks are pending
CI / semgrep (push) Waiting to run
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:
commit
8e55b5ff55
5 changed files with 189 additions and 24 deletions
|
|
@ -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: [])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue