diff --git a/app/Sources/MenuBarCore/Formatting.swift b/app/Sources/MenuBarCore/Formatting.swift index ca2a5f3e8be..c81a4272345 100644 --- a/app/Sources/MenuBarCore/Formatting.swift +++ b/app/Sources/MenuBarCore/Formatting.swift @@ -57,6 +57,23 @@ public enum Format { return "\(max(minutes, 1))m" } + /// Exact provider-reported reset in the user's local time, with a coarse countdown. + public static func quotaResetDetail( + _ date: Date?, now: Date = Date(), + locale: Locale = .current, timeZone: TimeZone = .current + ) -> String { + guard let date, date.timeIntervalSince1970.isFinite else { + return "Reset time unavailable" + } + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.setLocalizedDateFormatFromTemplate("MMMdjmmz") + let timestamp = formatter.string(from: date) + if date <= now { return "Reset time passed · \(timestamp)" } + return "Resets \(timestamp) · in \(resetsIn(date, now: now))" + } + /// "2m ago" for staleness labels on the degraded state. public static func age(_ date: Date?, now: Date = Date()) -> String { guard let date else { return unknown } diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index e93803086ca..f637d38b8c6 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -411,10 +411,28 @@ public struct ProviderQuota: Decodable, Equatable, Sendable { public let updatedAt: Double } +/// Pool percentages and the effective subscription's reset schedule have different scopes. +public struct QuotaAggregation: Decodable, Equatable, Sendable { + public let presentation: String? + public let currentAccount: CurrentAccount? + + public struct CurrentAccount: Decodable, Equatable, Sendable { + public let quota: ResetSchedule? + } + + public struct ResetSchedule: Decodable, Equatable, Sendable { + public let fiveHourResetAt: Double? + public let weeklyResetAt: Double? + public let monthlyResetAt: Double? + public let customWindows: [QuotaWindow]? + } +} + public struct QuotaReport: Decodable, Equatable, Sendable { public let provider: String public let label: String public let source: String + public let aggregation: QuotaAggregation? public let quota: ProviderQuota public let updatedAt: Double } @@ -551,6 +569,22 @@ public extension QuotaReport { Self.date(from: quota.updatedAt) } + /// Never attach an individual subscription's reset to the combined pool quota. + /// The expanded panel labels this schedule explicitly as the current account. + func currentAccountResetAt(for window: NormalizedQuota) -> Date? { + guard ["openai", "chatgpt"].contains(provider.lowercased()), + aggregation?.presentation == "aggregate", + let schedule = aggregation?.currentAccount?.quota else { return nil } + let value: Double? + switch window.windowLabel { + case "5h": value = schedule.fiveHourResetAt + case "week": value = schedule.weeklyResetAt + case "month": value = schedule.monthlyResetAt + default: value = schedule.customWindows?.first { $0.label == window.windowLabel }?.resetAt + } + return Self.date(from: value) + } + /// Every window the provider reported, in display order. /// /// The live proxy is not uniform: `openai` and `xai` report a single named window, diff --git a/app/Sources/MenuBarCoreTests/FormattingSuite.swift b/app/Sources/MenuBarCoreTests/FormattingSuite.swift index ac39038b0d3..308a982e698 100644 --- a/app/Sources/MenuBarCoreTests/FormattingSuite.swift +++ b/app/Sources/MenuBarCoreTests/FormattingSuite.swift @@ -38,6 +38,23 @@ enum FormattingSuite { t.equal(Format.resetsIn(nil), "—") } + t.test("format: reset detail includes local date, time, zone, and honest state") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + let reset = now.addingTimeInterval(3 * 86400 + 4 * 3600) + let locale = Locale(identifier: "en_US_POSIX") + let utc = TimeZone(secondsFromGMT: 0)! + let text = Format.quotaResetDetail(reset, now: now, locale: locale, timeZone: utc) + t.expect(text.contains("Jul") && text.contains(":"), "date and clock time are visible") + t.expect(text.contains("GMT"), "time zone is visible") + t.expect(text.hasSuffix(" · in 3d 4h"), "countdown accompanies exact time") + let pacific = Format.quotaResetDetail(reset, now: now, locale: locale, + timeZone: TimeZone(identifier: "America/Los_Angeles")!) + t.expect(pacific != text, "reset follows the supplied local time zone") + t.equal(Format.quotaResetDetail(nil), "Reset time unavailable") + t.expect(Format.quotaResetDetail(now, now: now).hasPrefix("Reset time passed · "), + "past resets do not claim a future renewal") + } + t.test("format: staleness ages read naturally") { let now = Date(timeIntervalSince1970: 1_784_915_000) t.equal(Format.age(now.addingTimeInterval(-10), now: now), "just now") diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index a81a664b7f6..e2db8825282 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -41,6 +41,38 @@ enum ModelDecodingSuite { """ static func run(_ t: TestRunner) { + t.test("quota: pool account resets remain separate from aggregate percentages") { + let reset: Double = 1_900_000_000 + let json = """ + {"provider":"openai","label":"ChatGPT","source":"chatgpt:wham","updatedAt":1, + "quota":{"weeklyPercent":29,"monthlyPercent":10,"updatedAt":1}, + "aggregation":{"presentation":"aggregate","includedAccounts":2, + "weekly":{"nextRecoveryAt":1800000000000}, + "currentAccount":{"quota":{"weeklyPercent":50,"weeklyResetAt":\(reset), + "monthlyResetAt":\(reset * 1000)}}}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows[0].percent, 29, "pool percentage stays combined") + t.isNil(windows[0].resetAt, "pool is not given a single subscription's reset") + t.equal(report.currentAccountResetAt(for: windows[0]), Date(timeIntervalSince1970: reset), + "uses account reset, not earlier partial pool recovery") + t.equal(report.currentAccountResetAt(for: windows[1]), Date(timeIntervalSince1970: reset), + "both timestamp units are supported") + } + + t.test("quota: missing and nonaggregate account schedules do not invent resets") { + for aggregation in ["null", "{}", "{\"presentation\":\"aggregate\",\"currentAccount\":{\"quota\":null}}", + "{\"presentation\":\"coverage-only\",\"currentAccount\":{\"quota\":{\"weeklyResetAt\":1900000000}}}"] { + let report = try decode(QuotaReport.self, """ + {"provider":"openai","label":"ChatGPT","source":"test","updatedAt":1, + "quota":{"weeklyPercent":29,"updatedAt":1},"aggregation":\(aggregation)} + """) + t.isNil(report.currentAccountResetAt(for: report.normalizedWindows()[0]), + "absent or inapplicable metadata stays unknown") + } + } + t.test("build provenance: formats only stamped git revisions") { t.equal( BuildProvenance.shortRevision( diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index 6a58140e6aa..f1bf33b56d8 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -359,7 +359,11 @@ final class ProviderQuotaRowView: NSView { arranged.append(ObservedLimitEventRowView(event: limitEvent)) } for window in windows { - arranged.append(QuotaWindowRowView(window: window)) + arranged.append(QuotaWindowRowView( + window: window, + showsResetDetail: ["openai", "chatgpt"].contains(row.provider.lowercased()), + currentAccountResetAt: report.currentAccountResetAt(for: window) + )) } for reference in references { arranged.append(ReferenceQuotaWindowRowView(window: reference)) diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index 4f63c8b2c64..6af4be8d3c2 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -656,7 +656,11 @@ final class QuotaBarView: NSView { /// One real quota window row inside an expanded provider card. final class QuotaWindowRowView: NSView { - init(window: NormalizedQuota) { + init( + window: NormalizedQuota, + showsResetDetail: Bool = false, + currentAccountResetAt: Date? = nil + ) { super.init(frame: .zero) let title = makeLabel(windowTitle(window.windowLabel), font: Theme.caption, color: Theme.text) @@ -671,7 +675,7 @@ final class QuotaWindowRowView: NSView { used.translatesAutoresizingMaskIntoConstraints = false used.widthAnchor.constraint(equalToConstant: 58).isActive = true let reset = makeLabel( - resetText(window.resetAt, windowLabel: window.windowLabel), + currentAccountResetAt != nil ? "Account reset" : resetText(window.resetAt, windowLabel: window.windowLabel), font: Theme.micro, color: Theme.faint ) @@ -684,12 +688,32 @@ final class QuotaWindowRowView: NSView { let row = makeRow([title, bar, used, reset], spacing: 6) row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) + let content = NSStackView(views: [row]) + content.orientation = .vertical + content.alignment = .leading + content.spacing = 2 + content.translatesAutoresizingMaskIntoConstraints = false + let detail: String? + if showsResetDetail, let currentAccountResetAt { + detail = Format.quotaResetDetail(currentAccountResetAt) + } else { + detail = showsResetDetail ? Format.quotaResetDetail(window.resetAt) : nil + } + if let detail { + let label = makeLabel(detail, font: Theme.micro, color: Theme.muted) + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + label.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - 20 + content.addArrangedSubview(label) + label.widthAnchor.constraint(equalTo: content.widthAnchor).isActive = true + } + addSubview(content) NSLayoutConstraint.activate([ - row.topAnchor.constraint(equalTo: topAnchor), - row.leadingAnchor.constraint(equalTo: leadingAnchor), - row.trailingAnchor.constraint(equalTo: trailingAnchor), - row.bottomAnchor.constraint(equalTo: bottomAnchor), + content.topAnchor.constraint(equalTo: topAnchor), + content.leadingAnchor.constraint(equalTo: leadingAnchor), + content.trailingAnchor.constraint(equalTo: trailingAnchor), + content.bottomAnchor.constraint(equalTo: bottomAnchor), + row.widthAnchor.constraint(equalTo: content.widthAnchor), bar.widthAnchor.constraint(greaterThanOrEqualToConstant: 78), bar.heightAnchor.constraint(equalToConstant: 5), heightAnchor.constraint(greaterThanOrEqualToConstant: 20), @@ -697,10 +721,12 @@ final class QuotaWindowRowView: NSView { setAccessibilityElement(true) setAccessibilityRole(.staticText) + let resetDescription = (currentAccountResetAt != nil ? "Current account, " : "") + + (detail ?? resetText(window.resetAt, windowLabel: window.windowLabel)) setAccessibilityLabel( window.hasPercent - ? "\(windowTitle(window.windowLabel)): \(Format.percent(window.percent)) used, \(resetText(window.resetAt, windowLabel: window.windowLabel))" - : "\(windowTitle(window.windowLabel)): unavailable" + ? "\(windowTitle(window.windowLabel)): \(Format.percent(window.percent)) used, \(resetDescription)" + : "\(windowTitle(window.windowLabel)): unavailable, \(resetDescription)" ) } diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index 03e40610ec2..240893fc577 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -654,6 +654,51 @@ runner.test("ui: ChatGPT/OpenAI expands first; Kimi and Grok stay collapsed") { runner.equal(accordion.expandedProviderIDs, ["kimi"], "one open section") } +runner.test("ui: expanded ChatGPT windows show exact resets and collapse hides them") { + let reset = Date().addingTimeInterval(3 * 86400).timeIntervalSince1970 + let quotas = decodeQuotas(""" + [{"provider":"openai","label":"ChatGPT","source":"test","updatedAt":1, + "quota":{"updatedAt":1,"fiveHourPercent":12,"fiveHourResetAt":\(reset), + "weeklyPercent":22,"weeklyResetAt":\(reset * 1000),"monthlyPercent":30}}] + """) + let accordion = ProviderQuotaAccordionView() + accordion.apply(makeSnapshot(quotas: quotas)) + func details() -> [String] { + textFields(in: accordion).map(\.stringValue).filter { + $0.contains(" · in ") || $0 == "Reset time unavailable" + } + } + runner.equal(details().count, 3, "each window has reset detail, including missing times") + runner.equal(details()[0], details()[1], "seconds and milliseconds produce the same reset") + runner.expect(details()[0].contains(":"), "clock time is visible") + accordion.toggleForTesting("openai") + runner.equal(details().count, 0, "collapsed provider hides reset details") + accordion.toggleForTesting("openai") + runner.equal(details().count, 3, "expanding restores reset detail") +} + +runner.test("ui: pool quota reads the current subscription reset without changing pool usage") { + let reset = Date().addingTimeInterval(86400).timeIntervalSince1970 + let quotas = decodeQuotas(""" + [{"provider":"openai","label":"ChatGPT","source":"chatgpt:wham","updatedAt":1, + "quota":{"weeklyPercent":29,"updatedAt":1}, + "aggregation":{"presentation":"aggregate","includedAccounts":2, + "weekly":{"nextRecoveryAt":\((reset - 3600) * 1000)}, + "currentAccount":{"quota":{"weeklyPercent":50,"weeklyResetAt":\(reset)}}}}] + """) + let accordion = ProviderQuotaAccordionView() + accordion.apply(makeSnapshot(quotas: quotas)) + let fields = textFields(in: accordion).map(\.stringValue) + runner.expect(fields.contains("29% used"), "combined pool usage is retained") + runner.expect(fields.contains(Format.quotaResetDetail(Date(timeIntervalSince1970: reset))), + "account reset uses the live nested response format and labels its scope") + runner.expect(!fields.contains("Reset time unavailable"), "known subscription reset is visible") + runner.expect(fields.contains("Account reset"), "summary does not claim the whole pool resets") + accordion.toggleForTesting("openai") + runner.expect(!textFields(in: accordion).contains { $0.stringValue.hasPrefix("Resets ") }, + "collapse hides the subscription detail") +} + runner.test("ui: configured Grok stays visible when its quota report is unavailable") { let quotas = decodeQuotas(""" [ diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index 1f6fbf8320a..d479cfed57f 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -217,7 +217,7 @@ private enum Fixture { } """ - let quotasJSON = """ + var quotasJSON = """ [ { "provider": "openai", @@ -250,6 +250,16 @@ private enum Fixture { ] """ + if CommandLine.arguments.contains("--pool-quota") { + quotasJSON = """ + [{"provider":"openai","label":"ChatGPT","source":"chatgpt:wham","updatedAt":\(ms), + "quota":{"weeklyPercent":29,"updatedAt":\(ms)}, + "aggregation":{"presentation":"aggregate","includedAccounts":1,"excludedAccounts":0, + "currentAccount":{"quota":{"weeklyPercent":29, + "weeklyResetAt":\(now.addingTimeInterval(86400).timeIntervalSince1970)}}}}] + """ + } + // Grok is intentionally configured but absent from quota reports: this is the // expired/transient-probe state that must remain visible without a fake value. let providersJSON = """ diff --git a/docs-site/public/macos-quota-resets.png b/docs-site/public/macos-quota-resets.png new file mode 100644 index 00000000000..4a7508871bd Binary files /dev/null and b/docs-site/public/macos-quota-resets.png differ diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index db8e213bfa5..526d4e4ae1f 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -244,7 +244,13 @@ remains intact. Unrelated Codex preference edits made after sync are allowed. If changed, custom, ambiguous, or cannot be proved safely, CodexCommander leaves the existing route unchanged rather than guessing. Do not delete or edit the journal manually. -ChatGPT appears first and expanded when its quota report is available. Kimi and Grok appear as +ChatGPT appears first and expanded when its quota report is available. Each expanded ChatGPT quota +window shows its provider-reported reset date and time in your Mac's time zone, plus a countdown. +In Pool mode, percentages describe combined capacity. The reset detail is labeled **Account reset** +and uses the effective subscription's reset schedule; other accounts can reset at different times. +Missing reset times are shown as **Reset time unavailable**; a past timestamp says **Reset time passed** +until refreshed quota data arrives. These are usage-limit resets, not subscription billing dates. +Kimi and Grok appear as collapsed summaries. A configured quota-capable provider that returns no report stays visible as **Quota unavailable**; expand it to open Provider settings. When a linked Grok or Kimi CLI login is stale, the row instead says **Login needs refresh** and tells you to run `grok` or `kimi`, then use