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 @@ -46,6 +46,7 @@
- Menu: add a toggle to show reset times as absolute clock values (instead of countdowns).
- Menu: show an “Open Terminal” action when Claude OAuth fails.
- Menu: add “Hide personal information” toggle and redact emails in menu UI (#137). Thanks @t3dotgg!
- Menu: keep a pace summary line alongside the visual marker (#155). Thanks @antons!
- Menu: reduce provider-switch flicker and avoid redundant menu card sizing for faster opens (#132). Thanks @ibehnam!
- Menu: keep background refresh on open without forcing token usage (#158). Thanks @weequan93!
- Menu: Cursor switcher shows On-Demand remaining when Plan is exhausted in show-remaining mode (#193). Thanks @vltansky!
Expand Down
322 changes: 167 additions & 155 deletions Sources/CodexBar/MenuCardView.swift

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ struct MenuDescriptor {
title: meta.weeklyLabel,
window: weekly,
resetStyle: resetStyle)
if let paceText = UsagePaceText.weekly(provider: provider, window: weekly) {
entries.append(.text(paceText, .secondary))
if let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: weekly) {
entries.append(.text(paceSummary, .secondary))
}
} else if provider == .claude {
entries.append(.text("Weekly usage unavailable for this account.", .secondary))
Expand Down
62 changes: 38 additions & 24 deletions Sources/CodexBar/UsagePaceText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,46 @@ import CodexBarCore
import Foundation

enum UsagePaceText {
private static let minimumExpectedPercent: Double = 3

static func weekly(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? {
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 pace.expectedUsedPercent >= Self.minimumExpectedPercent else { return nil }
struct WeeklyDetail: Sendable {
let leftLabel: String
let rightLabel: String?
let expectedUsedPercent: Double
let stage: UsagePace.Stage
}

let label = Self.label(for: pace.stage)
let deltaSuffix = Self.deltaSuffix(for: pace)
let etaSuffix = Self.etaSuffix(for: pace, now: now)
private static let minimumExpectedPercent: Double = 3

if let etaSuffix {
return "Pace: \(label)\(deltaSuffix) · \(etaSuffix)"
static func weeklySummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? {
guard let detail = weeklyDetail(provider: provider, window: window, now: now) else { return nil }
if let rightLabel = detail.rightLabel {
return "Pace: \(detail.leftLabel) · \(rightLabel)"
}
return "Pace: \(label)\(deltaSuffix)"
return "Pace: \(detail.leftLabel)"
}

private static func label(for stage: UsagePace.Stage) -> String {
switch stage {
case .onTrack: "On pace"
case .slightlyAhead, .ahead, .farAhead: "Ahead"
case .slightlyBehind, .behind, .farBehind: "Behind"
}
static func weeklyDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? {
guard let pace = weeklyPace(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)
}

private static func deltaSuffix(for pace: UsagePace) -> String {
private static func detailLeftLabel(for pace: UsagePace) -> String {
let deltaValue = Int(abs(pace.deltaPercent).rounded())
let sign = pace.deltaPercent >= 0 ? "+" : "-"
return " (\(sign)\(deltaValue)%)"
switch pace.stage {
case .onTrack:
return "On pace"
case .slightlyAhead, .ahead, .farAhead:
return "\(deltaValue)% in deficit"
case .slightlyBehind, .behind, .farBehind:
return "\(deltaValue)% in reserve"
}
}

private static func etaSuffix(for pace: UsagePace, now: Date) -> String? {
if pace.willLastToReset { return "Lasts to reset" }
private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? {
if pace.willLastToReset { return "Lasts until reset" }
guard let etaSeconds = pace.etaSeconds else { return nil }
let etaText = Self.durationText(seconds: etaSeconds, now: now)
if etaText == "now" { return "Runs out now" }
Expand All @@ -49,4 +55,12 @@ enum UsagePaceText {
if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) }
return countdown
}

private static func weeklyPace(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: 10080) else { return nil }
guard pace.expectedUsedPercent >= Self.minimumExpectedPercent else { return nil }
return pace
}
}
162 changes: 157 additions & 5 deletions Sources/CodexBar/UsageProgressBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,181 @@ import SwiftUI

/// Static progress fill with no implicit animations, used inside the menu card.
struct UsageProgressBar: View {
private static let paceStripeCount = 3
private static func paceStripeWidth(for scale: CGFloat) -> CGFloat {
2
}

private static func paceStripeSpan(for scale: CGFloat) -> CGFloat {
let stripeCount = max(1, Self.paceStripeCount)
return Self.paceStripeWidth(for: scale) * CGFloat(stripeCount)
}

let percent: Double
let tint: Color
let accessibilityLabel: String
let pacePercent: Double?
let paceOnTop: Bool
@Environment(\.menuItemHighlighted) private var isHighlighted
@Environment(\.colorScheme) private var colorScheme
@Environment(\.displayScale) private var displayScale

init(
percent: Double,
tint: Color,
accessibilityLabel: String,
pacePercent: Double? = nil,
paceOnTop: Bool = true)
{
self.percent = percent
self.tint = tint
self.accessibilityLabel = accessibilityLabel
self.pacePercent = pacePercent
self.paceOnTop = paceOnTop
}

private var clamped: Double {
min(100, max(0, self.percent))
}

private var tipMaxOpacity: Double {
if self.isHighlighted {
return 0.55
}
return 0.15
}

private var tipMidOpacity: Double {
self.tipMaxOpacity * 0.5
}

var body: some View {
GeometryReader { proxy in
let scale = max(self.displayScale, 1)
let fillWidth = proxy.size.width * self.clamped / 100
ZStack(alignment: .leading) {
let paceWidth = proxy.size.width * Self.clampedPercent(self.pacePercent) / 100
let tipWidth = max(25, proxy.size.height * 6.5)
let stripeInset = 1 / scale
let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset
let showTip = self.pacePercent != nil && tipWidth > 0.5
let needsPunchCompositing = showTip
let bar = ZStack(alignment: .leading) {
Capsule()
.fill(MenuHighlightStyle.progressTrack(self.isHighlighted))
Capsule()
.fill(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))
.frame(width: fillWidth)
self.actualBar(width: fillWidth)
if showTip {
self.paceTip(width: tipWidth)
.offset(x: tipOffset)
}
}
.clipped()
if self.isHighlighted {
bar
.compositingGroup()
.drawingGroup()
} else if needsPunchCompositing {
bar
.compositingGroup()
} else {
bar
}
}
.frame(height: 6)
.accessibilityLabel(self.accessibilityLabel)
.accessibilityValue("\(Int(self.clamped)) percent")
.drawingGroup()
}

private func actualBar(width: CGFloat) -> some View {
Capsule()
.fill(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))
.frame(width: width)
.contentShape(Rectangle())
.allowsHitTesting(false)
}

private func paceTip(width: CGFloat) -> some View {
let isDeficit = self.paceOnTop == false
let useDeficitRed = isDeficit && self.isHighlighted == false
func stripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) {
let rect = CGRect(origin: .zero, size: size)
let extend = size.height * 2
let stripeTopY: CGFloat = -extend
let stripeBottomY: CGFloat = size.height + extend
let align: (CGFloat) -> CGFloat = { value in
(value * scale).rounded() / scale
}

let stripeWidth = Self.paceStripeWidth(for: scale)
let punchWidth = stripeWidth * 3
let stripeInset = 1 / scale
let stripeAnchorX = align(rect.maxX - stripeInset)
let stripeMinY = align(stripeTopY)
let stripeMaxY = align(stripeBottomY)
let anchorTopX = stripeAnchorX
var punchedStripe = Path()
var centerStripe = Path()
let availableWidth = (anchorTopX - punchWidth) - rect.minX
guard availableWidth >= 0 else { return (punchedStripe, centerStripe) }

let punchRightTopX = align(anchorTopX)
let punchLeftTopX = punchRightTopX - punchWidth
let punchRightBottomX = punchRightTopX
let punchLeftBottomX = punchLeftTopX
punchedStripe.addPath(Path { path in
path.move(to: CGPoint(x: punchLeftTopX, y: stripeMinY))
path.addLine(to: CGPoint(x: punchRightTopX, y: stripeMinY))
path.addLine(to: CGPoint(x: punchRightBottomX, y: stripeMaxY))
path.addLine(to: CGPoint(x: punchLeftBottomX, y: stripeMaxY))
path.closeSubpath()
})

let centerLeftTopX = align(punchLeftTopX + (punchWidth - stripeWidth) / 2)
let centerRightTopX = centerLeftTopX + stripeWidth
let centerRightBottomX = centerRightTopX
let centerLeftBottomX = centerLeftTopX
centerStripe.addPath(Path { path in
path.move(to: CGPoint(x: centerLeftTopX, y: stripeMinY))
path.addLine(to: CGPoint(x: centerRightTopX, y: stripeMinY))
path.addLine(to: CGPoint(x: centerRightBottomX, y: stripeMaxY))
path.addLine(to: CGPoint(x: centerLeftBottomX, y: stripeMaxY))
path.closeSubpath()
})

return (punchedStripe, centerStripe)
}

return ZStack {
Canvas { context, size in
let rect = CGRect(origin: .zero, size: size)
let scale = max(self.displayScale, 1)
context.clip(to: Path(rect))
let stripes = stripePaths(size: size, scale: scale)
context.fill(stripes.punched, with: .color(.white.opacity(0.9)))
}
.blendMode(.destinationOut)

Canvas { context, size in
let rect = CGRect(origin: .zero, size: size)
let scale = max(self.displayScale, 1)
context.clip(to: Path(rect))
let stripes = stripePaths(size: size, scale: scale)
let stripeColor: Color = if self.isHighlighted {
.white
} else if useDeficitRed {
.red
} else {
.green
}
context.fill(stripes.center, with: .color(stripeColor))
}
}
.frame(width: width)
.contentShape(Rectangle())
.allowsHitTesting(false)
}

private static func clampedPercent(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(100, max(0, value))
}
}
28 changes: 28 additions & 0 deletions Tests/CodexBarTests/StatusProbeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,34 @@ struct StatusProbeTests {
#expect(snap.accountOrganization == "ACME")
}

@Test
func parseClaudeStatusWithExtraUsageSection() throws {
let sample = """
Settings: Status Config Usage (tab to cycle)

Current session
▌ 1% used
Resets 3:59pm (Europe/Helsinki)

Current week (all models)
▌ 1% used
Resets Jan 2, 2026, 10:59pm (Europe/Helsinki)

Current week (Sonnet only)
0% used

Extra usage
Extra usage not enabled • /extra-usage to enable
"""

let snap = try ClaudeStatusProbe.parse(text: sample)
#expect(snap.sessionPercentLeft == 99)
#expect(snap.weeklyPercentLeft == 99)
#expect(snap.opusPercentLeft == 100)
#expect(snap.primaryResetDescription == "Resets 3:59pm (Europe/Helsinki)")
#expect(snap.secondaryResetDescription == "Resets Jan 2, 2026, 10:59pm (Europe/Helsinki)")
}

@Test
func parseClaudeStatusWithBracketPlanNoiseNoEsc() throws {
let sample = """
Expand Down
Loading
Loading