diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b59bbb..57a803b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - **OpenCode's session-level fallback now bills reasoning tokens at the output rate.** When none of a session's messages produce a priced call, codeburn falls back to the totals on OpenCode's `session` row, and that path handed `tokens_output` alone to the pricing table while still reporting `tokens_reasoning` separately, so a reasoning model's thinking showed up in the token breakdown but was never charged (4,000 reasoning tokens on Sonnet 4 left a $0.11 session priced at $0.05). OpenCode itself charges reasoning at the output rate and codeburn's per-message path already did; the fallback and the per-message path now both go through `billableOutputTokens`, the same rule the cache-rehydration path uses, so the three can't drift apart again. Displayed token totals are unchanged. (#1334) ### Fixed (menubar) +- **A quota window that resets now refreshes now.** The menubar's 30s tick compared the reset times it already holds — Claude's 5-hour and weekly windows, Codex's two windows, every Capacity Dock provider's — against the previous tick, and forces a refresh for the provider whose window just rolled over instead of leaving the pre-reset percentage on screen for up to five minutes. Each reset instant fires once; everything else keeps the existing cadence. - **The popover's quota warning can be read in light mode, and says which limit it is warning about.** The banner under the tagline painted its text in the same system yellow, orange or red as its 12% pill, so in light mode warning text measured 1.2:1 to 1.4:1 against the pill, and even red reached only 3.0:1; in dark mode red fell to 3.3:1. The pill keeps its hue, and the text and its glyph now take a per-appearance colour for each severity, a deep amber, rust or brick in light mode and a lifted orange or red in dark mode, that measures 5.3:1 or better everywhere. A test holds every severity at WCAG AA (4.5:1) against the pill composited over both ends of the popover's background in both appearances. The sentence also named no window. Its figure is the provider's worst window, so "Claude 70% of quota used" meant the 5-hour limit while Claude's own panel read weekly 34%, and it looked wrong. It now reads `Claude · 5-hour 71% · resets in 3h 12m`, with the countdown worded exactly as the Capacity Dock words it and one line per provider when several are warning. The countdown is left off when the provider reports no reset time or the reset has already passed. "Over limit" is kept for a window at or past 100%, where it used to cover anything from 90%. The flame's colour and the providers that trigger the banner are unchanged. ## 0.9.24 - 2026-09-04 diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index ebd6a4e2..4e51f07b 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -568,6 +568,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM fileprivate var lastCapacityDockProviderRefreshAt: Date? private var claudeQuotaFailureCount = 0 private var nextClaudeQuotaRefreshAt: Date? + private var lastQuotaRolloverCheckAt = Date() + + /// Reset instants crossed since the previous tick, grouped by the force + /// flags `refreshLiveQuotaProgressIfDue` takes. Without this a window that + /// resets at 14:00 keeps showing its pre-reset percentage until the next + /// cadence tick. + private func quotaGroupsRolledOver( + now: Date = Date() + ) -> (claude: Bool, codex: Bool, dock: Bool, any: Bool) { + let since = lastQuotaRolloverCheckAt + lastQuotaRolloverCheckAt = now + func rolledOver(_ dates: [Date?]) -> Bool { + QuotaRefreshDecision.windowRolledOver( + resetDates: dates.compactMap { $0 }, + lastCheckedAt: since, + now: now + ) + } + let claude = rolledOver(store.subscription.map { + [$0.fiveHourResetsAt, $0.sevenDayResetsAt, $0.sevenDayOpusResetsAt, $0.sevenDaySonnetResetsAt] + } ?? []) + let codex = rolledOver(store.codexUsage.map { [$0.primary?.resetsAt, $0.secondary?.resetsAt] } ?? []) + let dock = rolledOver(store.capacityDockProviderSummaries.values.flatMap { + [$0.primary?.resetsAt] + $0.details.map(\.resetsAt) + }) + return (claude, codex, dock, claude || codex || dock) + } @discardableResult private func refreshLiveQuotaProgressIfDue( @@ -849,12 +876,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } } - if QuotaRefreshDecision.needsQuotaOnlyTick( + let rolledOver = quotaGroupsRolledOver() + if rolledOver.any || QuotaRefreshDecision.needsQuotaOnlyTick( payloadRefreshDue: shouldForceRefresh, payloadSkippedUnchanged: skippedUnchangedUsageRefresh ) { Task { [weak self] in - _ = await self?.refreshLiveQuotaProgressIfDue(force: forceQuota) + _ = await self?.refreshLiveQuotaProgressIfDue( + force: forceQuota, + forceClaude: rolledOver.claude, + forceCodex: rolledOver.codex, + forceCapacityDockProviders: rolledOver.dock + ) } } diff --git a/mac/Sources/CodeBurnMenubar/QuotaRefreshDecision.swift b/mac/Sources/CodeBurnMenubar/QuotaRefreshDecision.swift index 546176e8..393dc81c 100644 --- a/mac/Sources/CodeBurnMenubar/QuotaRefreshDecision.swift +++ b/mac/Sources/CodeBurnMenubar/QuotaRefreshDecision.swift @@ -22,4 +22,12 @@ enum QuotaRefreshDecision { guard autoRefreshAllowed else { return false } return now.timeIntervalSince(lastAttemptAt ?? .distantPast) >= threshold } + + /// True when a window's `resetsAt` fell between the previous tick and now. + /// That boundary is the one moment the provider's numbers are certain to + /// have moved, and it fires once per reset instant: the tick that sees it + /// moves `lastCheckedAt` past the boundary. + static func windowRolledOver(resetDates: [Date], lastCheckedAt: Date, now: Date) -> Bool { + resetDates.contains { $0 > lastCheckedAt && $0 <= now } + } } diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaRefreshDecisionTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaRefreshDecisionTests.swift index ddf523bf..d2381a8a 100644 --- a/mac/Tests/CodeBurnMenubarTests/QuotaRefreshDecisionTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/QuotaRefreshDecisionTests.swift @@ -84,4 +84,46 @@ struct QuotaRefreshDecisionTests { threshold: 300 )) } + + @Test("a reset between the last check and now rolls over") + func resetInsideWindowRollsOver() { + #expect(QuotaRefreshDecision.windowRolledOver( + resetDates: [now.addingTimeInterval(-3600), now.addingTimeInterval(-10)], + lastCheckedAt: now.addingTimeInterval(-30), + now: now + )) + } + + @Test("a reset landing exactly on now rolls over") + func resetAtNowRollsOver() { + #expect(QuotaRefreshDecision.windowRolledOver( + resetDates: [now], + lastCheckedAt: now.addingTimeInterval(-30), + now: now + )) + } + + @Test("a reset already seen at the last check does not fire twice") + func resetAtLastCheckIsExcluded() { + let resetsAt = now.addingTimeInterval(-30) + #expect(!QuotaRefreshDecision.windowRolledOver( + resetDates: [resetsAt], + lastCheckedAt: resetsAt, + now: now + )) + } + + @Test("a future reset and no reset dates hold the cadence") + func futureAndEmptyDoNotRollOver() { + #expect(!QuotaRefreshDecision.windowRolledOver( + resetDates: [now.addingTimeInterval(60)], + lastCheckedAt: now.addingTimeInterval(-30), + now: now + )) + #expect(!QuotaRefreshDecision.windowRolledOver( + resetDates: [], + lastCheckedAt: now.addingTimeInterval(-30), + now: now + )) + } }