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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 31 additions & 28 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct UsageMenuCardView: View {
let percent: Double
let percentStyle: PercentStyle
let resetText: String?
let detailText: String?

var percentLabel: String {
String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix)
Expand Down Expand Up @@ -123,6 +124,12 @@ struct UsageMenuCardView: View {
.foregroundStyle(.secondary)
}
}
if let detail = metric.detailText {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
if let credits = self.model.creditsText {
Expand Down Expand Up @@ -210,6 +217,7 @@ extension UsageMenuCardView.Model {
let lastError: String?
let usageBarsShowUsed: Bool
let tokenCostUsageEnabled: Bool
let now: Date
}

static func make(_ input: Input) -> UsageMenuCardView.Model {
Expand All @@ -218,12 +226,7 @@ extension UsageMenuCardView.Model {
snapshot: input.snapshot,
account: input.account)
let planText = Self.plan(for: input.provider, snapshot: input.snapshot, account: input.account)
let metrics = Self.metrics(
provider: input.provider,
metadata: input.metadata,
snapshot: input.snapshot,
dashboard: input.dashboard,
usageBarsShowUsed: input.usageBarsShowUsed)
let metrics = Self.metrics(input: input)
let creditsText = Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError)
let creditsHintText = Self.dashboardHint(provider: input.provider, error: input.dashboardError)
let tokenUsage = Self.tokenUsageSection(
Expand Down Expand Up @@ -302,47 +305,47 @@ extension UsageMenuCardView.Model {
return ("Not fetched yet", .info)
}

private static func metrics(
provider: UsageProvider,
metadata: ProviderMetadata,
snapshot: UsageSnapshot?,
dashboard: OpenAIDashboardSnapshot?,
usageBarsShowUsed: Bool) -> [Metric]
{
guard let snapshot else { return [] }
private static func metrics(input: Input) -> [Metric] {
guard let snapshot = input.snapshot else { return [] }
var metrics: [Metric] = []
let percentStyle: PercentStyle = usageBarsShowUsed ? .used : .left
let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
metrics.append(Metric(
id: "primary",
title: metadata.sessionLabel,
percent: Self.clamped(usageBarsShowUsed ? snapshot.primary.usedPercent : snapshot.primary.remainingPercent),
title: input.metadata.sessionLabel,
percent: Self.clamped(
input.usageBarsShowUsed ? snapshot.primary.usedPercent : snapshot.primary.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: snapshot.primary, prefersCountdown: true)))
resetText: Self.resetText(for: snapshot.primary, prefersCountdown: true),
detailText: nil))
if let weekly = snapshot.secondary {
let paceText = UsagePaceText.weekly(provider: input.provider, window: weekly, now: input.now)
metrics.append(Metric(
id: "secondary",
title: metadata.weeklyLabel,
percent: Self.clamped(usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent),
title: input.metadata.weeklyLabel,
percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: weekly, prefersCountdown: true)))
resetText: Self.resetText(for: weekly, prefersCountdown: true),
detailText: paceText))
}
if metadata.supportsOpus, let opus = snapshot.tertiary {
if input.metadata.supportsOpus, let opus = snapshot.tertiary {
metrics.append(Metric(
id: "tertiary",
title: metadata.opusLabel ?? "Sonnet",
percent: Self.clamped(usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent),
title: input.metadata.opusLabel ?? "Sonnet",
percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: opus, prefersCountdown: true)))
resetText: Self.resetText(for: opus, prefersCountdown: true),
detailText: nil))
}

if provider == .codex, let remaining = dashboard?.codeReviewRemainingPercent {
let percent = usageBarsShowUsed ? (100 - remaining) : remaining
if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent {
let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining
metrics.append(Metric(
id: "code-review",
title: "Code review",
percent: Self.clamped(percent),
percentStyle: percentStyle,
resetText: nil))
resetText: nil,
detailText: nil))
}
return metrics
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ struct MenuDescriptor {
Self.appendRateWindow(entries: &entries, title: meta.sessionLabel, window: snap.primary)
if let weekly = snap.secondary {
Self.appendRateWindow(entries: &entries, title: meta.weeklyLabel, window: weekly)
if let paceText = UsagePaceText.weekly(provider: provider, window: weekly) {
entries.append(.text(paceText, .secondary))
}
} else if provider == .claude {
entries.append(.text("Weekly usage unavailable for this account.", .secondary))
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,8 @@ extension StatusItemController {
isRefreshing: self.store.isRefreshing,
lastError: self.store.error(for: target),
usageBarsShowUsed: self.settings.usageBarsShowUsed,
tokenCostUsageEnabled: self.settings.isCCUsageCostUsageEffectivelyEnabled(for: target))
tokenCostUsageEnabled: self.settings.isCCUsageCostUsageEffectivelyEnabled(for: target),
now: Date())
return UsageMenuCardView.Model.make(input)
}
}
Expand Down
48 changes: 48 additions & 0 deletions Sources/CodexBar/UsagePaceText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import CodexBarCore
import Foundation

