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
34 changes: 30 additions & 4 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,11 @@ extension UsageMenuCardView.Model {
if input.provider == .warp, primary.resetsAt == nil {
primaryResetText = nil
}
let sessionPaceDetail = Self.sessionPaceDetail(
provider: input.provider,
window: primary,
now: input.now,
showUsed: input.usageBarsShowUsed)
metrics.append(Metric(
id: "primary",
title: input.metadata.sessionLabel,
Expand All @@ -762,10 +767,10 @@ extension UsageMenuCardView.Model {
percentStyle: percentStyle,
resetText: primaryResetText,
detailText: primaryDetailText,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true))
detailLeftText: sessionPaceDetail?.leftLabel,
detailRightText: sessionPaceDetail?.rightLabel,
pacePercent: sessionPaceDetail?.pacePercent,
paceOnTop: sessionPaceDetail?.paceOnTop ?? true))
}
if let weekly = snapshot.secondary {
let paceDetail = Self.weeklyPaceDetail(
Expand Down Expand Up @@ -848,6 +853,27 @@ extension UsageMenuCardView.Model {
let paceOnTop: Bool
}

private 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)
}

private static func weeklyPaceDetail(
provider: UsageProvider,
window: RateWindow,
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ struct MenuDescriptor {
{
entries.append(.text(detail, .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
34 changes: 33 additions & 1 deletion Sources/CodexBar/UsagePaceText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,42 @@ enum UsagePaceText {
return countdown
}

static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? {
Self.pace(provider: provider, window: window, now: now, defaultWindowMinutes: 300)
}

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)"
}

static func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? {
Self.pace(provider: provider, window: window, now: now, defaultWindowMinutes: 10080)
}

private static func pace(
provider: UsageProvider,
window: RateWindow,
now: Date,
defaultWindowMinutes: Int) -> 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: 10080) else { return nil }
guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: defaultWindowMinutes) else {
return nil
}
guard pace.expectedUsedPercent >= Self.minimumExpectedPercent else { return nil }
return pace
}
Expand Down
22 changes: 22 additions & 0 deletions Tests/CodexBarTests/UsagePaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,28 @@ struct UsagePaceTests {
#expect(UsagePace.weekly(window: tooFar, now: now) == nil)
}

@Test
func sessionPace_computesDeltaAndEtaFor5HourWindow() {
let now = Date(timeIntervalSince1970: 0)
// 300-minute (5-hour) window, 2 hours remaining => 3 hours elapsed
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 }
// elapsed = 3h of 5h => expected = 60%
#expect(abs(pace.expectedUsedPercent - 60.0) < 0.01)
// delta = 50 - 60 = -10 => behind (in reserve)
#expect(abs(pace.deltaPercent - (-10.0)) < 0.01)
#expect(pace.stage == .behind)
#expect(pace.willLastToReset == true)
}

@Test
func weeklyPace_hidesWhenUsageExistsButNoElapsed() {
let now = Date(timeIntervalSince1970: 0)
Expand Down
82 changes: 82 additions & 0 deletions Tests/CodexBarTests/UsagePaceTextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,86 @@ struct UsagePaceTextTests {

#expect(detail == nil)
}

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

@Test
func sessionPaceDetail_providesLeftRightLabels() {
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 sessionPaceDetail_reportsLastsUntilReset() {
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 sessionPaceSummary_formatsSingleLineText() {
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 sessionPaceDetail_hidesForUnsupportedProvider() {
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 sessionPaceDetail_hidesWhenResetIsMissing() {
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)
}
}