diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index db1d527d..2bf9d810 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -5,6 +5,21 @@ private let cacheTTLSeconds: TimeInterval = 30 private let interactiveRefreshResetSeconds: TimeInterval = 120 private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod" +private func quotaFetchWasCancelled(_ error: Error) -> Bool { + if error is CancellationError { return true } + if let error = error as? ClaudeSubscriptionService.FetchError, + case let .network(cause) = error, + cause is CancellationError { + return true + } + if let error = error as? CodexSubscriptionService.FetchError, + case let .network(cause) = error, + cause is CancellationError { + return true + } + return false +} + struct CachedPayload { let payload: MenubarPayload let fetchedAt: Date @@ -48,6 +63,11 @@ struct PayloadCacheKey: Hashable { @MainActor @Observable final class AppStore { + private struct QuotaRefreshToken { + let requestGeneration: Int + let lifecycleGeneration: Int + } + var selectedProvider: ProviderFilter = .all var selectedPeriod: Period = .today var selectedScope: MenubarScope = MenubarScope.savedMenubarScope() @@ -185,6 +205,20 @@ final class AppStore { var capacityDockProviderTransientFailures: Set = [] private var capacityDockProviderRefreshGenerations: [String: UInt64] = [:] @ObservationIgnored var capacityDockProviderQuotaService = CapacityDockProviderQuotaService.shared + /// Injectable seams keep the quota refresh state machine testable without + /// making the production tests contact provider endpoints. + @ObservationIgnored var claudeQuotaFetcher: @Sendable () async throws -> SubscriptionUsage? = { + try await ClaudeSubscriptionService.refreshIfBootstrapped() + } + @ObservationIgnored var codexQuotaFetcher: @Sendable () async throws -> CodexUsage? = { + try await CodexSubscriptionService.refreshIfBootstrapped() + } + @ObservationIgnored var claudeQuotaBootstrapChecker: @Sendable () -> Bool = { + ClaudeCredentialStore.isBootstrapCompleted + } + @ObservationIgnored var codexQuotaBootstrapChecker: @Sendable () -> Bool = { + CodexCredentialStore.isBootstrapCompleted + } @ObservationIgnored var capacityDockCredentialLoader: @Sendable (String) async throws -> CapacityDockProviderCredential = { try await CapacityDockProviderCredentialStore.loadAsync(for: $0) @@ -207,6 +241,15 @@ final class AppStore { /// resume after the await and re-populate the freshly-cleared state. private var claudeRefreshGen: Int = 0 private var codexRefreshGen: Int = 0 + /// Request tokens keep overlapping manual/cadence refreshes from restoring + /// an older state over a newer request. The lifecycle generation above still + /// handles disconnect; these tokens handle ordinary request supersession. + private var claudeRefreshRequestGen: Int = 0 + private var codexRefreshRequestGen: Int = 0 + private var claudeRefreshInFlightRequest: Int? + private var codexRefreshInFlightRequest: Int? + private var claudeRefreshRestoreState: SubscriptionLoadState? + private var codexRefreshRestoreState: SubscriptionLoadState? private var kimiRefreshGen: Int = 0 private var geminiRefreshGen: Int = 0 private var copilotRefreshGen: Int = 0 @@ -1361,6 +1404,74 @@ final class AppStore { await bootstrapCodex() } + private func beginClaudeQuotaRefresh() -> QuotaRefreshToken { + claudeRefreshRequestGen &+= 1 + let token = QuotaRefreshToken( + requestGeneration: claudeRefreshRequestGen, + lifecycleGeneration: claudeRefreshGen + ) + if claudeRefreshInFlightRequest == nil { + claudeRefreshRestoreState = subscriptionLoadState + } + claudeRefreshInFlightRequest = token.requestGeneration + // A populated subscription remains available to the plan bar, but its + // pace projection must be treated as stale for the whole await. + subscriptionLoadState = .loading + return token + } + + private func isCurrentClaudeQuotaRefresh(_ token: QuotaRefreshToken) -> Bool { + token.lifecycleGeneration == claudeRefreshGen + && token.requestGeneration == claudeRefreshRequestGen + && claudeRefreshInFlightRequest == token.requestGeneration + } + + private func finishClaudeQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentClaudeQuotaRefresh(token) else { return } + claudeRefreshInFlightRequest = nil + claudeRefreshRestoreState = nil + } + + private func restoreClaudeQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentClaudeQuotaRefresh(token) else { return } + subscriptionLoadState = claudeRefreshRestoreState + ?? (subscription == nil ? .failed : .loaded) + finishClaudeQuotaRefresh(token) + } + + private func beginCodexQuotaRefresh() -> QuotaRefreshToken { + codexRefreshRequestGen &+= 1 + let token = QuotaRefreshToken( + requestGeneration: codexRefreshRequestGen, + lifecycleGeneration: codexRefreshGen + ) + if codexRefreshInFlightRequest == nil { + codexRefreshRestoreState = codexLoadState + } + codexRefreshInFlightRequest = token.requestGeneration + codexLoadState = .loading + return token + } + + private func isCurrentCodexQuotaRefresh(_ token: QuotaRefreshToken) -> Bool { + token.lifecycleGeneration == codexRefreshGen + && token.requestGeneration == codexRefreshRequestGen + && codexRefreshInFlightRequest == token.requestGeneration + } + + private func finishCodexQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentCodexQuotaRefresh(token) else { return } + codexRefreshInFlightRequest = nil + codexRefreshRestoreState = nil + } + + private func restoreCodexQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentCodexQuotaRefresh(token) else { return } + codexLoadState = codexRefreshRestoreState + ?? (codexUsage == nil ? .failed : .loaded) + finishCodexQuotaRefresh(token) + } + func bootstrapSubscription() async { subscriptionLoadState = .bootstrapping do { @@ -1388,36 +1499,51 @@ final class AppStore { /// rather than every attempt. @discardableResult func refreshSubscriptionReportingSuccess() async -> Bool { - guard ClaudeCredentialStore.isBootstrapCompleted else { + guard claudeQuotaBootstrapChecker() else { if subscriptionLoadState != .notBootstrapped { subscriptionLoadState = .notBootstrapped } return false } - let gen = claudeRefreshGen - if subscription == nil { subscriptionLoadState = .loading } + let token = beginClaudeQuotaRefresh() do { - guard let usage = try await ClaudeSubscriptionService.refreshIfBootstrapped() else { + guard let usage = try await claudeQuotaFetcher() else { + restoreClaudeQuotaRefresh(token) return false } // Disconnect-during-fetch guard: if the user clicked Disconnect // while we were awaiting Anthropic, the generation token will // have advanced and we must drop this result instead of writing // it back over the freshly-cleared state. - guard gen == claudeRefreshGen else { return false } + guard isCurrentClaudeQuotaRefresh(token) else { return false } + guard !Task.isCancelled else { + restoreClaudeQuotaRefresh(token) + return false + } subscription = usage subscriptionError = nil subscriptionLoadState = .loaded + finishClaudeQuotaRefresh(token) await captureSnapshots(for: usage) return true } catch let err as ClaudeSubscriptionService.FetchError { - guard gen == claudeRefreshGen else { return false } + guard isCurrentClaudeQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(err) { + restoreClaudeQuotaRefresh(token) + return false + } applyFetchError(err) + finishClaudeQuotaRefresh(token) return false } catch { - guard gen == claudeRefreshGen else { return false } + guard isCurrentClaudeQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(error) { + restoreClaudeQuotaRefresh(token) + return false + } subscriptionError = sanitizeForUI(error.localizedDescription) subscriptionLoadState = .failed + finishClaudeQuotaRefresh(token) return false } } @@ -1432,11 +1558,17 @@ final class AppStore { // Bump the generation token so any in-flight refreshSubscription that // resumes after this point detects the disconnect and discards its // result instead of re-populating the cleared state. + let refreshRestoreState = claudeRefreshRestoreState claudeRefreshGen &+= 1 + claudeRefreshInFlightRequest = nil + claudeRefreshRestoreState = nil guard result.isSuccess else { // Nothing was removed, so nothing is disconnected. Leave the // connected state exactly as it was — the bootstrap flag is still // set, Disconnect stays available, and the banner says to retry. + if let refreshRestoreState { + subscriptionLoadState = refreshRestoreState + } subscriptionError = "Could not fully remove the local Claude credential cache. Disconnect again to retry." return } @@ -1473,43 +1605,64 @@ final class AppStore { @discardableResult func refreshCodexReportingSuccess() async -> Bool { - if case .dormant = codexLoadState, !CodexCredentialStore.isBootstrapCompleted { + if case .dormant = codexLoadState, !codexQuotaBootstrapChecker() { await bootstrapCodex() return codexLoadState == .loaded } - guard CodexCredentialStore.isBootstrapCompleted else { + guard codexQuotaBootstrapChecker() else { if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped } return false } - let gen = codexRefreshGen - if codexUsage == nil { codexLoadState = .loading } + let token = beginCodexQuotaRefresh() do { - guard let usage = try await CodexSubscriptionService.refreshIfBootstrapped() else { + guard let usage = try await codexQuotaFetcher() else { + restoreCodexQuotaRefresh(token) + return false + } + guard isCurrentCodexQuotaRefresh(token) else { return false } + guard !Task.isCancelled else { + restoreCodexQuotaRefresh(token) return false } - guard gen == codexRefreshGen else { return false } codexUsage = usage codexError = nil codexLoadState = .loaded + finishCodexQuotaRefresh(token) return true } catch let err as CodexSubscriptionService.FetchError { - guard gen == codexRefreshGen else { return false } + guard isCurrentCodexQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(err) { + restoreCodexQuotaRefresh(token) + return false + } applyCodexFetchError(err) + finishCodexQuotaRefresh(token) return false } catch { - guard gen == codexRefreshGen else { return false } + guard isCurrentCodexQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(error) { + restoreCodexQuotaRefresh(token) + return false + } codexError = sanitizeForUI(error.localizedDescription) codexLoadState = .failed + finishCodexQuotaRefresh(token) return false } } func disconnectCodex() { let result = CodexSubscriptionService.disconnect() + let refreshRestoreState = codexRefreshRestoreState codexRefreshGen &+= 1 + codexRefreshInFlightRequest = nil + codexRefreshRestoreState = nil guard result.isSuccess else { // Nothing removed means nothing disconnected; keep state intact so // Disconnect stays available for a retry. + if let refreshRestoreState { + codexLoadState = refreshRestoreState + } codexError = "Could not fully remove the local Codex credential cache. Disconnect again to retry." return } @@ -2025,6 +2178,16 @@ final class AppStore { let present = rows.compactMap(value) return present.isEmpty ? nil : present.reduce(0, +) } + // Cache read is stricter than the other token fields: a tile whose rows + // are split across a legacy and a current CLI must not present a partial + // known sum as complete. If any ACTIVE row lacks the cache field, the + // tile reports none — an idle row (hasUsage false) carries a genuine + // zero and does not force unknown. + let cacheRead: Int? = { + let activeMissing = rows.contains { $0.hasUsage && $0.cacheReadTokens == nil } + guard !activeMissing else { return nil } + return sum(\.cacheReadTokens) + }() return ProviderDetail( id: id, label: provider.displayName, @@ -2033,7 +2196,8 @@ final class AppStore { hasUsage: rows.contains { $0.hasUsage }, inputTokens: sum(\.inputTokens), outputTokens: sum(\.outputTokens), - sessions: sum(\.sessions) + sessions: sum(\.sessions), + cacheReadTokens: cacheRead ) } @@ -2227,12 +2391,13 @@ final class AppStore { if case .notBootstrapped = subscriptionLoadState { return nil } if case .bootstrapping = subscriptionLoadState { return nil } if case .noCredentials = subscriptionLoadState { return nil } + let usageIsFresh = QuotaSummary.isFresh(fetchedAt: subscription?.fetchedAt) let connection: QuotaSummary.Connection = { switch subscriptionLoadState { case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected case .loading: return subscription == nil ? .loading : .stale - case .loaded: return .connected + case .loaded: return usageIsFresh ? .connected : .stale case .failed: return subscription == nil ? .loading : .stale case let .terminalFailure(reason): return .terminalFailure(reason: reason) case .transientFailure: return .transientFailure @@ -2242,22 +2407,44 @@ final class AppStore { var primary: QuotaSummary.Window? var details: [QuotaSummary.Window] = [] if let usage = subscription { + // Claude's rate-limit windows are fixed lengths, so each row + // carries its validated duration for pace presentation. if let pct = usage.fiveHourPercent { - details.append(.init(label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt)) + details.append(.init( + label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt, + windowSeconds: QuotaPacePresentation.claudeFiveHourSeconds, + fetchedAt: usage.fetchedAt + )) } if let pct = usage.sevenDayPercent { - let weekly = QuotaSummary.Window(label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt) + let weekly = QuotaSummary.Window( + label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + ) primary = weekly details.append(weekly) } if let pct = usage.sevenDayOpusPercent { - details.append(.init(label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt)) + details.append(.init( + label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + )) } if let pct = usage.sevenDaySonnetPercent { - details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt)) + details.append(.init( + label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + )) } for scoped in usage.scopedWeekly { - details.append(.init(label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt)) + details.append(.init( + label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + )) } } let plan = subscription?.tier.displayName @@ -2268,12 +2455,13 @@ final class AppStore { if case .notBootstrapped = codexLoadState { return nil } if case .bootstrapping = codexLoadState { return nil } if case .noCredentials = codexLoadState { return nil } + let usageIsFresh = QuotaSummary.isFresh(fetchedAt: codexUsage?.fetchedAt) let connection: QuotaSummary.Connection = { switch codexLoadState { case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected case .loading: return codexUsage == nil ? .loading : .stale - case .loaded: return .connected + case .loaded: return usageIsFresh ? .connected : .stale case .failed: return codexUsage == nil ? .loading : .stale case let .terminalFailure(reason): return .terminalFailure(reason: reason) case .transientFailure: return .transientFailure @@ -2283,13 +2471,23 @@ final class AppStore { var primary: QuotaSummary.Window? var details: [QuotaSummary.Window] = [] if let usage = codexUsage { + // Codex reports each rate window's length itself, so every row + // carries its own validated duration for pace presentation. if let w = usage.primary { - let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + let row = QuotaSummary.Window( + label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt, + windowSeconds: w.limitWindowSeconds, + fetchedAt: usage.fetchedAt + ) primary = row details.append(row) } if let w = usage.secondary { - let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + let row = QuotaSummary.Window( + label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt, + windowSeconds: w.limitWindowSeconds, + fetchedAt: usage.fetchedAt + ) // Some Codex plans (free / guest tiers) only return a secondary // window. Promote it to primary so the chip bar always has a // data source instead of rendering as an empty track. @@ -2302,10 +2500,18 @@ final class AppStore { // the main Codex window. for extra in usage.additionalLimits { if let p = extra.primary, p.usedPercent > 0 { - details.append(.init(label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt)) + details.append(.init( + label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt, + windowSeconds: p.limitWindowSeconds, + fetchedAt: usage.fetchedAt + )) } if let s = extra.secondary, s.usedPercent > 0 { - details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt)) + details.append(.init( + label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt, + windowSeconds: s.limitWindowSeconds, + fetchedAt: usage.fetchedAt + )) } } // No rate windows here, so the allowance feeds the bar and badge. @@ -2313,7 +2519,9 @@ final class AppStore { let row = QuotaSummary.Window( label: credits.shortLabel, percent: credits.usedPercent / 100, - resetsAt: credits.resetsAt + resetsAt: credits.resetsAt, + windowSeconds: credits.windowSeconds, + fetchedAt: usage.fetchedAt ) if primary == nil { primary = row } details.append(row) diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 4165ddc7..4612a85a 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -378,6 +378,11 @@ struct ProviderDetail: Codable, Sendable { let inputTokens: Int? let outputTokens: Int? let sessions: Int? + /// Input tokens re-served from the provider's prompt cache for the period, + /// accounted separately from `inputTokens` and priced at the cache-read + /// rate inside `cost`. Nil on CLIs that predate per-provider cache + /// accounting: absent means unknown, never a fabricated zero. + let cacheReadTokens: Int? init( id: String, @@ -387,7 +392,8 @@ struct ProviderDetail: Codable, Sendable { hasUsage: Bool, inputTokens: Int? = nil, outputTokens: Int? = nil, - sessions: Int? = nil + sessions: Int? = nil, + cacheReadTokens: Int? = nil ) { self.id = id self.label = label @@ -397,10 +403,11 @@ struct ProviderDetail: Codable, Sendable { self.inputTokens = inputTokens self.outputTokens = outputTokens self.sessions = sessions + self.cacheReadTokens = cacheReadTokens } private enum CodingKeys: String, CodingKey { - case id, label, cost, calls, hasUsage, inputTokens, outputTokens, sessions + case id, label, cost, calls, hasUsage, inputTokens, outputTokens, sessions, cacheReadTokens } init(from decoder: Decoder) throws { @@ -419,6 +426,7 @@ struct ProviderDetail: Codable, Sendable { inputTokens = try c.decodeIfPresent(Int.self, forKey: .inputTokens) outputTokens = try c.decodeIfPresent(Int.self, forKey: .outputTokens) sessions = try c.decodeIfPresent(Int.self, forKey: .sessions) + cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift new file mode 100644 index 00000000..54009fd3 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift @@ -0,0 +1,235 @@ +import Foundation + +/// Turns a quota window into the Capacity Dock's one-line pace caption: the +/// whole-window average interpretation `QuotaPace` defends (#726 phase 1), +/// rendered as "on pace", a deficit/reserve stage, an estimated exhaustion, +/// or the explicit exhausted state. Deliberately text-only: the math lives in +/// `QuotaPace`, the wording lives here, so both stay testable without a view. +/// +/// Honesty rules this type enforces: +/// - The caption describes the AVERAGE pace across the whole elapsed window, +/// never a measured recent rate. The hover/accessibility text says so. +/// - Only validated window durations are used. A window without +/// `windowSeconds` gets no estimate — the label ("Weekly") is not a length. +/// - `Window.percent` is a 0...1 fraction; `QuotaPace` consumes 0...100. +/// This is the one place the unit crosses, and it is tested. Anything +/// outside 0...1 (negative, >1, NaN, infinity) is rejected, not clamped. +/// - A projection is valid only while the sample that produced the window is +/// inside `QuotaSummary`'s ten-minute freshness horizon. A connected +/// account can still carry an old loaded sample while a refresh is pending; +/// that sample must not become a forecast. +/// - The estimate is an ETA measured from `now` ("est. out in 3h 20m"), never +/// a lead before reset, and every countdown is computed against the passed +/// `now`, never a hidden wall clock, so fixtures stay deterministic. +/// - Stale, failed, loading or disconnected quota data gets nothing. A reset +/// in the past, or further out than one full window (clock/data skew), and +/// non-finite reset timestamps are refused before any branch. +enum QuotaPacePresentation { + /// Claude's rate-limit windows are fixed lengths (the same values the + /// plan popover projects with), so they are validated durations. + static let claudeFiveHourSeconds = 5 * 3600 + static let claudeSevenDaySeconds = 7 * 24 * 3600 + + /// What a window column draws in its reserved pace slot. The slot itself + /// stays empty when no `Line` is defensible. + struct Line: Equatable { + enum Kind: Equatable { + /// Projected from the whole-window average. + case estimate + /// The window is at exactly 100% and has not reset yet. + case exhausted + } + + /// Visual weight for the caption: muted for a healthy pace, amber for + /// a deficit or projected overflow, red once the limit is reached. + enum Tone: Equatable { + case neutral + case warning + case danger + } + + let kind: Kind + let tone: Tone + /// Compact caption for the column, e.g. "est. out in 3h 20m". + let text: String + /// Hover/accessibility text carrying the full honest reading. + let helpText: String + } + + /// The caption for one window, or nil when nothing defensible remains. + static func line( + for window: QuotaSummary.Window, + connection: QuotaSummary.Connection, + now: Date = Date() + ) -> Line? { + // Last-known or in-flight data cannot back a projection. + guard connection == .connected else { return nil } + // A connected summary can still be an old loaded sample while a + // refresh is pending. Keep the pace caption honest by tying it to the + // same injected `now` used by the calculation and by the tests. + guard window.isFresh(at: now) else { return nil } + guard let windowSeconds = window.windowSeconds, windowSeconds > 0 else { return nil } + guard let resetsAt = window.resetsAt else { return nil } + // `percent` arrives as a fraction; the pace math consumes percent. + // Reject non-finite or out-of-range samples before either branch: a + // negative fraction clamped to zero would invent a healthy forecast, + // and >1 is not a "100% used" signal, it is a broken sample. + guard window.percent.isFinite, window.percent >= 0, window.percent <= 1 else { return nil } + let usedPercent = window.percent * 100 + + // Reset in the past, or further out than one full window, or a + // non-finite timestamp: clock/data skew. Guard both branches — an + // "exhausted" window whose reset is months away is impossible data, + // not a limit that is actually reached. + let remaining = resetsAt.timeIntervalSince(now) + guard remaining.isFinite else { return nil } + guard remaining > 0, remaining <= TimeInterval(windowSeconds) else { return nil } + + if usedPercent >= 100 { + return Line( + kind: .exhausted, + tone: .danger, + text: "limit reached", + helpText: "This window's limit is fully used. It resets in \(countdownLabel(seconds: remaining))." + ) + } + + guard let result = QuotaPace.evaluate( + usedPercent: usedPercent, + resetsAt: resetsAt, + windowSeconds: windowSeconds, + now: now + ) else { return nil } + let tone: Line.Tone = result.willOverflow || result.deltaPercent > 2 ? .warning : .neutral + return Line( + kind: .estimate, + tone: tone, + text: caption(for: result, windowSeconds: windowSeconds, now: now), + helpText: helpText( + result: result, + windowSeconds: windowSeconds, + now: now, + resetsAt: resetsAt + ) + ) + } + + /// One line per displayed window, in report order. Distinct scope windows + /// that merely share a duration (Claude's Weekly vs Weekly · Opus vs + /// Weekly · Sonnet, or Codex's extra per-model limits) each keep their own + /// caption — they report different usage and different exhaustion risk. + /// Only a genuinely identical duplicate window (same label, percent, + /// reset and duration) is suppressed. + static func lines( + for windows: [QuotaSummary.Window], + connection: QuotaSummary.Connection, + now: Date = Date() + ) -> [Line?] { + var seen: [QuotaSummary.Window] = [] + return windows.map { window in + guard !seen.contains(window) else { return nil } + seen.append(window) + return line(for: window, connection: connection, now: now) + } + } + + /// Whether the panel must reserve a pace slot under the window columns. + /// Deliberately time-independent: the slot is reserved whenever connected + /// data carries a displayable window with validated duration and a reset + /// date, even while the caption itself is still empty (window younger + /// than 3%). Reserving on data alone keeps the computed panel height from + /// changing under the pointer as wall-clock time crosses a threshold. + static func reservesLine( + for windows: [QuotaSummary.Window], + connection: QuotaSummary.Connection + ) -> Bool { + guard connection == .connected else { return false } + return windows.contains { window in + guard let seconds = window.windowSeconds, seconds > 0 else { return false } + return window.resetsAt != nil + } + } + + /// Compact caption. The estimate is the projection itself: an over-pace + /// window reads as "est. out in " and a window under pace + /// reads as "est. N% at reset", so the useful number always fits one + /// narrow column. On windows at or under + /// `QuotaPace.etaSuppressionMaxSeconds` there is no projection or ETA at + /// all — a linear read of a short window cries wolf after one burst — so + /// only the deficit/reserve stage shows. + static func caption(for result: QuotaPace.Result, windowSeconds: Int, now: Date = Date()) -> String { + let compact = TimeInterval(windowSeconds) <= QuotaPace.etaSuppressionMaxSeconds + if compact { + if abs(result.deltaPercent) <= 2 { return "on pace" } + if result.deltaPercent > 0 { return "\(Int(result.deltaPercent.rounded()))% in deficit" } + return "\(Int(-result.deltaPercent.rounded()))% in reserve" + } + if result.willOverflow, let hitsLimitAt = result.hitsLimitAt { + return "est. out in \(countdownLabel(from: now, to: hitsLimitAt))" + } + return "est. \(Int(result.projectedPercent.rounded()))% at reset" + } + + private static func helpText( + result: QuotaPace.Result, + windowSeconds: Int, + now: Date, + resetsAt: Date + ) -> String { + let basis = "Estimated from the average pace across this whole " + + "\(windowLengthLabel(seconds: windowSeconds)) window so far — " + + "not a measured recent rate." + let projected = Int(result.projectedPercent.rounded()) + let projectionSentence: String + if result.willOverflow, let hitsLimitAt = result.hitsLimitAt { + projectionSentence = "At that pace the limit is reached in " + + "\(countdownLabel(from: now, to: hitsLimitAt)), before the reset in " + + "\(countdownLabel(from: now, to: resetsAt))." + } else if abs(result.deltaPercent) <= 2 { + projectionSentence = "Projected \(projected)% used by the reset — on pace." + } else if result.deltaPercent > 0 { + projectionSentence = String( + format: "Projected %d%% used by the reset, %.0f%% ahead of the pace the elapsed window implies.", + projected, result.deltaPercent + ) + } else { + projectionSentence = String( + format: "Projected %d%% used by the reset, %.0f%% of the window still in reserve.", + projected, -result.deltaPercent + ) + } + return basis + " " + projectionSentence + } + + /// Human label for a validated duration: the two lengths Claude and Codex + /// actually report, else a plain hours/days rendering of the number. + private static func windowLengthLabel(seconds: Int) -> String { + switch seconds { + case claudeFiveHourSeconds: return "5-hour" + case claudeSevenDaySeconds: return "7-day" + default: + let hours = seconds / 3600 + if hours >= 24, seconds % 86400 == 0 { return "\(hours / 24)-day" } + return "\(hours)-hour" + } + } + + /// "2d 3h" / "3h 20m" / "45m" / "<1m", mirroring the window column's own + /// countdown shape. Computed against the passed dates, never a hidden + /// wall clock, so fixtures stay deterministic. + static func countdownLabel(from now: Date, to date: Date) -> String { + countdownLabel(seconds: date.timeIntervalSince(now)) + } + + /// Countdown label for an already-computed interval (clamped at zero). + static func countdownLabel(seconds: TimeInterval) -> String { + let value = max(0, seconds) + if value < 60 { return "<1m" } + let minutes = Int(value / 60) + let hours = minutes / 60 + let days = hours / 24 + if days > 0 { return "\(days)d \(hours % 24)h" } + if hours > 0 { return "\(hours)h \(minutes % 60)m" } + return "\(minutes)m" + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index 70a21b35..8abfeab9 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -4,6 +4,17 @@ import Foundation /// 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 @@ -30,6 +41,36 @@ struct QuotaSummary: 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? + + init( + label: String, + percent: Double, + resetsAt: Date?, + windowSeconds: Int? = nil, + fetchedAt: Date? = nil + ) { + self.label = label + self.percent = percent + self.resetsAt = resetsAt + self.windowSeconds = windowSeconds + self.fetchedAt = fetchedAt + } + + /// 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 diff --git a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift index 05361afe..4973195a 100644 --- a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift @@ -99,7 +99,7 @@ enum CapacityDockMetrics { if CapacityDockGlance.drawsWindows(quota) { height += CapacityDockGlance.windows(quota).isEmpty ? CapacityDockGlance.windowsEmptyHeight - : CapacityDockGlance.windowsHeight + : CapacityDockGlance.windowsHeight(for: quota) } height += CapacityDockConnectionAction.resolve(quota: quota) == nil ? 0 : 38 let connectionExtra: CGFloat = switch quota.connection { @@ -135,13 +135,81 @@ enum CapacityDockGlance { /// Held as a constant because the panel frame is computed, not fitted. static let pillHeight: CGFloat = 46 static let pillGap: CGFloat = 6 - /// Three stacked lines, 13 + 13 + 12, with two 3pt gaps. Taller than the - /// 17pt burned figure beside it, so it sets the row. - static let todayContentHeight: CGFloat = 44 + /// Four stacked lines, 13 + 13 + 13 + 12, with three 3pt gaps. Taller than the + /// 17pt burned figure beside it, so it sets the row. The fourth line is the + /// cache-read figure, which drops out when the payload carries no + /// provider-scoped cache accounting — the reserved height does not move with + /// it, exactly like the input/output pair above it. + static let todayContentHeight: CGFloat = 60 /// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 16 bottom. static let windowsHeight: CGFloat = 77 + /// The column content height inside `windowsHeight`: the section's top and + /// bottom padding are owned by `windowsSection`, while a grid owns the rows. + static let windowContentHeight: CGFloat = windowsHeight - sectionPadTop - contentInset + /// A real gap keeps adjacent scope labels and captions visually separate. + /// The old zero-spacing HStack let intrinsic Text widths bleed across columns. + static let windowsColumnGap: CGFloat = 8 + static let windowsRowGap: CGFloat = 6 + /// One 9.5pt pace caption under the reset line, with its 2pt gap. Reserved + /// whenever connected data carries a window with validated duration, even + /// while the caption itself is still empty — the slot must not appear and + /// disappear with wall-clock time under an open panel. + static let paceLineHeight: CGFloat = 12 + static let paceLineGap: CGFloat = 2 /// 8 top + one secondary line + 16 bottom. static let windowsEmptyHeight: CGFloat = 37 + + /// Whether the windows row carries pace slots: connected data with at + /// least one displayed window holding validated duration metadata. + static func drawsPace(_ quota: QuotaSummary) -> Bool { + guard drawsWindows(quota) else { return false } + let shown = windows(quota) + guard !shown.isEmpty else { return false } + return QuotaPacePresentation.reservesLine(for: shown, connection: quota.connection) + } + + /// The windows row's height for this quota: the plain row, or the row with + /// the pace slot every column reserves. Must stay in step with + /// `windowColumn`, which draws the slot under every column when this fires. + static func windowsHeight(for quota: QuotaSummary) -> CGFloat { + let shown = windows(quota) + guard !shown.isEmpty else { return windowsEmptyHeight } + return ( + sectionPadTop + + windowsGridHeight(for: quota) + + contentInset + ).rounded() + } + + /// One or two windows stay on one compact row. Three and four windows use + /// two columns and enough row height for every percentage, reset, and pace + /// caption. This is shared by `detailHeight` and the actual SwiftUI grid. + static func windowColumnCount(for windowCount: Int) -> Int { + guard windowCount > 0 else { return 0 } + return min(windowCount, 2) + } + + static func windowRowCount(for windowCount: Int) -> Int { + let columns = windowColumnCount(for: windowCount) + guard columns > 0 else { return 0 } + return (windowCount + columns - 1) / columns + } + + static func windowRowHeight(hasPaceSlot: Bool) -> CGFloat { + windowContentHeight + (hasPaceSlot ? paceLineGap + paceLineHeight : 0) + } + + /// Height of the grid alone, excluding this section's top and bottom pads. + static func windowsGridHeight(for quota: QuotaSummary) -> CGFloat { + let count = windows(quota).count + guard count > 0 else { return 0 } + let rows = windowRowCount(for: count) + let rowHeight = windowRowHeight(hasPaceSlot: drawsPace(quota)) + return ( + CGFloat(rows) * rowHeight + + CGFloat(max(0, rows - 1)) * windowsRowGap + ).rounded() + } /// The staleness or reconnect line under the header. It is a section like any /// other, so the panel has to reserve its height: the frame is computed, not /// fitted, and an unreserved line squeezes every block below it. @@ -944,6 +1012,12 @@ struct CapacityDockDetailView: View { // arrows drop out instead. if let input = today.inputTokens { tokenLine("arrow.down", Double(input)) } if let output = today.outputTokens { tokenLine("arrow.up", Double(output)) } + // Same absence rule as the arrows: cache read is shown only + // when the payload carries it for this provider, so a legacy + // CLI reads as unknown rather than as a fabricated zero. + if let cacheRead = today.cacheReadTokens { + cacheReadLine(Double(cacheRead)) + } Text("\(today.calls.asThousandsSeparated()) calls") .font(.system(size: 10)) .monospacedDigit() @@ -975,24 +1049,62 @@ struct CapacityDockDetailView: View { .frame(height: 13 * s) } - /// One column per quota window, in the order the provider reported them. + /// Reused input tokens, kept apart from the `arrow.down` fresh-input figure + /// and from cache writes: this is the part of the prompt the provider served + /// from its cache at the discounted rate that `cost` already includes. + @ViewBuilder + private func cacheReadLine(_ value: Double) -> some View { + let s = model.detailScale + let explanation = "Input tokens reused from this provider's prompt cache today — " + + "not fresh input (arrow.down), not cache writes, and already priced at the " + + "cache-read rate inside the burned figure." + HStack(spacing: 4 * s) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color.capacityDockText.opacity(0.6)) + Text(value.asCompactTokens().lowercasedThousands()) + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(Color.capacityDockText) + Text("cache read") + .font(.system(size: 9.5)) + .foregroundStyle(Color.capacityDockText.opacity(0.6)) + } + .frame(height: 13 * s) + .help(explanation) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Cache read: \(value.asCompactTokens().lowercasedThousands()) tokens") + .accessibilityHint(explanation) + } + + /// One cell per quota window, in the order the provider reported them. + /// One or two windows stay on a compact row. Three or four windows use a + /// two-column grid whose cell width comes from the actual content geometry, + /// including its inter-column gap. When the panel reserved pace slots + /// (`drawsPace`), every cell draws the slot — empty where its window has no + /// defensible caption — so rows stay aligned with the height the panel + /// reserved. @ViewBuilder private func windowsSection(_ quota: QuotaSummary) -> some View { let s = model.detailScale let windows = CapacityDockGlance.windows(quota) + let hasPaceSlot = CapacityDockGlance.drawsPace(quota) + let paceLines = QuotaPacePresentation.lines( + for: windows, + connection: quota.connection + ) Group { if windows.isEmpty { budgetLine() .frame(height: CapacityDockGlance.captionLine * s) } else { - // A single window has no siblings to line up with, so it reads as - // a left-aligned figure rather than a lone centred digit. - let alignment: HorizontalAlignment = windows.count == 1 ? .leading : .center - HStack(spacing: 0) { - ForEach(Array(windows.enumerated()), id: \.offset) { _, window in - windowColumn(window, alignment: alignment) - } - } + windowGrid( + windows, + paceLines: paceLines, + hasPaceSlot: hasPaceSlot, + scale: s + ) + .frame(height: CapacityDockGlance.windowsGridHeight(for: quota) * s) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -1001,39 +1113,145 @@ struct CapacityDockDetailView: View { .padding(.horizontal, CapacityDockGlance.contentInset * s) } + @ViewBuilder + private func windowGrid( + _ windows: [QuotaSummary.Window], + paceLines: [QuotaPacePresentation.Line?], + hasPaceSlot: Bool, + scale: CGFloat + ) -> some View { + let columnCount = CapacityDockGlance.windowColumnCount(for: windows.count) + let rowCount = CapacityDockGlance.windowRowCount(for: windows.count) + let alignment: HorizontalAlignment = windows.count == 1 ? .leading : .center + GeometryReader { geometry in + let columnSpacing = columnCount > 1 + ? CapacityDockGlance.windowsColumnGap * scale + : 0 + let columnWidth = max( + 0, + (geometry.size.width - columnSpacing * CGFloat(max(0, columnCount - 1))) + / CGFloat(max(columnCount, 1)) + ) + VStack(spacing: windows.count > 2 ? CapacityDockGlance.windowsRowGap * scale : 0) { + ForEach(0.. some View { - let s = model.detailScale VStack(alignment: alignment, spacing: 0) { PercentGaugeText( label: window.percentLabel, fraction: window.percent, - font: .system(size: 20, weight: .semibold) + font: .system(size: 20 * scale, weight: .semibold) ) - .frame(height: 24 * s) + .frame(width: width, height: 24 * scale, alignment: alignment == .leading ? .leading : .center) Text(CapacityDockQuotaPresentation.displayLabel(window.label)) - .font(.system(size: 11)) + .font(.system(size: 11 * scale)) .foregroundStyle(Color.capacityDockText.opacity(0.6)) .lineLimit(1) - .frame(height: CapacityDockGlance.captionLine * s) - .padding(.top, 2 * s) + .minimumScaleFactor(0.7) + .truncationMode(.middle) + .frame( + width: width, + height: CapacityDockGlance.captionLine * scale, + alignment: alignment == .leading ? .leading : .center + ) + .padding(.top, 2 * scale) + .help(window.label) + .accessibilityLabel("Quota window " + window.label) Text(window.resetsInLabel) - .font(.system(size: 10)) + .font(.system(size: 10 * scale)) .monospacedDigit() .foregroundStyle(Color.capacityDockText.opacity(0.3)) - .lineLimit(1) - .frame(height: 12 * s) - .padding(.top, 2 * s) + // Reset labels come from the validated countdown formatter and + // are short (for example, "3d 11h"). Let the complete value + // keep its natural one-line width at the small 0.9x card scale; + // the grid cells are substantially wider than these labels. + .fixedSize(horizontal: true, vertical: true) + .frame( + width: width, + height: 12 * scale, + alignment: alignment == .leading ? .leading : .center + ) + .padding(.top, 2 * scale) + .accessibilityLabel("Resets " + window.resetsInLabel) + if hasPaceSlot { + paceCaption(paceLine) + .frame( + width: width, + height: CapacityDockGlance.paceLineHeight * scale, + alignment: alignment == .leading ? .leading : .center + ) + .padding(.top, CapacityDockGlance.paceLineGap * scale) + } } .frame( - maxWidth: .infinity, + width: width, alignment: alignment == .leading ? .leading : .center ) } + /// The whole-window-average pace reading under one quota window. An absent + /// caption leaves its reserved slot empty: no estimate is the honest state + /// for a too-young window, and inventing one is not. + @ViewBuilder + private func paceCaption(_ line: QuotaPacePresentation.Line?) -> some View { + if let line { + let color: Color = switch line.tone { + case .danger: .red.opacity(0.92) + case .warning: .orange.opacity(0.9) + case .neutral: Color.capacityDockText.opacity(0.45) + } + Text(line.text) + .font(.system(size: 9.5, weight: .medium)) + .monospacedDigit() + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.8) + // The full caption remains in the tooltip/accessibility tree; + // middle truncation retains both the estimate kind and its + // useful endpoint when a future caption grows longer. + .truncationMode(.middle) + .help(line.helpText) + .accessibilityElement(children: .ignore) + .accessibilityLabel(line.text) + .accessibilityHint(line.helpText) + } + } + /// No quota window exists for this provider, so money is the capacity. @ViewBuilder private func budgetLine() -> some View { diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift index 60834bf1..4a3c7ce6 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift @@ -2,8 +2,13 @@ import Foundation import Testing @testable import CodeBurnMenubar -private func window(_ label: String, _ percent: Double, resetsAt: Date? = nil) -> QuotaSummary.Window { - QuotaSummary.Window(label: label, percent: percent, resetsAt: resetsAt) +private func window( + _ label: String, + _ percent: Double, + resetsAt: Date? = nil, + windowSeconds: Int? = nil +) -> QuotaSummary.Window { + QuotaSummary.Window(label: label, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) } private func quota( @@ -80,6 +85,75 @@ struct CapacityDockGlanceTests { #expect(CapacityDockGlance.windows(quota([])).isEmpty) } + @Test("Pace slots reserve height only for connected windows with a validated duration") + func paceSlotReservedByDuration() { + let resetsAt = Date().addingTimeInterval(3 * 24 * 3600) + let withoutDuration = [window("5-hour", 0.2, resetsAt: resetsAt), window("Weekly", 0.5, resetsAt: resetsAt)] + let withDuration = [ + window("5-hour", 0.2, resetsAt: resetsAt, windowSeconds: QuotaPacePresentation.claudeFiveHourSeconds), + window("Weekly", 0.5, resetsAt: resetsAt, windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds), + ] + #expect(!CapacityDockGlance.drawsPace(quota(withoutDuration))) + #expect(CapacityDockGlance.drawsPace(quota(withDuration))) + // Missing duration still draws the plain row. + #expect(CapacityDockGlance.windowsHeight(for: quota(withoutDuration)) == CapacityDockGlance.windowsHeight) + let step = CapacityDockGlance.paceLineGap + CapacityDockGlance.paceLineHeight + #expect(CapacityDockGlance.windowsHeight(for: quota(withDuration)) == CapacityDockGlance.windowsHeight + step) + // Stale data carries the durations but no defensible estimate, so the + // slot is not reserved and the panel is shorter. + #expect(!CapacityDockGlance.drawsPace(quota(withDuration, connection: .stale))) + // The computed panel height reflects the reserved slot. + func detailHeight(_ windows: [QuotaSummary.Window], connection: QuotaSummary.Connection) -> CGFloat { + CapacityDockMetrics.detailHeight( + quota: quota(windows, connection: connection), + sessionCount: nil, + hasToday: false, + tailEdge: .right, + scale: 1 + ) + } + #expect(detailHeight(withDuration, connection: .connected) - detailHeight(withoutDuration, connection: .connected) == step) + for scale in [0.9, 1.0, 1.15, 1.25, 1.4] { + let h = CapacityDockMetrics.detailHeight( + quota: quota(withDuration), + sessionCount: 2, + hasToday: true, + tailEdge: .bottom, + scale: CGFloat(scale) + ) + #expect(h == h.rounded()) + } + } + + @Test("Three or four windows use two constrained columns and two rows") + func multiWindowGridGeometry() { + let plainThree = quota([ + window("5-hour", 0.2), + window("Weekly · Opus", 0.5), + window("Weekly · Sonnet", 0.7), + ]) + let plainFour = quota([ + window("5-hour", 0.2), + window("Weekly", 0.5), + window("Weekly · Opus", 0.7), + window("Weekly · Sonnet", 0.9), + ]) + #expect(CapacityDockGlance.windowColumnCount(for: 1) == 1) + #expect(CapacityDockGlance.windowColumnCount(for: 2) == 2) + #expect(CapacityDockGlance.windowColumnCount(for: 3) == 2) + #expect(CapacityDockGlance.windowColumnCount(for: 4) == 2) + #expect(CapacityDockGlance.windowRowCount(for: 1) == 1) + #expect(CapacityDockGlance.windowRowCount(for: 2) == 1) + #expect(CapacityDockGlance.windowRowCount(for: 3) == 2) + #expect(CapacityDockGlance.windowRowCount(for: 4) == 2) + #expect(abs(CapacityDockGlance.windowsGridHeight(for: plainThree) - (2 * 53 + 6)) < 0.001) + #expect(abs(CapacityDockGlance.windowsHeight(for: plainThree) - (8 + 2 * 53 + 6 + 16)) < 0.001) + #expect(CapacityDockGlance.windowsHeight(for: plainFour) == CapacityDockGlance.windowsHeight(for: plainThree)) + // One and two windows retain the original compact one-row geometry. + #expect(CapacityDockGlance.windowsHeight(for: quota([window("Weekly", 0.5)])) == CapacityDockGlance.windowsHeight) + #expect(CapacityDockGlance.windowsHeight(for: quota([window("5-hour", 0.2), window("Weekly", 0.5)])) == CapacityDockGlance.windowsHeight) + } + @Test("The pill tint ramps green, yellow, orange, red at 70, 80 and 90 percent") func severityRamp() { #expect(CapacityDockGlance.severityColor(0.0) == .green) @@ -206,10 +280,10 @@ struct CapacityDockGlanceTests { full == CapacityDockGlance.headerHeight + CapacityDockGlance.sessionsHeight(count: 1) + CapacityDockGlance.todayHeight - + CapacityDockGlance.windowsHeight + + CapacityDockGlance.windowsHeight(for: quota(three)) ) - // 44 header + 83 sessions + 81 today + 77 windows - #expect(full == 285) + // 44 header + 83 sessions + 97 today + 136 two-row windows grid. + #expect(full == 360) // The panel opens and closes on the same 16pt inset it uses sideways. let headerParts: CGFloat = CapacityDockGlance.contentInset + 20 + 8 #expect(CapacityDockGlance.headerHeight == headerParts) @@ -217,22 +291,34 @@ struct CapacityDockGlanceTests { CapacityDockGlance.windowsHeight == CapacityDockGlance.sectionPadTop + 53 + CapacityDockGlance.contentInset ) - // Today is three stacked lines (13 + 3 + 13 + 3 + 12) inside its padding. - #expect(CapacityDockGlance.todayContentHeight == 44) - #expect(CapacityDockGlance.todayHeight == 81) + // Today is four stacked lines (13 + 3 + 13 + 3 + 13 + 3 + 12) inside its + // padding: the cache-read line joined the input/output pair, and the row + // keeps one fixed height whether or not that line draws. + #expect(CapacityDockGlance.todayContentHeight == 60) + #expect(CapacityDockGlance.todayHeight == 97) // Past four sessions the list scrolls, so the panel stops growing. let capped = height(4, hasToday: true, windows: three) #expect(height(12, hasToday: true, windows: three) == capped) - #expect(capped == 44 + CapacityDockGlance.sessionsHeight(count: 4) + 81 + 77) + #expect( + capped == 44 + + CapacityDockGlance.sessionsHeight(count: 4) + + 97 + + CapacityDockGlance.windowsHeight(for: quota(three)) + ) // Each section is independently droppable. #expect(full - height(nil, hasToday: true, windows: three) == CapacityDockGlance.sessionsHeight(count: 1)) #expect(full - height(1, hasToday: false, windows: three) == CapacityDockGlance.todayHeight) #expect( height(1, hasToday: true, windows: []) - full - == CapacityDockGlance.windowsEmptyHeight - CapacityDockGlance.windowsHeight + == CapacityDockGlance.windowsEmptyHeight - CapacityDockGlance.windowsHeight(for: quota(three)) + ) + // One and two columns stay on the compact row; three windows need the + // second grid row so every scope remains visible. + #expect(height(1, hasToday: true, windows: [three[0]]) < full) + #expect( + height(1, hasToday: true, windows: [three[0], three[1]]) + == height(1, hasToday: true, windows: [three[0]]) ) - // Column count does not change the row's height. - #expect(height(1, hasToday: true, windows: [three[0]]) == full) } @Test("A vertical tail adds its allowance to the panel height") @@ -255,7 +341,12 @@ struct CapacityDockGlanceTests { func heightIsAlwaysWhole() { for scale in [0.9, 1.0, 1.15, 1.25, 1.4] { let height = CapacityDockMetrics.detailHeight( - quota: quota([window("5-hour", 0.2), window("Weekly", 0.5)]), + quota: quota([ + window("5-hour", 0.2), + window("Weekly", 0.5), + window("Weekly · Opus", 0.7), + window("Weekly · Sonnet", 0.9), + ]), sessionCount: 4, hasToday: true, tailEdge: .bottom, diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift new file mode 100644 index 00000000..c6f20818 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift @@ -0,0 +1,259 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +/// Fixtures for the Capacity Dock pace caption. These drive the production +/// `QuotaPacePresentation` path that the window columns render, not helper +/// arithmetic: every assertion names the exact caption string the view will +/// draw, which is also what pins the fraction/percent unit crossing. +private struct Fixture { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let week = 7 * 24 * 3600 + let fiveHours = 5 * 3600 + + /// resetsAt such that `fraction` of the window has elapsed at `now`. + func resets(afterElapsedFraction fraction: Double, windowSeconds: Int) -> Date { + now.addingTimeInterval(TimeInterval(windowSeconds) * (1 - fraction)) + } + + func window( + _ label: String, + _ percent: Double, + resetsAt: Date, + windowSeconds: Int + ) -> QuotaSummary.Window { + QuotaSummary.Window( + label: label, + percent: percent, + resetsAt: resetsAt, + windowSeconds: windowSeconds, + fetchedAt: now + ) + } + + func line( + percent: Double, + elapsedFraction: Double, + windowSeconds: Int, + connection: QuotaSummary.Connection = .connected + ) -> QuotaPacePresentation.Line? { + QuotaPacePresentation.line( + for: window("Weekly", percent, resetsAt: resets(afterElapsedFraction: elapsedFraction, windowSeconds: windowSeconds), windowSeconds: windowSeconds), + connection: connection, + now: now + ) + } +} + +@Suite("Capacity Dock quota pace caption") +struct CapacityDockPacePresentationTests { + private let f = Fixture() + + @Test("A window fraction is consumed as percent, not as a raw 0..1 value") + func fractionBecomesPercent() { + // 0.5 fraction at halfway through the week is 50% used — on pace. + let line = f.line(percent: 0.5, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(line?.kind == .estimate) + #expect(line?.text == "est. 100% at reset") + #expect(line?.tone == .neutral) + } + + @Test("Ahead of pace with a projected overflow names an exhaustion ETA from now") + func deficitProjectsEarlyExhaustion() { + // used 60%, expected 40% -> projected 150%; the limit is hit 44h 48m + // from `now`, while the reset itself is still 4d 4h away. + let line = f.line(percent: 0.6, elapsedFraction: 0.4, windowSeconds: f.week) + #expect(line?.kind == .estimate) + #expect(line?.text == "est. out in 1d 20h") + #expect(line?.tone == .warning) + #expect(!(line?.text.contains("early") ?? false)) + } + + @Test("The ETA is now-to-limit, which differs from now-to-reset") + func etaIsNowToLimitNotLeadBeforeReset() { + let hitsLimitAt = f.now.addingTimeInterval(44 * 3600 + 48 * 60) // 44h 48m + let resetsAt = f.resets(afterElapsedFraction: 0.4, windowSeconds: f.week) + let nowToLimit = QuotaPacePresentation.countdownLabel(from: f.now, to: hitsLimitAt) + let nowToReset = QuotaPacePresentation.countdownLabel(from: f.now, to: resetsAt) + // The two intervals differ, so the caption must state the ETA from now, + // not a "lead before reset" that would come out as reset-minus-limit. + #expect(nowToLimit == "1d 20h") + #expect(nowToReset == "4d 4h") + #expect(nowToLimit != nowToReset) + } + + @Test("Behind pace stays in reserve with a projection, no alarm") + func reserveStaysNeutral() { + let line = f.line(percent: 0.2, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(line?.text == "est. 40% at reset") + #expect(line?.tone == .neutral) + } + + @Test("A window younger than 3% elapsed gets no estimate") + func earlyWindowIsSilent() { + #expect(f.line(percent: 0.5, elapsedFraction: 0.01, windowSeconds: f.week) == nil) + } + + @Test("No usage yet reads as a zero projection, not as zero-signal") + func noUsageYet() { + let line = f.line(percent: 0.0, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(line?.text == "est. 0% at reset") + #expect(line?.tone == .neutral) + } + + @Test("A fully used window is exhausted, and a past reset is stale") + func exhaustedState() { + let reached = f.line(percent: 1.0, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(reached?.kind == .exhausted) + #expect(reached?.text == "limit reached") + #expect(reached?.tone == .danger) + let window = f.window( + "Weekly", 1.0, + resetsAt: f.now.addingTimeInterval(-100), + windowSeconds: f.week + ) + #expect(QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) == nil) + } + + @Test("Short windows show stage only, never a burst ETA") + func shortWindowSuppressesETA() { + let line = f.line(percent: 0.9, elapsedFraction: 0.5, windowSeconds: f.fiveHours) + #expect(line?.kind == .estimate) + #expect(line?.text == "40% in deficit") + #expect(line?.tone == .warning) + #expect(!(line?.text.contains("est.") ?? false)) + } + + @Test("Stale, failed and disconnected data get nothing") + func nonConnectedDataIsSilent() { + for connection: QuotaSummary.Connection in [.stale, .loading, .transientFailure, .disconnected] { + #expect(f.line(percent: 0.6, elapsedFraction: 0.4, windowSeconds: f.week, connection: connection) == nil) + } + } + + @Test("An old connected sample gets no pace caption") + func oldConnectedSampleIsSilent() { + let old = f.window( + "Weekly", + 0.6, + resetsAt: f.resets(afterElapsedFraction: 0.4, windowSeconds: f.week), + windowSeconds: f.week + ) + let stale = QuotaSummary.Window( + label: old.label, + percent: old.percent, + resetsAt: old.resetsAt, + windowSeconds: old.windowSeconds, + fetchedAt: f.now.addingTimeInterval(-QuotaSummary.freshnessThreshold - 1) + ) + #expect(QuotaPacePresentation.line(for: stale, connection: .connected, now: f.now) == nil) + } + + @Test("Negative, over-one, non-finite and missing metadata get nothing") + func invalidInputsAreSilent() { + // A negative fraction must be rejected, not clamped into a healthy 0%. + #expect(f.line(percent: -0.1, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + // Over 100% is not a "limit reached" signal — it is a broken sample. + #expect(f.line(percent: 1.2, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + #expect(f.line(percent: .nan, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + #expect(f.line(percent: .infinity, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + #expect(f.line(percent: 0.5, elapsedFraction: 0.5, windowSeconds: 0) == nil) + #expect(f.line(percent: 0.5, elapsedFraction: 0.5, windowSeconds: -100) == nil) + + let noDuration = QuotaSummary.Window( + label: "Weekly", + percent: 0.5, + resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), + windowSeconds: nil, + fetchedAt: f.now + ) + #expect(QuotaPacePresentation.line(for: noDuration, connection: .connected, now: f.now) == nil) + + let noReset = QuotaSummary.Window( + label: "Weekly", + percent: 0.5, + resetsAt: nil, + windowSeconds: f.week, + fetchedAt: f.now + ) + #expect(QuotaPacePresentation.line(for: noReset, connection: .connected, now: f.now) == nil) + } + + @Test("Clock skew in either direction gets nothing") + func clockSkewIsSilent() { + let tooFar = f.window("Weekly", 0.5, resetsAt: f.now.addingTimeInterval(TimeInterval(f.week + 100)), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: tooFar, connection: .connected, now: f.now) == nil) + let elapsed = f.window("Weekly", 0.5, resetsAt: f.now.addingTimeInterval(-1), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: elapsed, connection: .connected, now: f.now) == nil) + } + + @Test("An impossible exhausted window (reset far past the duration) is rejected") + func impossibleExhaustedIsSilent() { + // 100% used, but the reset is thirty days out on a weekly window: + // that is clock/data skew, not an actually-exhausted limit. + let window = f.window("Weekly", 1.0, resetsAt: f.now.addingTimeInterval(30 * 24 * 3600), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) == nil) + } + + @Test("A non-finite reset timestamp is rejected before any branch") + func nonFiniteResetIsSilent() { + let window = f.window("Weekly", 1.0, resetsAt: Date(timeIntervalSinceReferenceDate: .infinity), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) == nil) + } + + @Test("A mislabeled duration is used as given, not re-inferred from the label") + func durationIsUsedAsGiven() { + let thirtyDays = 30 * 24 * 3600 + let window = f.window( + "Weekly", 0.9, + resetsAt: f.now.addingTimeInterval(2 * 24 * 3600), + windowSeconds: thirtyDays + ) + let line = QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) + #expect(line?.kind == .estimate) + #expect(line?.text == "est. 96% at reset") + } + + @Test("Distinct scopes sharing a duration each keep their own caption") + func distinctScopesAreNotSuppressed() { + // A healthy aggregate Weekly and an exhausted Weekly · Opus share the + // same 7-day duration; the healthy window must not hide the exhausted + // one. + let weekly = f.window("Weekly", 0.5, resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), windowSeconds: f.week) + let opus = f.window("Weekly · Opus", 1.0, resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), windowSeconds: f.week) + let lines = QuotaPacePresentation.lines(for: [weekly, opus], connection: .connected, now: f.now) + #expect(lines[0]?.text == "est. 100% at reset") + #expect(lines[1]?.text == "limit reached") + } + + @Test("Same-duration windows with different reset dates both keep captions") + func sameDurationDifferentResets() { + let a = f.window("Limit A", 0.5, resetsAt: f.now.addingTimeInterval(3 * 24 * 3600), windowSeconds: f.week) + let b = f.window("Limit B", 0.9, resetsAt: f.now.addingTimeInterval(2 * 24 * 3600), windowSeconds: f.week) + let lines = QuotaPacePresentation.lines(for: [a, b], connection: .connected, now: f.now) + #expect(lines[0] != nil) + #expect(lines[1] != nil) + } + + @Test("Only a genuinely identical duplicate window is suppressed") + func exactDuplicatesAreSuppressed() { + let resetsAt = f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week) + let a = f.window("Weekly", 0.5, resetsAt: resetsAt, windowSeconds: f.week) + let duplicate = f.window("Weekly", 0.5, resetsAt: resetsAt, windowSeconds: f.week) + let lines = QuotaPacePresentation.lines(for: [a, duplicate], connection: .connected, now: f.now) + #expect(lines[0] != nil) + #expect(lines[1] == nil) + } + + @Test("The panel reserves a slot on metadata, independent of wall-clock time") + func reservationIsMetadataOnly() { + let resetsAt = f.now.addingTimeInterval(3 * 24 * 3600) + let eligible = [QuotaSummary.Window(label: "Weekly", percent: 0.5, resetsAt: resetsAt, windowSeconds: f.week, fetchedAt: f.now)] + let noDuration = [QuotaSummary.Window(label: "Weekly", percent: 0.5, resetsAt: resetsAt, fetchedAt: f.now)] + let noReset = [QuotaSummary.Window(label: "Weekly", percent: 0.5, resetsAt: nil, windowSeconds: f.week, fetchedAt: f.now)] + #expect(QuotaPacePresentation.reservesLine(for: eligible, connection: .connected)) + #expect(!QuotaPacePresentation.reservesLine(for: noDuration, connection: .connected)) + #expect(!QuotaPacePresentation.reservesLine(for: noReset, connection: .connected)) + #expect(!QuotaPacePresentation.reservesLine(for: eligible, connection: .stale)) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift index 2573755b..9001dd28 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift @@ -181,6 +181,106 @@ struct CapacityDockTodayTests { #expect(row?.outputTokens == nil) } + @Test("Cache read is the hovered provider's own figure, not the machine's") + func cacheReadIsProviderScoped() { + let store = store(todayPayload( + cost: 278.94, + calls: 1_542, + inputTokens: 9_000_000, + outputTokens: 400_000, + providerDetails: [ + ProviderDetail(id: "claude", label: "Claude", cost: 190.10, calls: 900, + hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, + sessions: 12, cacheReadTokens: 4_200_000), + ProviderDetail(id: "codex", label: "Codex", cost: 88.84, calls: 642, + hasUsage: true, inputTokens: 3_000_000, outputTokens: 150_000, + sessions: 4, cacheReadTokens: 120_000), + ] + )) + #expect(store.capacityDockToday(for: .claude)?.cacheReadTokens == 4_200_000) + #expect(store.capacityDockToday(for: .codex)?.cacheReadTokens == 120_000) + } + + @Test("A tile spanning several rows sums cache read, absent until a row reports it") + func combinedTileSumsCacheRead() { + let cursor = CapacityDockProvider(rawValue: "cursor")! + let sums = store(todayPayload( + cost: 1, + calls: 4, + inputTokens: 0, + outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 3_000), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 40_000), + ] + )) + #expect(sums.capacityDockToday(for: cursor)?.cacheReadTokens == 43_000) + + // Neither row reports cache read -> unknown, not a fabricated zero. + let absent = store(todayPayload( + cost: 1, calls: 4, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0.5, calls: 2, hasUsage: true), + ] + )) + #expect(absent.capacityDockToday(for: cursor)?.cacheReadTokens == nil) + } + + @Test("Known zero cache read stays zero; a legacy row stays unknown") + func cacheReadZeroVersusMissing() { + let store = store(todayPayload( + cost: 1, calls: 4, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "claude", label: "Claude", cost: 1, calls: 4, hasUsage: true, + cacheReadTokens: 0), + ] + )) + #expect(store.capacityDockToday(for: .claude)?.cacheReadTokens == 0) + } + + @Test("A partial cache-read sum is unknown, not a complete-looking total") + func partialCacheReadSumStaysUnknown() { + // One tile row reports cache read, the other is an active row from a + // CLI that predates the field: the tile must report none rather than + // present the one known row's figure as the whole account's total. + let cursor = CapacityDockProvider(rawValue: "cursor")! + let partial = store(todayPayload( + cost: 1, calls: 6, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 3_000), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0.5, calls: 4, hasUsage: true), + ] + )) + #expect(partial.capacityDockToday(for: cursor)?.cacheReadTokens == nil) + // An idle row (no usage) does not force unknown: its cache read is a + // genuine zero. + let idle = store(todayPayload( + cost: 0.5, calls: 2, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 3_000), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0, calls: 0, hasUsage: false), + ] + )) + #expect(idle.capacityDockToday(for: cursor)?.cacheReadTokens == 3_000) + } + + @Test("Large cache counts survive without overflow") + func cacheReadLargeCounts() { + let store = store(todayPayload( + cost: 1, calls: 4, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "claude", label: "Claude", cost: 1, calls: 4, hasUsage: true, + cacheReadTokens: 2_147_483_647), + ] + )) + #expect(store.capacityDockToday(for: .claude)?.cacheReadTokens == 2_147_483_647) + } + @Test("Kimi Code reads its CLI row rather than the CLI's separate kimi provider") func dockIDMapsToTheCLIProviderID() { #expect(CapacityDockProvider.kimiCode.payloadProviderID == "kimicode") @@ -295,6 +395,7 @@ struct CapacityDockTodayTests { #expect(old.inputTokens == nil) #expect(old.outputTokens == nil) #expect(old.sessions == nil) + #expect(old.cacheReadTokens == nil) let current = """ {"id":"claude","label":"Claude","cost":190.1,"calls":900,"hasUsage":true, @@ -304,5 +405,12 @@ struct CapacityDockTodayTests { #expect(new.inputTokens == 6_000_000) #expect(new.outputTokens == 250_000) #expect(new.sessions == 12) + + let withCache = """ + {"id":"claude","label":"Claude","cost":190.1,"calls":900,"hasUsage":true, + "inputTokens":6000000,"outputTokens":250000,"sessions":12,"cacheReadTokens":4200000} + """ + let cached = try JSONDecoder().decode(ProviderDetail.self, from: Data(withCache.utf8)) + #expect(cached.cacheReadTokens == 4_200_000) } } diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaFreshnessTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaFreshnessTests.swift new file mode 100644 index 00000000..08dcd6e6 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/QuotaFreshnessTests.swift @@ -0,0 +1,216 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private let quotaFreshnessNow = Date(timeIntervalSince1970: 1_900_000_000) + +private func claudeUsage(fetchedAt: Date) -> SubscriptionUsage { + SubscriptionUsage( + tier: .pro, + rawTier: "pro", + fiveHourPercent: 40, + fiveHourResetsAt: quotaFreshnessNow.addingTimeInterval(4 * 3600), + sevenDayPercent: 20, + sevenDayResetsAt: quotaFreshnessNow.addingTimeInterval(6 * 24 * 3600), + sevenDayOpusPercent: nil, + sevenDayOpusResetsAt: nil, + sevenDaySonnetPercent: nil, + sevenDaySonnetResetsAt: nil, + scopedWeekly: [], + fetchedAt: fetchedAt + ) +} + +private func codexUsage(fetchedAt: Date) -> CodexUsage { + CodexUsage( + plan: .plus, + primary: CodexUsage.Window( + usedPercent: 40, + resetsAt: quotaFreshnessNow.addingTimeInterval(4 * 3600), + limitWindowSeconds: 5 * 3600 + ), + secondary: nil, + additionalLimits: [], + creditsBalance: nil, + hasCredits: false, + creditsUnlimited: false, + creditLimit: nil, + resetCredits: nil, + fetchedAt: fetchedAt + ) +} + +private actor CodexRefreshGate { + private var waiters: [CheckedContinuation] = [] + private var isClosed = false + + var waiterCount: Int { waiters.count } + + func wait() async throws -> CodexUsage? { + if isClosed { return nil } + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if isClosed { + continuation.resume(returning: nil) + } else { + waiters.append(continuation) + } + } + } + + func release(_ result: Result) { + guard !waiters.isEmpty else { return } + waiters.removeFirst().resume(with: result) + } + + func releaseAll() { + while !waiters.isEmpty { + waiters.removeFirst().resume(returning: nil) + } + } + + func close() { + isClosed = true + releaseAll() + } +} + +private func waitForWaiterCount(_ gate: CodexRefreshGate, _ expected: Int) async -> Bool { + // A full SwiftPM run schedules this suite alongside credential and process + // tests that can briefly occupy the main actor. Give the controlled fetch + // a bounded scheduler window instead of relying on a tiny yield count. + for _ in 0..<1_000 { + if await gate.waiterCount == expected { return true } + try? await Task.sleep(for: .milliseconds(1)) + } + return await gate.waiterCount == expected +} + +@Suite("Capacity Dock quota freshness", .serialized) +@MainActor +struct QuotaFreshnessTests { + @Test("freshness accepts the established ten-minute boundary and rejects older or future samples") + func freshnessBoundary() { + let boundary = quotaFreshnessNow.addingTimeInterval(-QuotaSummary.freshnessThreshold) + #expect(QuotaSummary.isFresh(fetchedAt: boundary, now: quotaFreshnessNow)) + #expect(!QuotaSummary.isFresh( + fetchedAt: boundary.addingTimeInterval(-0.1), + now: quotaFreshnessNow + )) + #expect(!QuotaSummary.isFresh( + fetchedAt: quotaFreshnessNow.addingTimeInterval(1), + now: quotaFreshnessNow + )) + #expect(!QuotaSummary.isFresh(fetchedAt: nil, now: quotaFreshnessNow)) + } + + @Test("window metadata preserves sample age for the pace presentation") + func windowCarriesFreshness() { + let fetchedAt = quotaFreshnessNow.addingTimeInterval(-60) + let window = QuotaSummary.Window( + label: "Weekly", + percent: 0.4, + resetsAt: quotaFreshnessNow.addingTimeInterval(6 * 24 * 3600), + windowSeconds: 7 * 24 * 3600, + fetchedAt: fetchedAt + ) + #expect(window.isFresh(at: quotaFreshnessNow)) + #expect(!window.isFresh(at: quotaFreshnessNow.addingTimeInterval(QuotaSummary.freshnessThreshold + 1))) + #expect(!QuotaSummary.Window( + label: "Legacy", + percent: 0.4, + resetsAt: window.resetsAt, + windowSeconds: window.windowSeconds + ).isFresh(at: quotaFreshnessNow)) + } + + @Test("Claude loaded data becomes stale when its sample ages past the pace horizon") + func staleClaudeSummaryDoesNotLookConnected() { + let store = AppStore() + let now = Date() + let fetchedAt = now.addingTimeInterval(-QuotaSummary.freshnessThreshold - 1) + store.subscription = claudeUsage(fetchedAt: fetchedAt) + store.subscriptionLoadState = .loaded + + let summary = store.quotaSummary(for: .claude) + #expect(summary?.connection == .stale) + #expect(summary?.details.first?.fetchedAt == fetchedAt) + #expect(summary?.details.first?.isFresh(at: now) == false) + } + + @Test("Codex loaded data becomes stale when its sample ages past the pace horizon") + func staleCodexSummaryDoesNotLookConnected() { + let store = AppStore() + let now = Date() + let fetchedAt = now.addingTimeInterval(-QuotaSummary.freshnessThreshold - 1) + store.codexUsage = codexUsage(fetchedAt: fetchedAt) + store.codexLoadState = .loaded + + let summary = store.quotaSummary(for: .codex) + #expect(summary?.connection == .stale) + #expect(summary?.details.first?.fetchedAt == fetchedAt) + #expect(summary?.details.first?.isFresh(at: now) == false) + } + + @Test("fresh loaded samples remain connected") + func freshSummariesRemainConnected() { + let store = AppStore() + store.subscription = claudeUsage(fetchedAt: Date()) + store.subscriptionLoadState = .loaded + #expect(store.quotaSummary(for: .claude)?.connection == .connected) + + store.codexUsage = codexUsage(fetchedAt: Date()) + store.codexLoadState = .loaded + #expect(store.quotaSummary(for: .codex)?.connection == .connected) + } + + @Test("overlapping refreshes keep the newer loading state until it finishes") + func overlappingRefreshesDoNotRestoreAnOlderState() async { + let gate = CodexRefreshGate() + let store = AppStore() + store.codexUsage = codexUsage(fetchedAt: Date()) + store.codexLoadState = .loaded + store.codexQuotaBootstrapChecker = { true } + store.codexQuotaFetcher = { try await gate.wait() } + + let first = Task { await store.refreshCodexReportingSuccess() } + guard await waitForWaiterCount(gate, 1) else { + await gate.close() + _ = await first.value + #expect(Bool(false), "first refresh did not enter the controlled fetch") + return + } + #expect(store.codexLoadState == .loading) + + let second = Task { await store.refreshCodexReportingSuccess() } + guard await waitForWaiterCount(gate, 2) else { + await gate.close() + _ = await first.value + _ = await second.value + #expect(Bool(false), "second refresh did not enter the controlled fetch") + return + } + + // The superseded request must not restore `.loaded` while request 2 is + // still waiting. The current request's nil result restores the state + // that was present before the refresh pair began. + await gate.release(.success(nil)) + #expect(await first.value == false) + #expect(store.codexLoadState == .loading) + await gate.release(.success(nil)) + #expect(await second.value == false) + #expect(store.codexLoadState == .loaded) + await gate.close() + } + + @Test("a cancelled refresh restores the prior state instead of reporting failure") + func cancelledRefreshRestoresPriorState() async { + let store = AppStore() + store.codexUsage = codexUsage(fetchedAt: Date()) + store.codexLoadState = .loaded + store.codexQuotaBootstrapChecker = { true } + store.codexQuotaFetcher = { throw CancellationError() } + + #expect(await store.refreshCodexReportingSuccess() == false) + #expect(store.codexLoadState == .loaded) + } +} diff --git a/src/main.ts b/src/main.ts index 6c6a11f3..d200c39c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,8 @@ import { getProvider } from './providers/index.js' import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { convertCost, formatCost } from './currency.js' import { renderStatusBar } from './format.js' -import { DAILY_CACHE_VERSION, toDateString } from './daily-cache.js' +import { toDateString } from './daily-cache.js' +import { statusSnapshotSemanticKey } from './status-snapshot-semantic.js' import { dateKey } from './day-aggregator.js' import { sessionModelBillableOutputTokens } from './session-output.js' import { isBehavioralCall } from './behavioral-weight.js' @@ -52,14 +53,10 @@ import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const { version } = require('../package.json') -// Bump when the menubar payload's rendering semantics change without a package -// release or daily-cache version change. The envelope version in session-cache -// protects record shape; this protects the meaning of an otherwise valid one. -// v5: providerDetails carries per-provider tokens and sessions, which a v4 -// record predates — the dock glance would read a provider as having no token -// breakdown purely because the snapshot was written before this build. -const STATUS_SNAPSHOT_RENDER_VERSION = 5 -const STATUS_SNAPSHOT_SEMANTIC_KEY = `${version}:render-${STATUS_SNAPSHOT_RENDER_VERSION}:daily-${DAILY_CACHE_VERSION}` +// The snapshot semantic revision + key live in their own module so the CLI's +// snapshot read/write path and its regression tests agree on the same value +// without importing the CLI entry point (which parses argv as a side effect). +const STATUS_SNAPSHOT_SEMANTIC_KEY = statusSnapshotSemanticKey(version) import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js' diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 273e9d9a..8fadb274 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -91,6 +91,15 @@ export type ProviderCost = { outputTokens?: number /** Provider-scoped session count for the period, absent under the same rule. */ sessions?: number + /** Provider-scoped prompt-cache read tokens for the period, absent under the + * same rule: no day in the period reported cache reads for this provider, + * so a consumer must render unknown rather than zero. Distinct from fresh + * input (never double-counted into it) and priced inside `cost`. */ + cacheReadTokens?: number + /** Internal accounting flag, never emitted: true when some active day slice + * lacked the cache field, so `cacheReadTokens` is a partial sum that must + * be dropped rather than labelled complete. */ + cacheReadIncomplete?: boolean } import type { OptimizeResult } from './optimize.js' import { getCurrency } from './currency.js' @@ -283,10 +292,10 @@ export type MenubarPayload = { /// provider name (round-trips as `--provider`), `label` the display name, /// and `hasUsage` the period-activity signal used by provider pickers. /// The `providers` map keys stay lowercased display names for compatibility. - /// `inputTokens`, `outputTokens` and `sessions` are add-only and optional: - /// they are omitted when the period carries no per-provider breakdown for - /// them, so a consumer must render the absence rather than substitute a - /// period-wide figure. + /// `inputTokens`, `outputTokens`, `sessions` and `cacheReadTokens` are + /// add-only and optional: they are omitted when the period carries no + /// per-provider breakdown for them, so a consumer must render the absence + /// rather than substitute a period-wide figure. providerDetails: Array<{ id: string label: string @@ -296,6 +305,7 @@ export type MenubarPayload = { inputTokens?: number outputTokens?: number sessions?: number + cacheReadTokens?: number }> topProjects: Array<{ name: string @@ -513,6 +523,7 @@ function buildProviderDetails(providers: ProviderCost[]): MenubarPayload['curren ...(p.inputTokens === undefined ? {} : { inputTokens: p.inputTokens }), ...(p.outputTokens === undefined ? {} : { outputTokens: p.outputTokens }), ...(p.sessions === undefined ? {} : { sessions: p.sessions }), + ...(p.cacheReadTokens === undefined || p.cacheReadIncomplete ? {} : { cacheReadTokens: p.cacheReadTokens }), })) } diff --git a/src/status-snapshot-semantic.ts b/src/status-snapshot-semantic.ts new file mode 100644 index 00000000..796ebf2c --- /dev/null +++ b/src/status-snapshot-semantic.ts @@ -0,0 +1,27 @@ +import { DAILY_CACHE_VERSION } from './daily-cache.js' + +/// Bump when the menubar payload's rendering semantics change without a +/// package release or daily-cache version change. The envelope version in +/// session-cache protects record shape; this protects the meaning of an +/// otherwise valid one. Each revision must be distinct from every OTHER +/// branch's revision: a snapshot written by a different change must not be +/// accepted here while lacking this change's fields. +/// v5: providerDetails carries per-provider tokens and sessions, which a v4 +/// record predates — the dock glance would read a provider as having no +/// token breakdown purely because the snapshot was written before that. +/// v6: taken by PR1265 (per-model counts). +/// v7: providerDetails also carries per-provider cacheReadTokens, which a v6 +/// record predates — the dock's cache-read row would stay hidden behind a +/// warm snapshot even once the live payload had the data. +export const STATUS_SNAPSHOT_RENDER_VERSION = 7 + +/// The semantic key recorded on every status snapshot. A snapshot whose stored +/// key differs (an older render revision, or a different daily-cache version) +/// is rejected by `loadStatusSnapshot` and recomputed exactly once, then +/// reused stably under the new key. +export function statusSnapshotSemanticKey( + version: string, + renderVersion: number = STATUS_SNAPSHOT_RENDER_VERSION, +): string { + return `${version}:render-${renderVersion}:daily-${DAILY_CACHE_VERSION}` +} diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index ff273f23..2738429c 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -31,6 +31,12 @@ export type ProviderSliceTotal = { inputTokens?: number outputTokens?: number sessions?: number + cacheReadTokens?: number + /// True when at least one active day slice in this fold carried no + /// per-provider cache accounting (a day finalized before the field existed). + /// The caller uses it to keep the period total UNKNOWN rather than a partial + /// known sum: an incomplete total must not masquerade as a complete one. + cacheReadIncomplete?: boolean } /// Folds one day's provider slice into the period total. Tokens and sessions are @@ -45,6 +51,15 @@ export function addProviderSlice(totals: Record, nam if (slice.inputTokens !== undefined) total.inputTokens = (total.inputTokens ?? 0) + slice.inputTokens if (slice.outputTokens !== undefined) total.outputTokens = (total.outputTokens ?? 0) + slice.outputTokens if (slice.sessions !== undefined) total.sessions = (total.sessions ?? 0) + slice.sessions + if (slice.cacheReadTokens !== undefined) { + total.cacheReadTokens = (total.cacheReadTokens ?? 0) + slice.cacheReadTokens + } else if (providerSliceHasUsage(slice)) { + // An active day recorded before per-provider cache accounting has no + // cache read to contribute; an idle day (no usage) is a genuine zero and + // leaves the total alone. Marking incomplete here lets the consumer drop + // the partial sum rather than label it complete. + total.cacheReadIncomplete = true + } totals[name] = total } @@ -59,6 +74,37 @@ export function providerSliceHasUsage(slice: ProviderDaySlice): boolean { || (slice.cacheWriteTokens ?? 0) > 0 } +/// Preserve the optional cache-read contract when a provider-scoped durable +/// query projects a day down to one provider. `sliceDayToProvider` keeps the +/// original provider slice under `day.providers`, while the day-level numeric +/// fields use zero-compatible legacy defaults for the older aggregates. The +/// provider detail must inspect that slice directly or an active legacy row +/// with no cache field would become a fabricated known zero. +function cacheReadForProviderDays(days: DailyEntry[], provider: string): Pick { + let cacheReadTokens = 0 + let hasCacheReadValue = false + let cacheReadIncomplete = false + for (const day of days) { + const slice = day.providers[provider] + if (!slice) continue + // An explicit zero is a real known value even when the rest of the slice + // is idle (for example, a configured provider with a finalized zero row). + // Check field presence before the activity predicate so scoped queries do + // not turn that contract value into unknown. + if (slice.cacheReadTokens !== undefined) { + cacheReadTokens += slice.cacheReadTokens + hasCacheReadValue = true + continue + } + if (!providerSliceHasUsage(slice)) continue + cacheReadIncomplete = true + } + return { + ...(hasCacheReadValue ? { cacheReadTokens } : {}), + ...(cacheReadIncomplete ? { cacheReadIncomplete: true } : {}), + } +} + export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) @@ -921,6 +967,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: if (sources.length > 0) providers.push({ name: p.name, displayName: p.displayName, cost: 0, calls: 0, hasUsage: false }) } } else { + const providerCacheRead = cacheReadForProviderDays(cacheDaysForPeriod ?? [], pf) providers.push({ name: pf, displayName: displayNameByName.get(pf) ?? pf, @@ -931,6 +978,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: inputTokens: currentData.inputTokens, outputTokens: currentData.outputTokens, sessions: currentData.sessions, + ...providerCacheRead, hasUsage: currentData.cost > 0 || currentData.savingsUSD > 0 || currentData.calls > 0 diff --git a/tests/cli-cache-read-pipeline.test.ts b/tests/cli-cache-read-pipeline.test.ts new file mode 100644 index 00000000..c108b4b9 --- /dev/null +++ b/tests/cli-cache-read-pipeline.test.ts @@ -0,0 +1,284 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { delimiter as pathDelimiter, join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { DAILY_CACHE_VERSION } from '../src/daily-cache.js' +import { getDailyCacheConfigHash } from '../src/usage-aggregator.js' + +type ProviderSeed = { + calls: number + cost: number + sessions: number + inputTokens: number + outputTokens: number + cacheReadTokens?: number +} + +function dateStringUtc(date: Date): string { + return date.toISOString().slice(0, 10) +} + +function dayAtUtcOffset(offset: number): string { + const now = new Date() + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + return dateStringUtc(new Date(today - offset * 24 * 60 * 60 * 1000)) +} + +function seededDay(date: string, providers: Record) { + const rows = Object.values(providers) + return { + date, + cost: rows.reduce((sum, row) => sum + row.cost, 0), + savingsUSD: 0, + calls: rows.reduce((sum, row) => sum + row.calls, 0), + sessions: rows.reduce((sum, row) => sum + row.sessions, 0), + inputTokens: rows.reduce((sum, row) => sum + row.inputTokens, 0), + outputTokens: rows.reduce((sum, row) => sum + row.outputTokens, 0), + cacheReadTokens: rows.reduce((sum, row) => sum + (row.cacheReadTokens ?? 0), 0), + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers, + } +} + +function runCli(args: string[], home: string, extraEnv: Record = {}) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + CODEX_HOME: join(home, '.codex'), + KIMI_CODE_HOME: join(home, '.kimi'), + CODEBURN_DESKTOP_SESSIONS_DIR: join(home, '.desktop-sessions'), + TZ: 'UTC', + ...extraEnv, + }, + encoding: 'utf-8', + timeout: 60_000, + }) +} + +function detail(payload: { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } }, id: string) { + return payload.current.providerDetails.find(row => row.id === id) +} + +describe('status menubar cache-read pipeline', () => { + it('combines fresh and durable provider slices while honoring selected dates and unknown legacy fields', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cache-read-pipeline-')) + const knownDate = dayAtUtcOffset(10) + const excludedDate = dayAtUtcOffset(9) + const partialDate = dayAtUtcOffset(8) + const todayDate = dayAtUtcOffset(0) + + try { + await mkdir(join(home, '.claude', 'projects', 'fresh-project'), { recursive: true }) + await mkdir(join(home, '.codex'), { recursive: true }) + await mkdir(join(home, '.kimi'), { recursive: true }) + await mkdir(join(home, '.desktop-sessions'), { recursive: true }) + await mkdir(join(home, '.cache', 'codeburn'), { recursive: true }) + + const freshTimestamp = new Date(Date.now() - 10 * 60_000).toISOString() + await writeFile( + join(home, '.claude', 'projects', 'fresh-project', 'fresh.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: 'fresh-cache-session', + timestamp: freshTimestamp, + message: { role: 'user', content: 'exercise the durable cache path' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'fresh-cache-session', + timestamp: new Date(Date.now() - 9 * 60_000).toISOString(), + message: { + id: 'fresh-cache-message', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: 500, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 700, + }, + }, + }), + ].join('\n') + '\n', + ) + + const cache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: getDailyCacheConfigHash(), + tzKey: 'UTC', + lastComputedDate: dayAtUtcOffset(1), + complete: true, + days: [ + seededDay(knownDate, { + claude: { calls: 2, cost: 10, sessions: 1, inputTokens: 100, outputTokens: 20, cacheReadTokens: 1111 }, + codex: { calls: 3, cost: 20, sessions: 1, inputTokens: 200, outputTokens: 30, cacheReadTokens: 2222 }, + // Explicit zero with no activity: this is a known zero, not an + // absent legacy field, and must survive a provider-scoped query. + gemini: { calls: 0, cost: 0, sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, + hermes: { calls: 1, cost: 40, sessions: 1, inputTokens: 400, outputTokens: 50 }, + }), + // This day is deliberately inside the selected range but omitted by + // --days below. Its values prove that provider/date selection happens + // after durable loading, rather than selecting a whole cache file. + seededDay(excludedDate, { + claude: { calls: 2, cost: 11, sessions: 1, inputTokens: 110, outputTokens: 21, cacheReadTokens: 9001 }, + codex: { calls: 3, cost: 21, sessions: 1, inputTokens: 210, outputTokens: 31, cacheReadTokens: 9002 }, + gemini: { calls: 1, cost: 31, sessions: 1, inputTokens: 310, outputTokens: 41, cacheReadTokens: 9003 }, + hermes: { calls: 1, cost: 41, sessions: 1, inputTokens: 410, outputTokens: 51, cacheReadTokens: 9004 }, + }), + // Included alongside knownDate: this gives Hermes one known row and + // one active legacy-missing row, so a partial sum must stay unknown. + seededDay(partialDate, { + hermes: { calls: 1, cost: 42, sessions: 1, inputTokens: 420, outputTokens: 52, cacheReadTokens: 3333 }, + }), + ], + } + await writeFile( + join(home, '.cache', 'codeburn', `daily-cache.v${DAILY_CACHE_VERSION}.json`), + JSON.stringify(cache), + 'utf-8', + ) + + const args = [ + 'status', '--format', 'menubar-json', '--provider', 'all', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ] + const all = runCli(args, home) + expect(all.status, `stderr: ${all.stderr}`).toBe(0) + const allPayload = JSON.parse(all.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } } + + // Historical Claude (1111) + the real fresh parse (700) are both present. + expect(detail(allPayload, 'claude')?.cacheReadTokens).toBe(1811) + // The selected day has a distinct durable Codex value, while the excluded + // day's 9002 never enters the total. + expect(detail(allPayload, 'codex')?.cacheReadTokens).toBe(2222) + expect(detail(allPayload, 'gemini')?.cacheReadTokens).toBe(0) // known zero + expect(detail(allPayload, 'hermes')).toMatchObject({ hasUsage: true }) + expect(detail(allPayload, 'hermes')).not.toHaveProperty('cacheReadTokens') // active legacy missing + + const selectedClaude = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'claude', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedClaude.status, `stderr: ${selectedClaude.stderr}`).toBe(0) + const claudePayload = JSON.parse(selectedClaude.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } } + expect(detail(claudePayload, 'claude')?.cacheReadTokens).toBe(1811) + + const selectedCodex = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'codex', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedCodex.status, `stderr: ${selectedCodex.stderr}`).toBe(0) + const codexPayload = JSON.parse(selectedCodex.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } } + expect(detail(codexPayload, 'codex')?.cacheReadTokens).toBe(2222) + + const selectedGemini = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'gemini', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedGemini.status, `stderr: ${selectedGemini.stderr}`).toBe(0) + const geminiPayload = JSON.parse(selectedGemini.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } } + expect(detail(geminiPayload, 'gemini')).toMatchObject({ hasUsage: false, cacheReadTokens: 0 }) + + const selectedHermes = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'hermes', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedHermes.status, `stderr: ${selectedHermes.stderr}`).toBe(0) + const hermesPayload = JSON.parse(selectedHermes.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } } + expect(detail(hermesPayload, 'hermes')).toMatchObject({ hasUsage: true }) + expect(detail(hermesPayload, 'hermes')).not.toHaveProperty('cacheReadTokens') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, 120_000) + + it('keeps fresh cache reads in a selected Claude config provider detail', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cache-read-config-scope-')) + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + const base = new Date(Date.now() - 10 * 60_000) + const ts = (offset: number) => new Date(base.getTime() + offset).toISOString() + const assistant = (sessionId: string, timestamp: string, cacheRead: number) => JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: `${sessionId}-assistant`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: 500, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: cacheRead, + }, + }, + }) + const sourceEnv = { + CLAUDE_CONFIG_DIR: '', + CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter), + } + + try { + await mkdir(join(work, 'projects', 'selected'), { recursive: true }) + await mkdir(join(personal, 'projects', 'other'), { recursive: true }) + await mkdir(join(home, '.cache', 'codeburn'), { recursive: true }) + await writeFile( + join(work, 'projects', 'selected', 'work.jsonl'), + [ + JSON.stringify({ type: 'user', sessionId: 'selected-session', timestamp: ts(0), message: { role: 'user', content: 'fixture' } }), + assistant('selected-session', ts(60_000), 4321), + ].join('\n') + '\n', + ) + await writeFile( + join(personal, 'projects', 'other', 'personal.jsonl'), + [ + JSON.stringify({ type: 'user', sessionId: 'other-session', timestamp: ts(0), message: { role: 'user', content: 'fixture' } }), + assistant('other-session', ts(60_000), 9876), + ].join('\n') + '\n', + ) + + const all = runCli([ + 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize', '--no-timeline', + ], home, sourceEnv) + expect(all.status, `stderr: ${all.stderr}`).toBe(0) + const allPayload = JSON.parse(all.stdout) as { + claudeConfigs?: { options: Array<{ id: string; label: string }> } + } + const selectedId = allPayload.claudeConfigs?.options.find(option => option.label === 'claude-work')?.id + expect(selectedId).toBeTruthy() + + const selected = runCli([ + 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', + '--claude-config-source', selectedId!, '--no-optimize', '--no-timeline', + ], home, sourceEnv) + expect(selected.status, `stderr: ${selected.stderr}`).toBe(0) + const selectedPayload = JSON.parse(selected.stdout) as { + current: { cacheReadTokens: number; providerDetails: Array<{ id: string; cacheReadTokens?: number }> } + } + expect(selectedPayload.current.cacheReadTokens).toBe(4321) + expect(detail(selectedPayload, 'claude')?.cacheReadTokens).toBe(4321) + } finally { + await rm(home, { recursive: true, force: true }) + } + }, 120_000) +}) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index f3bfa903..cb53018a 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -888,6 +888,123 @@ describe('codeburn status --format menubar-json', () => { } }) + it('carries per-provider cache read through the parse path', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cache-read-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + const now = new Date() + const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + JSON.stringify({ + type: 'assistant', + sessionId: 's1', + timestamp: ts2, + message: { + id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 500, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 400 }, + }, + }), + ].join('\n'), + ) + + const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize'] + const result = runCli(args, home) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + const payload = JSON.parse(result.stdout) as { + current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } + } + expect(payload.current.providerDetails.find(provider => provider.id === 'claude')?.cacheReadTokens).toBe(400) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('recomputes a snapshot from the previous render revision, then reuses it stably', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cache-render-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + const now = new Date() + const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + JSON.stringify({ + type: 'assistant', + sessionId: 's1', + timestamp: ts2, + message: { + id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 500, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 400 }, + }, + }), + ].join('\n'), + ) + + const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize'] + + // First run writes a snapshot under the current (v7) semantic key. + const first = runCli(args, home) + expect(first.status, `stderr: ${first.stderr}`).toBe(0) + + const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn')) + expect(snapshotFiles).toHaveLength(1) + const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as { + semanticKey: string + payload: { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } } + } + // Downgrade to the PREVIOUS render revision (v6, taken by PR1265) and + // strip the cache field, simulating a warm snapshot from that branch: + // it must be rejected rather than served as if it had this data. + record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-6:') + for (const row of record.payload.current.providerDetails) delete row.cacheReadTokens + await writeFile(snapshotFiles[0]!, JSON.stringify(record)) + + // Recompute: the v6 record is rejected and rebuilt with cache data. + const second = runCli(args, home) + expect(second.status, `stderr: ${second.stderr}`).toBe(0) + const rebuilt = JSON.parse(second.stdout) as { + current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } + } + expect(rebuilt.current.providerDetails.find(provider => provider.id === 'claude')?.cacheReadTokens).toBe(400) + + // Stable reuse: the recomputed v7 snapshot is served as-is. Inject a + // sentinel into the on-disk payload (keeping the v7 key and fingerprint) + // and confirm the next run returns it rather than recomputing. + const files = findSnapshotFiles(join(home, '.cache', 'codeburn')) + const fresh = JSON.parse(await readFile(files[0]!, 'utf-8')) as { + semanticKey: string + payload: { sentinel?: string } + } + expect(fresh.semanticKey).toContain(':render-7:') + fresh.payload.sentinel = 'reused-v7' + await writeFile(files[0]!, JSON.stringify(fresh)) + + const third = runCli(args, home) + expect(third.status, `stderr: ${third.stderr}`).toBe(0) + expect(JSON.parse(third.stdout)).toHaveProperty('sentinel', 'reused-v7') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('reprices from an updated live LiteLLM cache instead of serving a stale snapshot', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-pricing-gen-')) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index fe96a440..fa021eef 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -281,6 +281,44 @@ describe('buildMenubarPayload', () => { ]) }) + it('carries per-provider cache read into providerDetails', () => { + const providers: ProviderCost[] = [ + { name: 'claude', displayName: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12, cacheReadTokens: 4_200_000 }, + { name: 'codex', displayName: 'Codex', cost: 88.84, calls: 642, hasUsage: true, inputTokens: 3_000_000, outputTokens: 150_000, sessions: 4, cacheReadTokens: 0 }, + ] + const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null) + expect(payload.current.providerDetails).toEqual([ + { id: 'claude', label: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12, cacheReadTokens: 4_200_000 }, + { id: 'codex', label: 'Codex', cost: 88.84, calls: 642, hasUsage: true, inputTokens: 3_000_000, outputTokens: 150_000, sessions: 4, cacheReadTokens: 0 }, + ]) + }) + + it('omits the cache-read key entirely when no day reported it', () => { + // Add-only contract: absent means unknown, not zero, so a legacy row must + // stay absent rather than being emitted as 0. + const payload = buildMenubarPayload( + emptyPeriod('Today'), + [{ name: 'claude', displayName: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12 }], + null, + ) + expect(payload.current.providerDetails).toEqual([ + { id: 'claude', label: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12 }, + ]) + expect(Object.keys(payload.current.providerDetails[0]!)).not.toContain('cacheReadTokens') + }) + + it('drops a partial cache-read sum when an active day lacked counts', () => { + // A fold that summed some days but missed a legacy active day must not be + // labelled complete: the emitter omits the key rather than publish a + // partial number. + const payload = buildMenubarPayload( + emptyPeriod('Today'), + [{ name: 'claude', displayName: 'Claude', cost: 190.1, calls: 900, hasUsage: true, cacheReadTokens: 4_200_000, cacheReadIncomplete: true }], + null, + ) + expect(Object.keys(payload.current.providerDetails[0]!)).not.toContain('cacheReadTokens') + }) + it('omits the token and session keys entirely when the period has no breakdown', () => { // Add-only contract: a consumer must be able to tell "no breakdown" from // zero, so absent stays absent rather than being emitted as 0. diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts index a8818555..df6063dd 100644 --- a/tests/usage-aggregator.test.ts +++ b/tests/usage-aggregator.test.ts @@ -52,15 +52,15 @@ describe('addProviderSlice', () => { addProviderSlice(totals, 'claude', { cost: 2.5, calls: 1, savingsUSD: 0, inputTokens: 50, outputTokens: 5, sessions: 1 }) addProviderSlice(totals, 'codex', { cost: 1, calls: 3, savingsUSD: 0, inputTokens: 7, outputTokens: 3, sessions: 1 }) - expect(totals.claude).toEqual({ cost: 12.5, calls: 5, hasUsage: true, inputTokens: 150, outputTokens: 25, sessions: 3 }) - expect(totals.codex).toEqual({ cost: 1, calls: 3, hasUsage: true, inputTokens: 7, outputTokens: 3, sessions: 1 }) + expect(totals.claude).toEqual({ cost: 12.5, calls: 5, hasUsage: true, inputTokens: 150, outputTokens: 25, sessions: 3, cacheReadIncomplete: true }) + expect(totals.codex).toEqual({ cost: 1, calls: 3, hasUsage: true, inputTokens: 7, outputTokens: 3, sessions: 1, cacheReadIncomplete: true }) }) it('leaves tokens absent (not zero) when no day carried a breakdown', () => { const totals: Record = {} // A day finalized before per-provider tokens were cached. addProviderSlice(totals, 'claude', { cost: 3, calls: 2, savingsUSD: 0 }) - expect(totals.claude).toEqual({ cost: 3, calls: 2, hasUsage: true }) + expect(totals.claude).toEqual({ cost: 3, calls: 2, hasUsage: true, cacheReadIncomplete: true }) expect(totals.claude!.inputTokens).toBeUndefined() expect(totals.claude!.outputTokens).toBeUndefined() expect(totals.claude!.sessions).toBeUndefined() @@ -68,13 +68,41 @@ describe('addProviderSlice', () => { // One day that does report them makes the total reportable again, counting // only what was actually reported. addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, inputTokens: 9, outputTokens: 4 }) - expect(totals.claude).toEqual({ cost: 4, calls: 3, hasUsage: true, inputTokens: 9, outputTokens: 4 }) + expect(totals.claude).toEqual({ cost: 4, calls: 3, hasUsage: true, inputTokens: 9, outputTokens: 4, cacheReadIncomplete: true }) }) it('keeps a token-only day visible as usage', () => { const totals: Record = {} addProviderSlice(totals, 'hermes', { cost: 0, calls: 0, savingsUSD: 0, inputTokens: 12, outputTokens: 0 }) - expect(totals.hermes).toEqual({ cost: 0, calls: 0, hasUsage: true, inputTokens: 12, outputTokens: 0 }) + expect(totals.hermes).toEqual({ cost: 0, calls: 0, hasUsage: true, inputTokens: 12, outputTokens: 0, cacheReadIncomplete: true }) + }) + + it('sums cache read per provider, keeping zero and absence distinct', () => { + const totals: Record = {} + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 }) + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 50 }) + addProviderSlice(totals, 'codex', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 0 }) + addProviderSlice(totals, 'grok', { cost: 1, calls: 1, savingsUSD: 0 }) + + expect(totals.claude!.cacheReadTokens).toBe(150) + expect(totals.claude!.cacheReadIncomplete).toBeUndefined() + expect(totals.codex!.cacheReadTokens).toBe(0) // a reported zero, not unknown + expect(totals.grok!.cacheReadTokens).toBeUndefined() // absent, not zero + }) + + it('marks the total incomplete when an active day lacks cache read', () => { + const totals: Record = {} + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 }) + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0 }) + expect(totals.claude!.cacheReadIncomplete).toBe(true) + }) + + it('does not mark incomplete when only idle days lack cache read', () => { + const totals: Record = {} + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 }) + addProviderSlice(totals, 'claude', { cost: 0, calls: 0, savingsUSD: 0 }) + expect(totals.claude!.cacheReadIncomplete).toBeUndefined() + expect(totals.claude!.cacheReadTokens).toBe(100) }) })