enum UsagePaceText {
static func weekly(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? {
guard provider == .codex || provider == .claude else { return nil }
guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil }

let label = Self.label(for: pace.stage)
let deltaSuffix = Self.deltaSuffix(for: pace)
let etaSuffix = Self.etaSuffix(for: pace, now: now)

if let etaSuffix {
return "Pace: \(label)\(deltaSuffix) · \(etaSuffix)"
}
return "Pace: \(label)\(deltaSuffix)"
}

private static func label(for stage: UsagePace.Stage) -> String {
switch stage {
case .onTrack: "On track"
case .slightlyAhead, .ahead, .farAhead: "High"
case .slightlyBehind, .behind, .farBehind: "Low"
}
}

private static func deltaSuffix(for pace: UsagePace) -> String {
let deltaValue = Int(abs(pace.deltaPercent).rounded())
let sign = pace.deltaPercent >= 0 ? "+" : "-"
return " (\(sign)\(deltaValue)%)"
}

private static func etaSuffix(for pace: UsagePace, now: Date) -> String? {
if pace.willLastToReset { return "Lasts to reset" }
guard let etaSeconds = pace.etaSeconds else { return nil }
return Self.leftText(seconds: etaSeconds, now: now)
}

private static func leftText(seconds: TimeInterval, now: Date) -> String {
let date = now.addingTimeInterval(seconds)
let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now)
if countdown == "now" { return "now" }
if countdown.hasPrefix("in ") {
return "\(countdown.dropFirst(3)) left"
}
return "\(countdown) left"
}
}
81 changes: 81 additions & 0 deletions Sources/CodexBarCore/UsagePace.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

public struct UsagePace: Sendable {
public enum Stage: Sendable {
case onTrack
case slightlyAhead
case ahead
case farAhead
case slightlyBehind
case behind
case farBehind
}

public let stage: Stage
public let deltaPercent: Double
public let expectedUsedPercent: Double
public let actualUsedPercent: Double
public let etaSeconds: TimeInterval?
public let willLastToReset: Bool

public static func weekly(
window: RateWindow,
now: Date = .init(),
defaultWindowMinutes: Int = 10080) -> UsagePace?
{
guard let resetsAt = window.resetsAt else { return nil }
let minutes = window.windowMinutes ?? defaultWindowMinutes
guard minutes > 0 else { return nil }

let duration = TimeInterval(minutes) * 60
let timeUntilReset = resetsAt.timeIntervalSince(now)
guard timeUntilReset > 0 else { return nil }
guard timeUntilReset <= duration else { return nil }
let elapsed = Self.clamp(duration - timeUntilReset, lower: 0, upper: duration)
let expected = Self.clamp((elapsed / duration) * 100, lower: 0, upper: 100)
let actual = Self.clamp(window.usedPercent, lower: 0, upper: 100)
if elapsed == 0, actual > 0 {
return nil
}
let delta = actual - expected
let stage = Self.stage(for: delta)

var etaSeconds: TimeInterval?
var willLastToReset = false

if elapsed > 0, actual > 0 {
let rate = actual / elapsed
if rate > 0 {
let remaining = max(0, 100 - actual)
let candidate = remaining / rate
if candidate >= timeUntilReset {
willLastToReset = true
} else {
etaSeconds = candidate
}
}
} else if elapsed > 0, actual == 0 {
willLastToReset = true
}

return UsagePace(
stage: stage,
deltaPercent: delta,
expectedUsedPercent: expected,
actualUsedPercent: actual,
etaSeconds: etaSeconds,
willLastToReset: willLastToReset)
}

private static func stage(for delta: Double) -> Stage {
let absDelta = abs(delta)
if absDelta <= 2 { return .onTrack }
if absDelta <= 6 { return delta >= 0 ? .slightlyAhead : .slightlyBehind }
if absDelta <= 12 { return delta >= 0 ? .ahead : .behind }
return delta >= 0 ? .farAhead : .farBehind
}

private static func clamp(_ value: Double, lower: Double, upper: Double) -> Double {
min(upper, max(lower, value))
}
}
18 changes: 12 additions & 6 deletions Tests/CodexBarTests/MenuCardModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ struct MenuCardModelTests {
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
tokenCostUsageEnabled: false))
tokenCostUsageEnabled: false,
now: now))

#expect(model.providerName == "Codex")
#expect(model.metrics.count == 2)
Expand Down Expand Up @@ -103,7 +104,8 @@ struct MenuCardModelTests {
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: true,
tokenCostUsageEnabled: false))
tokenCostUsageEnabled: false,
now: now))

#expect(model.metrics.first?.title == "Session")
#expect(model.metrics.first?.percent == 22)
Expand Down Expand Up @@ -145,7 +147,8 @@ struct MenuCardModelTests {
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
tokenCostUsageEnabled: false))
tokenCostUsageEnabled: false,
now: now))

#expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 })
}
Expand Down Expand Up @@ -180,7 +183,8 @@ struct MenuCardModelTests {
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
tokenCostUsageEnabled: false))
tokenCostUsageEnabled: false,
now: now))

#expect(model.metrics.count == 1)
#expect(model.metrics.first?.title == "Session")
Expand All @@ -204,7 +208,8 @@ struct MenuCardModelTests {
isRefreshing: false,
lastError: "Probe failed for Codex",
usageBarsShowUsed: false,
tokenCostUsageEnabled: false))
tokenCostUsageEnabled: false,
now: Date()))

#expect(model.subtitleStyle == .error)
#expect(model.subtitleText.contains("Probe failed"))
Expand All @@ -228,7 +233,8 @@ struct MenuCardModelTests {
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
tokenCostUsageEnabled: false))
tokenCostUsageEnabled: false,
now: Date()))

#expect(model.planText == nil)
#expect(model.email.isEmpty)
Expand Down
Loading