Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
- **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.
- **The Trend tooltip shows the day's total again under its model breakdown.** Provider-filtered history now carries tokens, so with only Claude selected the tooltip header switched to a token count and the day's cost appeared nowhere, and the model rows could not stand in for it because only four of up to five models render. A Total row with the day's cost and tokens now closes the breakdown, as it does in the web dashboard, and the header figure it replaces is shown only for days without a breakdown. Fixes #1433.
- **Codex now gets the same early quota-reset notification Claude gets.** Its windows are keyed by a pre-localization label, so a language switch or the "· limit reached" suffix no longer drops the stored baseline, and when a provider reports absolute usage a spend-cap increase is no longer mistaken for a cleared counter. (#725)

### Changed (desktop)
- **The desktop shell stops re-rendering the whole tree once a second.** AppMain owned a wall-clock `setInterval(1000)` that existed only so the footer could print "refreshed Ns ago", but every tick re-rendered the sidebar, the hero, the daily chart, the heatmap and every table 60 times a minute whether or not any data had changed. The per-second tick now lives in a leaf `RefreshedAt` component that owns just that label, and the shell itself re-renders only on real state changes plus a 15-second day check that fires exactly when the local calendar rolls over — that rollover matters because the overview memo keys bake in a today/month boundary, so midnight must produce one re-render to keep "Today" honest. Renderer render-count regressions are pinned by a churn test that fails if the shell re-renders on the per-second tick or on same-day clock checks.
Expand Down
60 changes: 59 additions & 1 deletion mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,9 @@ final class AppStore {
codexError = nil
codexLoadState = .loaded
await codexBankedResetAnnouncer.observe(usage.resetCredits)
// A bootstrap is the far side of a gap, so this fetch only seeds a
// baseline — the same discipline `bootstrapSubscription` uses.
await detectCodexEarlyResets(baselineIsTrusted: false)
} catch let err as CodexSubscriptionService.FetchError {
applyCodexFetchError(err)
} catch {
Expand All @@ -1657,6 +1660,11 @@ final class AppStore {
if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped }
return false
}
// Read before `beginCodexQuotaRefresh` moves the state to `.loading`;
// with a refresh already in flight the restore state is the real one.
let stateBeforeFetch = codexRefreshInFlightRequest == nil
? codexLoadState
: (codexRefreshRestoreState ?? codexLoadState)
let token = beginCodexQuotaRefresh()
do {
guard let usage = try await codexQuotaFetcher() else {
Expand All @@ -1676,6 +1684,9 @@ final class AppStore {
// side-effect of a successful fetch and must not be able to hold the
// single-flight token open.
await codexBankedResetAnnouncer.observe(usage.resetCredits)
await detectCodexEarlyResets(
baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted
)
return true
} catch let err as CodexSubscriptionService.FetchError {
guard isCurrentCodexQuotaRefresh(token) else { return false }
Expand Down Expand Up @@ -1717,6 +1728,7 @@ final class AppStore {
codexUsage = nil
codexError = nil
codexLoadState = .notBootstrapped
earlyQuotaResetMonitor.forget(providerID: CapacityDockProvider.codex.rawValue)
// Same reason the snapshot store is wiped on the Claude side: a
// reconnect under a different account must baseline again rather than
// announce that account's entire inventory as new grants.
Expand Down Expand Up @@ -2682,7 +2694,9 @@ final class AppStore {
percent: credits.usedPercent / 100,
resetsAt: credits.resetsAt,
windowSeconds: credits.windowSeconds,
fetchedAt: usage.fetchedAt
fetchedAt: usage.fetchedAt,
storageLabel: credits.storageLabel,
usedUnits: credits.used
)
if primary == nil { primary = row }
details.append(row)
Expand Down Expand Up @@ -2921,6 +2935,50 @@ final class AppStore {
)
}

/// Hand Codex's freshly fetched windows to the same monitor. Codex windows
/// have no keys of their own, so each is identified by its pre-localization
/// `storageLabel` when the row has one, else by its display label (a period
/// or model name, which does not translate).
///
/// A window with no `resetsAt` is passed with no reading, and one with no
/// validated duration with no duration: both make the detector say nothing.
private func detectCodexEarlyResets(baselineIsTrusted: Bool, now: Date = Date()) async {
guard let summary = codexQuotaSummary(filter: .codex) else { return }
let provider = CapacityDockProvider.codex
var observations: [EarlyQuotaResetMonitor.Observation] = []
var seen: Set<String> = []
for row in summary.details {
let identity = row.storageLabel ?? row.label
guard !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }
let key = EarlyQuotaResetFormat.windowKey(forLabel: identity)
guard seen.insert(key).inserted else { continue }
observations.append(EarlyQuotaResetMonitor.Observation(
windowKey: key,
windowName: EarlyQuotaResetFormat.windowName(forLabel: identity),
windowSeconds: row.windowSeconds,
// `QuotaSummary.Window` carries a 0...1 fraction; the detector
// reasons in 0...100 points.
reading: row.resetsAt.map {
EarlyQuotaResetReading(
percent: row.percent * 100,
resetsAt: $0,
observedAt: now,
usedUnits: row.usedUnits
)
}
))
}
guard !observations.isEmpty else { return }
await earlyQuotaResetMonitor.record(
providerID: provider.rawValue,
providerName: provider.displayName,
planLabel: summary.planLabel,
baselineIsTrusted: baselineIsTrusted,
observations: observations,
now: now
)
}

/// Claude's rate-limit windows are fixed lengths, the same durations the
/// pace captions project against.
private static func claudeWindowSeconds(forKey key: String) -> Int? {
Expand Down
6 changes: 6 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ struct CodexUsage: Sendable, Equatable {
var shortLabel: String {
reached ? L("Monthly usage limit · limit reached") : L("Monthly usage limit")
}

/// The identity behind `shortLabel`: pre-localization and free of the
/// `reached` state, so the early-reset monitor's storage key and name
/// survive a language switch and do not flip at the limit boundary —
/// the goodwill reset the monitor announces happens exactly there.
var storageLabel: String { "Monthly usage limit" }
}

let plan: PlanType
Expand Down
114 changes: 98 additions & 16 deletions mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,26 @@ struct EarlyQuotaResetReading: Codable, Equatable, Sendable {
let percent: Double
let resetsAt: Date
let observedAt: Date
/// Absolute usage in the provider's own units, when the adapter reports
/// one. The ratio alone cannot tell the two ways usage percent falls: a
/// vendor clearing the counter (a goodwill reset) and a vendor raising the
/// limit (a spend-cap increase) both drop it. Optional so records written
/// before the field existed still decode.
var usedUnits: Double?

init(percent: Double, resetsAt: Date, observedAt: Date, usedUnits: Double? = nil) {
self.percent = percent
self.resetsAt = resetsAt
self.observedAt = observedAt
self.usedUnits = usedUnits
}

/// A reading this build can reason about. Anything else is "no opinion".
var isWellFormed: Bool {
percent.isFinite && percent >= 0 && percent <= 100
&& resetsAt.timeIntervalSince1970.isFinite
&& observedAt.timeIntervalSince1970.isFinite
&& (usedUnits == nil || usedUnits!.isFinite)
}
}

Expand Down Expand Up @@ -90,11 +104,15 @@ struct EarlyQuotaResetEvent: Codable, Equatable, Sendable {
/// Decides whether two consecutive readings of the same window are an early
/// reset. Pure: every clock value comes from the readings themselves.
///
/// The detector assumes a fixed-cycle window with a validated duration (Claude's
/// 5-hour and 7-day limits). A rolling window's reset time creeps forward on
/// every fetch, which is exactly what signal 1 must not read as a new cycle, so
/// callers must not pass rolling windows and a window without a duration gets
/// no opinion.
/// The detector assumes a fixed-cycle window with a duration the ADAPTER has
/// validated as fixed (Claude's 5-hour and 7-day constants, Codex's
/// `limitWindowSeconds`). A rolling window's reset time creeps forward on every
/// fetch, and no pair of readings can tell a rolling re-anchor observed across
/// a gap from a genuine cut-short cycle: both move the reset forward by the
/// elapsed time and both can drop the percent. The exclusion of rolling
/// windows is therefore the `windowSeconds` contract itself — an adapter that
/// cannot vouch for a fixed cycle passes nil, and a window without a duration
/// gets no opinion.
enum EarlyQuotaResetDetector {
/// Anything within this of a boundary is clock or timestamp noise, not a
/// reset: vendors jitter `resets_at` by seconds between fetches, and local
Expand Down Expand Up @@ -163,9 +181,16 @@ enum EarlyQuotaResetDetector {

// Signal 1: a new cycle began while the old one still had time left.
if jump >= skewTolerance {
// A new fixed cycle starts no earlier than our last look at the old
// one, so it cannot reset sooner than a window after that look. A
// reset time that merely creeps forward is not a new cycle.
// A successor cycle began when the vendor cut the old one short —
// after our last look at it, by definition of this pair — so its
// reset sits at or after (last look + one window), minus rounding.
// This is what rejects a same-cycle nudge (the vendor moving its
// reset a few hours later inside the ONE cycle: the "successor"
// that implies began before our last look). It cannot reject a
// rolling window's re-anchor, whose implied start is always "now":
// for that shape the anchor holds for any observation gap, and the
// exclusion is the windowSeconds contract, not this test (see the
// type doc).
let anchoredToNewCycle = current.resetsAt
>= previous.observedAt.addingTimeInterval(window - cycleAnchorTolerance)
guard anchoredToNewCycle else { return nil }
Expand All @@ -179,6 +204,13 @@ enum EarlyQuotaResetDetector {
guard abs(jump) < skewTolerance else { return nil }
guard previous.percent - current.percent >= minimumPercentDrop,
current.percent <= maximumPercentAfterDrop else { return nil }
// A spend-cap increase is not a goodwill reset: the limit grew, the
// ratio fell, and the absolute usage did not. When the provider
// reports absolute units, require them to fall too; percent-only
// providers (Claude) keep the ratio test.
if let before = previous.usedUnits, let after = current.usedUnits {
guard after < before else { return nil }
}
return event(.usageDropped, previous: previous, current: current, context: context)
}

Expand Down Expand Up @@ -215,6 +247,47 @@ enum EarlyQuotaResetFormat {
}
}

/// Storage identity for a window that has no key of its own. Claude's
/// windows keep the snapshot store's keys; Codex identifies its windows by
/// a label slugified here. The label MUST be pre-localized English —
/// adapters whose display label translates or carries state pass
/// `QuotaSummary.Window.storageLabel` instead, and the caller prefers it —
/// because a slug of a translated string both drops the stored baseline on
/// a language switch and lets two translated siblings collide on one key.
///
/// Callers must pass a label with something in it; a blank one is skipped
/// before it reaches here.
static func windowKey(forLabel label: String) -> String {
var slug = ""
var pendingSeparator = false
for scalar in label.lowercased().unicodeScalars {
if CharacterSet.alphanumerics.contains(scalar) {
if pendingSeparator { slug.append("_") }
slug.unicodeScalars.append(scalar)
pendingSeparator = false
} else if !slug.isEmpty {
pendingSeparator = true
}
}
return slug.isEmpty ? "window" : slug
}

/// Copy noun for a window named only by its display label. A label that
/// already says what it caps ("Monthly usage limit") keeps its own noun; one
/// that names only a period ("Weekly", "5-hour") gains "limit" so the
/// notification reads as a sentence.
///
/// English, like `claudeWindowName(forKey:)`, because this is the name that
/// is persisted with the event: `limitName`, `usageName` and `windowNoun`
/// translate it at render.
static func windowName(forLabel label: String) -> String {
let trimmed = label
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !trimmed.isEmpty else { return trimmed }
return trimmed.hasSuffix("limit") ? trimmed : "\(trimmed) limit"
}

/// "2d 3h", "18h", "40m" — rounded to the unit it prints, so a lead of
/// 1h57m reads "2h" rather than truncating to "1h".
static func lead(seconds: TimeInterval) -> String {
Expand All @@ -232,8 +305,10 @@ enum EarlyQuotaResetFormat {
/// stays the English name `claudeWindowName(forKey:)` produced and the
/// translation happens here, at render. Keyed on that English name rather
/// than by stripping `" limit"` off the end, which is a rule only English
/// obeys. A label outside the known set — a provider wired up later —
/// keeps the old suffix behaviour and reads through untranslated.
/// obeys. A label outside the known set — Codex's, composed by
/// `windowName(forLabel:)` — keeps the suffix behaviour: the provider's noun
/// reads through untranslated and only the word this file added to it is
/// routed, the same shape `usageName` already used for its default.

/// "weekly limit" -> "weekly limit": the cap itself.
static func limitName(_ name: String) -> String {
Expand All @@ -242,7 +317,9 @@ enum EarlyQuotaResetFormat {
case "weekly limit": L("weekly limit")
case "Opus weekly limit": L("Opus weekly limit")
case "Sonnet weekly limit": L("Sonnet weekly limit")
default: name
default: name.hasSuffix(" limit")
? L("%@ limit", String(name.dropLast(" limit".count)))
: name
}
}

Expand All @@ -258,13 +335,18 @@ enum EarlyQuotaResetFormat {
}

/// "weekly limit" -> "weekly usage": what the vendor cleared, not the cap.
/// A name outside the known set whose noun already says "usage" — Codex's
/// "monthly usage limit" — is left alone rather than doubled into
/// "monthly usage usage".
static func usageName(_ name: String) -> String {
switch name {
case "5-hour limit": L("5-hour usage")
case "weekly limit": L("weekly usage")
case "Opus weekly limit": L("Opus weekly usage")
case "Sonnet weekly limit": L("Sonnet weekly usage")
default: L("%@ usage", windowNoun(name))
case "5-hour limit": return L("5-hour usage")
case "weekly limit": return L("weekly usage")
case "Opus weekly limit": return L("Opus weekly usage")
case "Sonnet weekly limit": return L("Sonnet weekly usage")
default:
let noun = windowNoun(name)
return noun.hasSuffix("usage") ? noun : L("%@ usage", noun)
}
}
}
19 changes: 18 additions & 1 deletion mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,36 @@ struct QuotaSummary: Equatable {
/// preserved for legacy/unsupported summaries and is not fresh enough
/// to support a pace projection.
let fetchedAt: Date?
/// Pre-localization, state-independent name for this window, when the
/// adapter has one that differs from `label`. The early-reset monitor
/// keys and names windows from this when present: a display label that
/// translates (or carries state such as "· limit reached") must not
/// become storage identity, or a language switch drops the baseline
/// and two translated siblings collide on one key.
let storageLabel: String?
/// Absolute usage the provider reported for this window, in the
/// provider's own units (credits, requests…), when it reports one.
/// The percent alone cannot tell a vendor clearing the counter from a
/// limit that grew: both drop the ratio. Nil when the adapter has no
/// absolute figure.
let usedUnits: Double?

init(
label: String,
percent: Double,
resetsAt: Date?,
windowSeconds: Int? = nil,
fetchedAt: Date? = nil
fetchedAt: Date? = nil,
storageLabel: String? = nil,
usedUnits: Double? = nil
) {
self.label = label
self.percent = percent
self.resetsAt = resetsAt
self.windowSeconds = windowSeconds
self.fetchedAt = fetchedAt
self.storageLabel = storageLabel
self.usedUnits = usedUnits
}

/// A pace estimate is valid only while the underlying sample remains
Expand Down
Loading
Loading