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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Command Code: add browser-cookie provider support for monthly USD billing credits (#857). Thanks @sixhobbits!
- StepFun: add username/password or Oasis-Token provider support for Step Plan rate-limit tracking (#815). Thanks @tevenfeng!
- OpenRouter, Mistral, and Kimi K2: show balance/spend metrics in menu bar text when quota percentage is not useful (#853). Thanks @willytop8!
- Usage pace: show session-level pace indicators for Codex and Claude 5-hour windows (#355). Thanks @johnlarkin1!

### Fixes
- Startup: avoid blocking menu-bar creation on synchronous defaults migration/default seeding when macOS preferences services stall.
Expand Down
50 changes: 50 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import CodexBarCore
import SwiftUI

extension UsageMenuCardView.Model {
struct PaceDetail {
let leftLabel: String
let rightLabel: String?
let pacePercent: Double?
let paceOnTop: Bool
}

static func progressColor(for provider: UsageProvider) -> Color {
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
return Color(red: color.red, green: color.green, blue: color.blue)
Expand All @@ -14,4 +21,47 @@ extension UsageMenuCardView.Model {
{
UsageFormatter.resetLine(for: window, style: style, now: now)
}

static func sessionPaceDetail(
provider: UsageProvider,
window: RateWindow,
now: Date,
showUsed: Bool) -> PaceDetail?
{
guard let detail = UsagePaceText.sessionDetail(provider: provider, window: window, now: now) else { return nil }
let expectedUsed = detail.expectedUsedPercent
let actualUsed = window.usedPercent
let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed)
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 }
return PaceDetail(
leftLabel: detail.leftLabel,
rightLabel: detail.rightLabel,
pacePercent: pacePercent,
paceOnTop: paceOnTop)
}

static func weeklyPaceDetail(
window: RateWindow,
now: Date,
pace: UsagePace?,
showUsed: Bool) -> PaceDetail?
{
guard let pace else { return nil }
let detail = UsagePaceText.weeklyDetail(pace: pace, now: now)
let expectedUsed = detail.expectedUsedPercent
let actualUsed = window.usedPercent
let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed)
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 }
return PaceDetail(
leftLabel: detail.leftLabel,
rightLabel: detail.rightLabel,
pacePercent: pacePercent,
paceOnTop: paceOnTop)
}
}
46 changes: 16 additions & 30 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,17 @@ extension UsageMenuCardView.Model {
// Abacus: show credits as detail, compute pace on the primary monthly window
var primaryPacePercent: Double?
var primaryPaceOnTop = true
if let paceDetail = Self.sessionPaceDetail(
provider: input.provider,
window: primary,
now: input.now,
showUsed: input.usageBarsShowUsed)
{
primaryDetailLeft = paceDetail.leftLabel
primaryDetailRight = paceDetail.rightLabel
primaryPacePercent = paceDetail.pacePercent
primaryPaceOnTop = paceDetail.paceOnTop
}
if input.provider == .abacus {
if let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Expand Down Expand Up @@ -1292,7 +1303,11 @@ extension UsageMenuCardView.Model {
case .session:
title = input.metadata.sessionLabel
id = "primary"
paceDetail = nil
paceDetail = Self.sessionPaceDetail(
provider: input.provider,
window: window,
now: input.now,
showUsed: input.usageBarsShowUsed)
case .weekly:
title = input.metadata.weeklyLabel
id = "secondary"
Expand Down Expand Up @@ -1411,35 +1426,6 @@ extension UsageMenuCardView.Model {
return "\(remaining)/\(limit) left"
}

private struct PaceDetail {
let leftLabel: String
let rightLabel: String?
let pacePercent: Double?
let paceOnTop: Bool
}

private static func weeklyPaceDetail(
window: RateWindow,
now: Date,
pace: UsagePace?,
showUsed: Bool) -> PaceDetail?
{
guard let pace else { return nil }
let detail = UsagePaceText.weeklyDetail(pace: pace, now: now)
let expectedUsed = detail.expectedUsedPercent
let actualUsed = window.usedPercent
let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed)
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 }
return PaceDetail(
leftLabel: detail.leftLabel,
rightLabel: detail.rightLabel,
pacePercent: pacePercent,
paceOnTop: paceOnTop)
}

private static func syntheticRegenDetail(
weekly: RateWindow,
cost: ProviderCostSnapshot?,
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ struct MenuDescriptor {
let paceSummary = UsagePaceText.weeklySummary(pace: pace)
entries.append(.text(paceSummary, .secondary))
}
if let paceSummary = UsagePaceText.sessionSummary(provider: provider, window: primary) {
entries.append(.text(paceSummary, .secondary))
}
}
if let weekly = snap.secondary {
let weeklyResetOverride: String? = {
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodexBar/UsagePaceText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,29 @@ enum UsagePaceText {
let rounded = (percent / 5).rounded() * 5
return Int(rounded)
}

static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? {
guard provider == .codex || provider == .claude else { return nil }
guard window.remainingPercent > 0 else { return nil }
guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) else { return nil }
guard pace.expectedUsedPercent >= 3 else { return nil }
return pace
}

static func sessionDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? {
guard let pace = sessionPace(provider: provider, window: window, now: now) else { return nil }
return WeeklyDetail(
leftLabel: Self.detailLeftLabel(for: pace),
rightLabel: Self.detailRightLabel(for: pace, now: now),
expectedUsedPercent: pace.expectedUsedPercent,
stage: pace.stage)
}

static func sessionSummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? {
guard let detail = sessionDetail(provider: provider, window: window, now: now) else { return nil }
if let rightLabel = detail.rightLabel {
return "Pace: \(detail.leftLabel) · \(rightLabel)"
}
return "Pace: \(detail.leftLabel)"
}
}
19 changes: 19 additions & 0 deletions Tests/CodexBarTests/UsagePaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,23 @@ struct UsagePaceTests {

#expect(pace == nil)
}

@Test
func `session pace computes delta and eta for five hour window`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 50,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)

let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300)

#expect(pace != nil)
guard let pace else { return }
#expect(abs(pace.expectedUsedPercent - 60.0) < 0.01)
#expect(abs(pace.deltaPercent - -10.0) < 0.01)
#expect(pace.stage == .behind)
#expect(pace.willLastToReset == true)
}
}
82 changes: 82 additions & 0 deletions Tests/CodexBarTests/UsagePaceTextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,86 @@ struct UsagePaceTextTests {

#expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk")
}

