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
35 changes: 27 additions & 8 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,23 @@ extension UsageMenuCardView.Model {
input: input,
projection: codexProjection,
percentStyle: percentStyle))
} else if input.provider == .copilot {
let copilotWindow = snapshot.primary ?? snapshot.secondary
if let copilotWindow {
let paceDetail = Self.weeklyPaceDetail(
window: copilotWindow,
now: input.now,
pace: input.weeklyPace,
showUsed: input.usageBarsShowUsed)
metrics.append(Self.primaryMetric(
input: input,
primary: copilotWindow,
percentStyle: percentStyle,
zaiTokenDetail: zaiTokenDetail,
openRouterQuotaDetail: openRouterQuotaDetail,
titleOverride: input.metadata.sessionLabel,
paceDetail: paceDetail))
}
} else if let primary = snapshot.primary {
metrics.append(Self.primaryMetric(
input: input,
Expand All @@ -958,7 +975,7 @@ extension UsageMenuCardView.Model {
zaiTokenDetail: zaiTokenDetail,
openRouterQuotaDetail: openRouterQuotaDetail))
}
if input.provider != .codex, let weekly = snapshot.secondary {
if input.provider != .codex, input.provider != .copilot, let weekly = snapshot.secondary {
metrics.append(Self.secondaryMetric(
input: input,
weekly: weekly,
Expand Down Expand Up @@ -1033,7 +1050,9 @@ extension UsageMenuCardView.Model {
primary: RateWindow,
percentStyle: PercentStyle,
zaiTokenDetail: String?,
openRouterQuotaDetail: String?) -> Metric
openRouterQuotaDetail: String?,
titleOverride: String? = nil,
paceDetail: PaceDetail? = nil) -> Metric
{
var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil
var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now)
Expand All @@ -1059,16 +1078,16 @@ extension UsageMenuCardView.Model {
}
return Metric(
id: "primary",
title: input.metadata.sessionLabel,
title: titleOverride ?? input.metadata.sessionLabel,
percent: Self.clamped(
input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent),
percentStyle: percentStyle,
resetText: primaryResetText,
detailText: primaryDetailText,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true)
detailLeftText: paceDetail?.leftLabel,
detailRightText: paceDetail?.rightLabel,
pacePercent: paceDetail?.pacePercent,
paceOnTop: paceDetail?.paceOnTop ?? true)
}

private static func secondaryMetric(
Expand Down Expand Up @@ -1278,7 +1297,7 @@ extension UsageMenuCardView.Model {
let actualPercent = showUsed ? actualUsed : (100 - actualUsed)
if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil }
let paceOnTop = actualUsed <= expectedUsed
let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent }
let pacePercent: Double? = if detail.stage == UsagePace.Stage.onTrack { nil } else { expectedPercent }
return PaceDetail(
leftLabel: detail.leftLabel,
rightLabel: detail.rightLabel,
Expand Down
19 changes: 18 additions & 1 deletion Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,20 @@ struct MenuDescriptor {
window: primaryWindow,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
if provider == .copilot,
let pace = store.weeklyPace(provider: provider, window: primary)
{
let paceSummary = UsagePaceText.weeklySummary(pace: pace)
entries.append(.text(paceSummary, .secondary))
}
if provider == .warp || provider == .kilo,
let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
entries.append(.text(detail, .secondary))
}
}
if let weekly = snap.secondary {
if provider != .copilot, let weekly = snap.secondary {
let weeklyResetOverride: String? = {
guard provider == .warp || provider == .kilo || provider == .perplexity else { return nil }
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down Expand Up @@ -198,6 +204,17 @@ struct MenuDescriptor {
let paceSummary = UsagePaceText.weeklySummary(pace: pace)
entries.append(.text(paceSummary, .secondary))
}
} else if provider == .copilot, snap.primary == nil, let weekly = snap.secondary {
Self.appendRateWindow(
entries: &entries,
title: meta.sessionLabel,
window: weekly,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
if let pace = store.weeklyPace(provider: provider, window: weekly) {
let paceSummary = UsagePaceText.weeklySummary(pace: pace)
entries.append(.text(paceSummary, .secondary))
}
}
if meta.supportsOpus, let opus = snap.tertiary {
// Perplexity purchased credits don't reset; show the balance as plain text.
Expand Down
14 changes: 9 additions & 5 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -535,14 +535,18 @@ struct ProvidersPane: View {
tokenError = nil
}

let weeklyPace = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
let weeklyPace: UsagePace? = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
{
self.store.weeklyPace(provider: provider, window: weekly, now: now)
} else if provider == .copilot,
let window = snapshot?.primary ?? snapshot?.secondary
{
self.store.weeklyPace(provider: provider, window: window, now: now)
} else if let window = snapshot?.secondary {
self.store.weeklyPace(provider: provider, window: window, now: now)
} else {
snapshot?.secondary.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
nil
}
let input = UsageMenuCardView.Model.Input(
provider: provider,
Expand Down
8 changes: 7 additions & 1 deletion Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,13 @@ extension StatusItemController {
case .percent:
pace = nil
case .pace, .both:
let weeklyWindow = codexProjection?.rateWindow(for: .weekly) ?? snapshot?.secondary
let weeklyWindow = if let codexWeekly = codexProjection?.rateWindow(for: .weekly) {
codexWeekly
} else if provider == .copilot {
snapshot?.primary ?? snapshot?.secondary
} else {
snapshot?.secondary
}
pace = weeklyWindow.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
Expand Down
14 changes: 9 additions & 5 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1408,14 +1408,18 @@ extension StatusItemController {

let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil
let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto
let weeklyPace = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
let weeklyPace: UsagePace? = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
{
self.store.weeklyPace(provider: target, window: weekly, now: now)
} else if target == .copilot,
let window = snapshot?.primary ?? snapshot?.secondary
{
self.store.weeklyPace(provider: target, window: window, now: now)
} else if let window = snapshot?.secondary {
self.store.weeklyPace(provider: target, window: window, now: now)
} else {
snapshot?.secondary.flatMap { window in
self.store.weeklyPace(provider: target, window: window, now: now)
}
nil
}
let input = UsageMenuCardView.Model.Input(
provider: target,
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore+HistoricalPace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Foundation
extension UsageStore {
func supportsWeeklyPace(for provider: UsageProvider) -> Bool {
switch provider {
case .codex, .claude:
case .codex, .claude, .copilot:
true
default:
false
Expand Down
49 changes: 49 additions & 0 deletions Sources/CodexBarCore/CopilotUsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,30 @@ public struct CopilotUsageResponse: Sendable, Decodable {
}
}

public struct QuotaWindow: Sendable, Equatable {
public let assignedAt: Date
public let resetsAt: Date
public let windowMinutes: Int
}

public let quotaSnapshots: QuotaSnapshots
public let copilotPlan: String
public let assignedDate: String?
public let quotaResetDate: String?

public var quotaWindow: QuotaWindow? {
guard let resetsAt = Self.parseQuotaDate(self.quotaResetDate),
let assignedAt = Self.calendarMonthStart(for: resetsAt),
resetsAt > assignedAt
else {
return nil
}

let windowMinutes = Int((resetsAt.timeIntervalSince(assignedAt) / 60).rounded())
guard windowMinutes > 0 else { return nil }
return QuotaWindow(assignedAt: assignedAt, resetsAt: resetsAt, windowMinutes: windowMinutes)
}

private enum CodingKeys: String, CodingKey {
case quotaSnapshots = "quota_snapshots"
case copilotPlan = "copilot_plan"
Expand Down Expand Up @@ -294,4 +313,34 @@ public struct CopilotUsageResponse: Sendable, Decodable {
}
return snapshot
}

private static func parseQuotaDate(_ value: String?) -> Date? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}

let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = isoFormatter.date(from: value) {
return date
}
isoFormatter.formatOptions = [.withInternetDateTime]
if let date = isoFormatter.date(from: value) {
return date
}

let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: value)
}

private static func calendarMonthStart(for resetDate: Date) -> Date? {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
let resetMonth = calendar.dateInterval(of: .month, for: resetDate)?.start
return calendar.date(byAdding: .month, value: -1, to: resetMonth ?? resetDate)
}
}
14 changes: 9 additions & 5 deletions Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ public struct CopilotUsageFetcher: Sendable {
}

let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: data)
let premium = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions)
let chat = self.makeRateWindow(from: usage.quotaSnapshots.chat)
let quotaWindow = usage.quotaWindow
let premium = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions, quotaWindow: quotaWindow)
let chat = self.makeRateWindow(from: usage.quotaSnapshots.chat, quotaWindow: quotaWindow)

let primary: RateWindow?
let secondary: RateWindow?
Expand Down Expand Up @@ -74,7 +75,10 @@ public struct CopilotUsageFetcher: Sendable {
request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version")
}

private func makeRateWindow(from snapshot: CopilotUsageResponse.QuotaSnapshot?) -> RateWindow? {
private func makeRateWindow(
from snapshot: CopilotUsageResponse.QuotaSnapshot?,
quotaWindow: CopilotUsageResponse.QuotaWindow?) -> RateWindow?
{
guard let snapshot else { return nil }
guard !snapshot.isPlaceholder else { return nil }
guard snapshot.hasPercentRemaining else { return nil }
Expand All @@ -83,8 +87,8 @@ public struct CopilotUsageFetcher: Sendable {

return RateWindow(
usedPercent: usedPercent,
windowMinutes: nil, // Not provided
resetsAt: nil, // Not provided per-quota in the simplified snapshot
windowMinutes: quotaWindow?.windowMinutes,
resetsAt: quotaWindow?.resetsAt,
resetDescription: nil)
}
}
46 changes: 46 additions & 0 deletions Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,52 @@ struct CodexPresentationCharacterizationTests {
#expect(store.codexCookieCacheScopeForOpenAIWeb() == nil)
}

@Test
func `Copilot menu shows a single premium row with pace summary`() {
let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-copilot-monthly-pace")
settings.statusChecksEnabled = false

let now = Date()
let fetcher = UsageFetcher(environment: [:])
let store = UsageStore(
fetcher: fetcher,
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
startupBehavior: .testing)
store._setSnapshotForTesting(
UsageSnapshot(
primary: RateWindow(
usedPercent: 23,
windowMinutes: 31 * 24 * 60,
resetsAt: now.addingTimeInterval(5 * 24 * 60 * 60),
resetDescription: nil),
secondary: RateWindow(
usedPercent: 23,
windowMinutes: 31 * 24 * 60,
resetsAt: now.addingTimeInterval(5 * 24 * 60 * 60),
resetDescription: nil),
updatedAt: now,
identity: ProviderIdentitySnapshot(
providerID: .copilot,
accountEmail: "copilot@example.com",
accountOrganization: nil,
loginMethod: "individual_pro")),
provider: .copilot)

let descriptor = MenuDescriptor.build(
provider: .copilot,
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updateReady: false,
includeContextualActions: false)

let lines = self.textLines(from: descriptor)
#expect(lines.contains(where: { $0.hasPrefix("Premium:") }))
#expect(lines.contains(where: { $0.hasPrefix("Chat:") }) == false)
#expect(lines.contains(where: { $0.hasPrefix("Pace:") && $0.contains("reserve") }))
}

@Test
func `zai menu descriptor includes Tokens MCP and 5-hour rows`() {
let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-zai-three-quota")
Expand Down
Loading