codeburn/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift
ozymandiashh 1e6d43d836
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
menubar: early-reset notification for Codex (#1339)
Codex hands its quota windows to the same early-reset monitor Claude uses, on refresh and on bootstrap, and a Codex disconnect clears its record. Window identity comes from a pre-localization storage label, so a language switch or the limit-reached suffix cannot drop the stored baseline or flip the key at the boundary. A window with no validated cycle length stays silent, and when a provider reports absolute usage a spend-cap increase no longer reads as a cleared counter.
2026-09-17 14:33:35 +02:00

181 lines
7.6 KiB
Swift

import Foundation
/// Per-provider live-quota snapshot consumed by the AgentTab progress bar and
/// Capacity Dock. Every CodeBurn-owned provider adapter normalizes into this
/// presentation type.
struct QuotaSummary: Equatable {
/// Quota providers use a ten-minute freshness horizon for last-known
/// snapshots. A projection from an older sample is misleading even when
/// the credentials are still connected, so pace presentation must omit it.
static let freshnessThreshold: TimeInterval = 10 * 60
static func isFresh(fetchedAt: Date?, now: Date = Date()) -> Bool {
guard let fetchedAt else { return false }
let age = now.timeIntervalSince(fetchedAt)
return age.isFinite && age >= 0 && age <= freshnessThreshold
}
enum Connection: Equatable {
case connected
case disconnected // no credentials present
case loading
case stale // had data once, current fetch is in flight
case transientFailure // backing off; show last-known data dimmed
case terminalFailure(reason: String?) // user must reconnect
}
let providerFilter: ProviderFilter
let connection: Connection
let primary: Window? // weekly utilization, the headline bar
let details: [Window] // 5h, weekly, opus, sonnet — full hover card
/// Display label for the user's plan (e.g. "Max 20x", "Pro Lite"). Shown
/// in the top-right corner of the hover detail popover so users can
/// confirm at a glance which subscription is feeding the bar.
let planLabel: String?
/// Optional footer rows that the popover renders below the window list.
/// Used for provider-specific facts such as account identity, remaining
/// credits, source attribution, and retry diagnostics.
let footerLines: [String]
struct Window: Equatable {
let label: String
let percent: Double // 0..1
let resetsAt: Date?
/// Length of this window in seconds, carried only from metadata the
/// provider service itself validates (Codex's `limitWindowSeconds`,
/// Claude's fixed 5-hour/7-day windows). Nil means the duration is not
/// known — pace presentation must omit the estimate rather than infer
/// a length from the label or the reset date.
let windowSeconds: Int?
/// Timestamp of the provider sample that produced this window. Nil is
/// 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,
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
/// inside the established live-quota freshness horizon.
func isFresh(at now: Date = Date()) -> Bool {
QuotaSummary.isFresh(fetchedAt: fetchedAt, now: now)
}
}
/// Color band thresholds for the inline chip bar and aggregate menubar
/// flame tint. Four tiers so the icon can step from "you're approaching
/// your limit" (yellow) through "you're about to hit the wall" (orange)
/// to "you're over" (red) — matches what the user expects from a warning
/// indicator in the menu bar.
static func severity(for percent: Double) -> Severity {
if percent >= 0.95 { return .danger }
if percent >= 0.7 { return .critical }
if percent >= 0.6 { return .warning }
return .normal
}
enum Severity {
case normal // <60% green
case warning // 60-70% yellow
case critical // 70-95% orange
case danger // >=95% red
}
/// The glance value (percent + color) for Capacity Dock. Every provider is
/// put on the same billing horizon: the weekly window if one exists, else the
/// monthly window. Only when a provider exposes neither does it fall back to
/// the window nearest exhaustion. Empty data stays nil rather than
/// masquerading as 0%.
var headlineWindow: Window? {
var candidates = details
if let primary, !candidates.contains(primary) {
candidates.append(primary)
}
func firstMatching(_ needle: String) -> Window? {
candidates.first { $0.label.range(of: needle, options: .caseInsensitive) != nil }
}
if let weekly = firstMatching("week") { return weekly }
if let monthly = firstMatching("month") { return monthly }
return candidates.max { lhs, rhs in lhs.percent < rhs.percent }
}
}
/// The one user-initiated recovery action Capacity Dock may offer. Keeping the
/// decision pure lets the dock render the same affordance whether a provider
/// has no summary yet or has explicitly reported an expired connection.
enum CapacityDockConnectionAction: String, Equatable, Sendable {
case connect = "Connect"
case reconnect = "Reconnect"
var title: String {
switch self {
case .connect: L("Connect")
case .reconnect: L("Reconnect")
}
}
func title(for provider: CapacityDockProvider) -> String {
if provider.catalogEntry.authMethods == [.apiTokenOrCloudCredentials] {
return L("Add API Key")
}
return title
}
static func resolve(quota: QuotaSummary?) -> Self? {
guard let quota else { return .connect }
switch quota.connection {
case .disconnected: return .connect
case .terminalFailure: return .reconnect
case .connected, .loading, .stale, .transientFailure: return nil
}
}
}
extension QuotaSummary.Window {
/// Human-readable countdown like "2h 11m" or "3d 14h" or "now".
var resetsInLabel: String {
guard let resetsAt else { return "" }
let seconds = max(0, resetsAt.timeIntervalSinceNow)
if seconds < 60 { return L("now") }
let minutes = Int(seconds / 60)
let hours = minutes / 60
let days = hours / 24
// d/h/m are unit abbreviations; zh-Hans uses 天/小时/分.
if days > 0 { return L("%1$lldd %2$lldh", days, hours % 24) }
if hours > 0 { return L("%1$lldh %2$lldm", hours, minutes % 60) }
return L("%lldm", minutes)
}
var percentLabel: String {
let pct = Int((percent * 100).rounded())
return "\(pct)%"
}
}