// MARK: - Session pace (5-hour window)

@Test
func `session pace detail provides left right labels`() {
let now = Date(timeIntervalSince1970: 0)
// 300-minute window, 2h remaining => 3h elapsed out of 5h
// expected = 60%, actual = 80% => 20% ahead (in deficit)
let window = RateWindow(
usedPercent: 80,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)

let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now)

#expect(detail != nil)
#expect(detail?.leftLabel == "20% in deficit")
#expect(detail?.rightLabel != nil)
#expect(detail?.stage == .farAhead)
}

@Test
func `session pace detail reports lasts until reset`() {
let now = Date(timeIntervalSince1970: 0)
// 300-minute window, 2h remaining => 3h elapsed
// expected = 60%, actual = 10% => far behind (in reserve)
let window = RateWindow(
usedPercent: 10,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)

let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now)

#expect(detail != nil)
#expect(detail?.leftLabel == "50% in reserve")
#expect(detail?.rightLabel == "Lasts until reset")
}

@Test
func `session pace summary formats single line text`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 80,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)

let summary = UsagePaceText.sessionSummary(provider: .claude, window: window, now: now)

#expect(summary != nil)
#expect(summary?.hasPrefix("Pace:") == true)
}

@Test
func `session pace detail hides for unsupported provider`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 50,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)

let detail = UsagePaceText.sessionDetail(provider: .zai, window: window, now: now)

#expect(detail == nil)
}

@Test
func `session pace detail hides when reset is missing`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 50,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil)

let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now)

#expect(detail == nil)
}
}