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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 236 additions & 28 deletions mac/Sources/CodeBurnMenubar/AppStore.swift

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
}

Expand Down
235 changes: 235 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift
Original file line number Diff line number Diff line change
@@ -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 <now-to-limit>" 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"
}
}
41 changes: 41 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading