diff --git a/CHANGELOG.md b/CHANGELOG.md index 7341bb53e2..47108ea343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Vertex AI: match Cloud Monitoring quota usage without a `limit_name` to its unambiguous same-metric, same-location limit, restoring quota percentages (#2958). Thanks @MachApple! +- Antigravity: render one lane per quota bucket on the serve dashboard and in `codexbar dashboard`, instead of repeating two buckets as extra "Gemini Models" and "Claude and GPT" rows. ## 0.50.0 — 2026-08-15 diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift index bc168c8ce6..7727c4d304 100644 --- a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -286,6 +286,13 @@ enum DashboardSnapshotBuilder { usage: UsageSnapshot?) -> [DashboardWindowPayload] { guard let usage else { return [] } + // Provider-specific by design: Antigravity's primary and secondary are representatives + // copied out of its own quota-summary lanes so the icon and menu bar have standard slots + // to read. Emitting all three repeats two lanes under the generic session and weekly + // labels, so the dashboard renders the summary lanes alone, one per quota bucket. + if provider == .antigravity, let windows = self.antigravityQuotaSummaryWindows(usage) { + return windows + } let labels = self.rateWindowLabels(provider: provider, metadata: metadata, usage: usage) var windows: [DashboardWindowPayload] = [] // Provider-specific by design: Amp subscription payloads model balance and orb as non-time-window kinds. @@ -309,6 +316,17 @@ enum DashboardSnapshotBuilder { return windows } + /// Display lanes for an Antigravity quota-summary snapshot, or `nil` when the snapshot has no + /// summary lanes and must keep the standard primary and secondary rows. Every family stays + /// visible: the dashboard is a detail surface, so only the duplicated representatives go away. + private static func antigravityQuotaSummaryWindows(_ usage: UsageSnapshot) -> [DashboardWindowPayload]? { + let extras = usage.extraRateWindows ?? [] + guard extras.contains(where: { AntigravityStatusSnapshot.isQuotaSummaryWindowID($0.id) }) else { + return nil + } + return extras.map { self.makeWindow(kind: $0.id, label: $0.title, window: $0.window) } + } + private struct RateWindowLabels { let primary: String let secondary: String diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 88b0ad1447..0ef0bd767c 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -282,7 +282,7 @@ public struct AntigravityStatusSnapshot: Sendable { } } - static func isQuotaSummaryWindowID(_ id: String) -> Bool { + public static func isQuotaSummaryWindowID(_ id: String) -> Bool { id.hasPrefix(self.quotaSummaryWindowIDPrefix) } diff --git a/Tests/CodexBarTests/DashboardAntigravityWindowTests.swift b/Tests/CodexBarTests/DashboardAntigravityWindowTests.swift new file mode 100644 index 0000000000..a6796ffec0 --- /dev/null +++ b/Tests/CodexBarTests/DashboardAntigravityWindowTests.swift @@ -0,0 +1,131 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +/// The dashboard snapshot renders one Antigravity lane per quota bucket. The primary and secondary +/// representatives are copies of two of those buckets, kept only so the icon and menu bar have +/// standard slots to read, so the dashboard must not repeat them as rows of their own. +struct DashboardAntigravityWindowTests { + @Test + func `dashboard renders one Antigravity lane per quota bucket`() throws { + let windows = try self.antigravityWindows(geminiSessionPercent: 3.9, geminiWeeklyPercent: 8.1) + + #expect(windows.map { $0["label"] as? String } == [ + "Gemini 5-hour", + "Gemini weekly", + "Claude/GPT 5-hour", + "Claude/GPT weekly", + ]) + #expect(windows.map { $0["usedPercent"] as? Double } == [3.9, 8.1, 0, 0]) + } + + @Test + func `dashboard omits the Antigravity representative labels`() throws { + let windows = try self.antigravityWindows(geminiSessionPercent: 3.9, geminiWeeklyPercent: 8.1) + let labels = windows.compactMap { $0["label"] as? String } + + #expect(!labels.contains("Gemini Models")) + #expect(!labels.contains("Claude and GPT")) + } + + @Test + func `dashboard keeps standard lanes for an Antigravity snapshot without summary windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 7 * 24 * 60, resetsAt: now, resetDescription: nil), + tertiary: nil, + updatedAt: now) + + let windows = try self.windows(for: usage, now: now) + + #expect(windows.map { $0["kind"] as? String } == ["session", "weekly"]) + #expect(windows.map { $0["label"] as? String } == ["Gemini Models", "Claude and GPT"]) + } + + // MARK: - Fixtures + + /// Mirrors the shape the quota-summary probe produces: four lanes, with `primary` and + /// `secondary` set to the most-used lane of each family rather than to distinct windows. + private func antigravityWindows( + geminiSessionPercent: Double, + geminiWeeklyPercent: Double) throws -> [[String: Any]] + { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let geminiSession = RateWindow( + usedPercent: geminiSessionPercent, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil) + let geminiWeekly = RateWindow( + usedPercent: geminiWeeklyPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: now, + resetDescription: nil) + let thirdPartySession = RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: now, resetDescription: nil) + let thirdPartyWeekly = RateWindow( + usedPercent: 0, + windowMinutes: 7 * 24 * 60, + resetsAt: now, + resetDescription: nil) + let extras = [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini 5-hour", + window: geminiSession, + usageKnown: true), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini weekly", + window: geminiWeekly, + usageKnown: true), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude/GPT 5-hour", + window: thirdPartySession, + usageKnown: true), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude/GPT weekly", + window: thirdPartyWeekly, + usageKnown: true), + ] + let usage = UsageSnapshot( + primary: geminiWeeklyPercent >= geminiSessionPercent ? geminiWeekly : geminiSession, + secondary: thirdPartySession, + tertiary: nil, + extraRateWindows: extras, + updatedAt: now) + + return try self.windows(for: usage, now: now) + } + + private func windows(for usage: UsageSnapshot, now: Date) throws -> [[String: Any]] { + let payload = ProviderPayload( + provider: .antigravity, + account: nil, + version: nil, + source: "cli", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .antigravity, enabled: true)]), + identityMode: .redacted, + generatedAt: now, + refreshInterval: 60, + codexBarVersion: nil) + + let json = try #require(CodexBarCLI.encodeJSON(snapshot, pretty: false)) + let data = try #require(json.data(using: .utf8)) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + return try #require(provider["windows"] as? [[String: Any]]) + } +}