diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index cd12a0e0d6..2c542f630f 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -784,10 +784,13 @@ extension UsageMenuCardView.Model { static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left if Self.hasAntigravityQuotaSummaryWindows(snapshot) { - return Self.extraRateWindowMetrics( + let metrics = Self.extraRateWindowMetrics( snapshot: snapshot, input: input, percentStyle: percentStyle) + guard !input.showsAllUsageLanes else { return metrics } + let idleIDs = AntigravityQuotaFamilyVisibility.idleWindowIDs(in: snapshot) + return idleIDs.isEmpty ? metrics : metrics.filter { !idleIDs.contains($0.id) } } var metrics: [Metric] = [] diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index ae00259dc8..c06a0f4584 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -31,6 +31,9 @@ extension UsageMenuCardView.Model { let claudeDailyRoutinesUsageVisible: Bool let codexSparkUsageVisible: Bool let copilotBudgetExtrasEnabled: Bool + /// Provider details is the diagnostic surface and lists every usage lane a provider reports. + /// The menu and widgets stay curated and may drop lanes that carry no information. + let showsAllUsageLanes: Bool let sourceLabel: String? let subtitleOverride: String? let kiloAutoMode: Bool @@ -72,6 +75,7 @@ extension UsageMenuCardView.Model { claudeDailyRoutinesUsageVisible: Bool = true, codexSparkUsageVisible: Bool = true, copilotBudgetExtrasEnabled: Bool = false, + showsAllUsageLanes: Bool = false, sourceLabel: String? = nil, subtitleOverride: String? = nil, kiloAutoMode: Bool = false, @@ -112,6 +116,7 @@ extension UsageMenuCardView.Model { self.claudeDailyRoutinesUsageVisible = claudeDailyRoutinesUsageVisible self.codexSparkUsageVisible = codexSparkUsageVisible self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled + self.showsAllUsageLanes = showsAllUsageLanes self.sourceLabel = sourceLabel self.subtitleOverride = subtitleOverride self.kiloAutoMode = kiloAutoMode diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7f1229e2ff..986ac799e6 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -596,6 +596,7 @@ struct ProvidersPane: View { claudeDailyRoutinesUsageVisible: self.settings.claudeDailyRoutinesUsageVisible, codexSparkUsageVisible: self.settings.codexSparkUsageVisible, copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, + showsAllUsageLanes: true, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, quotaWarningThresholds: [ diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index d20336d793..180ed532be 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -493,7 +493,9 @@ extension UsageStore { }), !windows.isEmpty else { return nil } - return windows.map { namedWindow in + // Match the menu card and drop model families the account never touches. + let idleIDs = AntigravityQuotaFamilyVisibility.idleWindowIDs(in: snapshot) + return windows.filter { !idleIDs.contains($0.id) }.map { namedWindow in WidgetSnapshot.WidgetUsageRowSnapshot( id: namedWindow.id, title: namedWindow.title, diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityQuotaFamilyVisibility.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityQuotaFamilyVisibility.swift new file mode 100644 index 0000000000..839295f6ea --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityQuotaFamilyVisibility.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Antigravity reports every model family the plan covers, so an account that only runs Gemini still +/// receives a Claude/GPT pair pinned at 0%. Display surfaces hide a family once no lane in it reports +/// known usage above zero. Menu bar and icon selection rank by highest used, so an untouched family +/// never wins there and this stays a display-only filter. +public enum AntigravityQuotaFamilyVisibility { + /// Window IDs that display surfaces should drop. Empty when every family is untouched, so a card + /// or widget right after a reset still renders its lanes instead of an empty list. + public static func idleWindowIDs(in snapshot: UsageSnapshot) -> Set { + let windows = (snapshot.extraRateWindows ?? []) + .filter { AntigravityStatusSnapshot.isQuotaSummaryWindowID($0.id) } + guard !windows.isEmpty else { return [] } + let families = Dictionary(grouping: windows, by: Self.familyKey) + let idleFamilies = families.filter { _, lanes in + lanes.allSatisfy { !$0.usageKnown || $0.window.usedPercent <= 0 } + } + guard idleFamilies.count < families.count else { return [] } + return Set(idleFamilies.values.flatMap { lanes in lanes.map(\.id) }) + } + + /// Classifies a lane the same way the widget row resolver does: the bucket ID carries the family and + /// survives a display-text change, so it wins over the rendered title. A group with neither signal + /// falls back to the title with its bucket suffix removed, which keeps an unfamiliar family's lanes + /// on one key so a reset lane never hides while its active sibling stays. + private static func familyKey(_ namedWindow: NamedRateWindow) -> String { + // Provider-specific by design: these tokens are Antigravity's own quota families, not CodexBar providers. + let id = namedWindow.id.lowercased() + if id.contains("gemini") { + return "gemini" + } + if id.contains("3p") || id.contains("third-party") { + return "claude-gpt" + } + let title = namedWindow.title.lowercased() + if title.contains("gemini") { + return "gemini" + } + if title.contains("claude") || title.contains("gpt") { + return "claude-gpt" + } + // Titles render as a group title plus a bucket title, so drop the bucket half to key per family. + for suffix in Self.bucketTitleSuffixes where title.hasSuffix(suffix) { + let stripped = String(title.dropLast(suffix.count)).trimmingCharacters(in: .whitespaces) + if !stripped.isEmpty { + return stripped + } + } + return title + } + + private static let bucketTitleSuffixes = [" 5-hour", " weekly"] +} diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index b07ef767c6..bebf376e4e 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -521,4 +521,229 @@ struct MenuCardAntigravityTests { #expect(model.metrics[0].percent == 5) #expect(model.metrics[0].percentLabel == "5% used") } + + @Test + func `antigravity quota summary hides an untouched model family`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 1), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 4, weekly: true), + Self.quotaSummaryWindow(id: "3p-5h", title: "Claude/GPT 5-hour", usedPercent: 0), + Self.quotaSummaryWindow(id: "3p-weekly", title: "Claude/GPT weekly", usedPercent: 0, weekly: true), + ], + now: now) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + ]) + } + + @Test + func `antigravity quota summary hides a family whose only known lane is untouched`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 1), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 4, weekly: true), + Self.quotaSummaryWindow(id: "3p-5h", title: "Claude/GPT 5-hour", usedPercent: 0), + Self.quotaSummaryWindow( + id: "3p-weekly", + title: "Claude/GPT weekly", + usedPercent: 0, + weekly: true, + usageKnown: false), + ], + now: now) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + ]) + } + + @Test + func `antigravity quota summary keeps every family when all are untouched`() throws { + // Right after a weekly reset every lane sits at zero; the card must not render empty. + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 0), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 0, weekly: true), + Self.quotaSummaryWindow(id: "3p-5h", title: "Claude/GPT 5-hour", usedPercent: 0), + Self.quotaSummaryWindow(id: "3p-weekly", title: "Claude/GPT weekly", usedPercent: 0, weekly: true), + ], + now: now) + + #expect(model.metrics.count == 4) + } + + @Test + func `antigravity keeps a renamed third party pair together`() throws { + // The bucket ID carries the family, so a group title that never says Claude or GPT must still + // pair its lanes; otherwise a freshly reset weekly lane would hide beside an active 5-hour lane. + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 1), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 4, weekly: true), + Self.quotaSummaryWindow(id: "3p-5h", title: "Third-party models 5-hour", usedPercent: 27), + Self.quotaSummaryWindow( + id: "3p-weekly", + title: "Third-party models weekly", + usedPercent: 0, + weekly: true), + ], + now: now) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + "antigravity-quota-summary-3p-5h", + "antigravity-quota-summary-3p-weekly", + ]) + } + + @Test + func `antigravity hides a renamed third party pair when it is untouched`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 1), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 4, weekly: true), + Self.quotaSummaryWindow(id: "3p-5h", title: "Third-party models 5-hour", usedPercent: 0), + Self.quotaSummaryWindow( + id: "3p-weekly", + title: "Third-party models weekly", + usedPercent: 0, + weekly: true), + ], + now: now) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + ]) + } + + @Test + func `antigravity keeps an unfamiliar family pair together`() throws { + // Neither the ID nor the title names a known family, so the title fallback decides. Titles render + // as a group title plus a bucket title, and the fallback must drop the bucket half to pair lanes. + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 1), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 4, weekly: true), + Self.quotaSummaryWindow(id: "grok-5h", title: "Grok 5-hour", usedPercent: 30), + Self.quotaSummaryWindow(id: "grok-weekly", title: "Grok weekly", usedPercent: 0, weekly: true), + ], + now: now) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + "antigravity-quota-summary-grok-5h", + "antigravity-quota-summary-grok-weekly", + ]) + } + + @Test + func `antigravity hides an unfamiliar family pair when it is untouched`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 1), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 4, weekly: true), + Self.quotaSummaryWindow(id: "grok-5h", title: "Grok 5-hour", usedPercent: 0), + Self.quotaSummaryWindow(id: "grok-weekly", title: "Grok weekly", usedPercent: 0, weekly: true), + ], + now: now) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + ]) + } + + @Test + func `antigravity provider details keep every family`() throws { + // Provider details is the diagnostic surface, so it lists lanes the menu curates away. + let now = Date(timeIntervalSince1970: 1_735_000_000) + let model = try Self.quotaSummaryModel( + windows: [ + Self.quotaSummaryWindow(id: "gemini-5h", title: "Gemini 5-hour", usedPercent: 1), + Self.quotaSummaryWindow(id: "gemini-weekly", title: "Gemini weekly", usedPercent: 4, weekly: true), + Self.quotaSummaryWindow(id: "3p-5h", title: "Claude/GPT 5-hour", usedPercent: 0), + Self.quotaSummaryWindow(id: "3p-weekly", title: "Claude/GPT weekly", usedPercent: 0, weekly: true), + ], + now: now, + showsAllUsageLanes: true) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + "antigravity-quota-summary-3p-5h", + "antigravity-quota-summary-3p-weekly", + ]) + } + + private static func quotaSummaryWindow( + id: String, + title: String, + usedPercent: Double, + weekly: Bool = false, + usageKnown: Bool = true) -> NamedRateWindow + { + NamedRateWindow( + id: "antigravity-quota-summary-\(id)", + title: title, + window: RateWindow( + usedPercent: usedPercent, + windowMinutes: weekly ? 10080 : 300, + resetsAt: nil, + resetDescription: nil), + usageKnown: usageKnown) + } + + private static func quotaSummaryModel( + windows: [NamedRateWindow], + now: Date, + showsAllUsageLanes: Bool = false) throws -> UsageMenuCardView.Model + { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: windows, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Google AI Pro")) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + return UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + showsAllUsageLanes: showsAllUsageLanes, + hidePersonalInfo: false, + now: now)) + } } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 81f7047a84..deeed9e36f 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1870,7 +1870,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 834, + line: 837, anchor: "if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {", expectedProviderIDs: ["claude", "codex", "copilot"], expectedReferenceCount: 4, @@ -1878,7 +1878,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 859, + line: 862, anchor: "let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil", expectedProviderIDs: ["doubao", "sub2api"], expectedReferenceCount: 3, @@ -1886,7 +1886,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 930, + line: 933, anchor: "if input.provider == .antigravity,", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -1894,7 +1894,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 964, + line: 967, anchor: "if provider == .claude, window.windowMinutes != 10080 {", expectedProviderIDs: ["antigravity", "claude", "codex"], expectedReferenceCount: 4, @@ -1902,7 +1902,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 996, + line: 999, anchor: "guard input.provider == .antigravity else { return nil }", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index 417c3d25f6..caf1750e06 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -231,6 +231,128 @@ struct UsageStoreWidgetSnapshotTests { #expect(entry.usageRows?.compactMap(\.percentLeft) == [91, 82, 73, 64]) } + @Test + func `widget snapshot hides untouched antigravity model families`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-untouched-family" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini 5-hour", + window: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini weekly", + window: RateWindow(usedPercent: 4, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude/GPT 5-hour", + window: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude/GPT weekly", + window: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Google AI Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-untouched-family-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(entry.usageRows?.map(\.title) == [ + "Gemini 5-hour", + "Gemini weekly", + ]) + } + + @Test + func `widget snapshot pairs renamed antigravity third party lanes`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-renamed-third-party" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini 5-hour", + window: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Third-party models 5-hour", + window: RateWindow(usedPercent: 27, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Third-party models weekly", + window: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Google AI Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-renamed-third-party-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + // The reset weekly lane stays because its 5-hour sibling is active. + #expect(entry.usageRows?.map(\.title) == [ + "Gemini 5-hour", + "Third-party models 5-hour", + "Third-party models weekly", + ]) + } + @Test func `widget snapshot labels antigravity compact fallback with model name`() async throws { let suite = "UsageStoreWidgetSnapshotTests-antigravity-compact-fallback" @@ -752,3 +874,66 @@ struct UsageStoreWidgetSnapshotTests { #expect(entry.usageRows?.map(\.title) == ["Total", "Auto", "API"]) } } + +@MainActor +struct UsageStoreWidgetSnapshotAntigravityFamilyTests { + @Test + func `widget snapshot pairs unfamiliar antigravity family lanes`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-unfamiliar-family" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini 5-hour", + window: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-grok-5h", + title: "Grok 5-hour", + window: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-grok-weekly", + title: "Grok weekly", + window: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Google AI Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-unfamiliar-family-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + // Neither ID nor title names a known family, so the title fallback must still pair the lanes. + #expect(entry.usageRows?.map(\.title) == [ + "Gemini 5-hour", + "Grok 5-hour", + "Grok weekly", + ]) + } +} diff --git a/docs/antigravity.md b/docs/antigravity.md index f57ece4dec..daac73bfcf 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -232,6 +232,12 @@ shared OAuth file can still be used as a fallback credential source. - Some Antigravity local/CLI model config entries include reset metadata but omit `remainingFraction`. Those windows stay in `extraRateWindows` for reset context and are marked with `usageKnown: false`; clients should not render their `usedPercent` as a real exhausted quota. +- Antigravity reports every model family the plan covers, so an account that only uses Gemini still receives a + Claude/GPT pair pinned at 0%. Menu cards and widgets hide a family once no lane in it reports known usage above + zero, and keep every family when they are all untouched, for example right after a weekly reset. Provider details + is the diagnostic surface and always lists every family, the same principle it already applies to cost data. The + filter is display-only: the snapshot, CLI output, and menu-bar ranking still see every window, and menu-bar + selection ranks by highest used, so an untouched family never wins there anyway. ## Constraints - Internal protocol; fields may change.