diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d569b0d43..2ab692c539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 33048c9b30..c59a4977c7 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -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) @@ -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) + } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 62e7ecdafc..fb3b20f8b8 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -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 @@ -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" @@ -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?, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 5afc44eb44..d18c1b45ea 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -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? = { diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 94d1ed5653..f24f1d0298 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -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)" + } } diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index c1bfd769a3..0de92b0986 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -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) + } } diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 987f2d1e8e..a5f6874565 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -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) + } }