diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f43c41..ec33a5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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! diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index b42b198a..cdd1cfa1 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -31,6 +31,10 @@ struct UsageMenuCardView: View { let percentStyle: PercentStyle let resetText: String? let detailText: String? + let detailLeftText: String? + let detailRightText: String? + let pacePercent: Double? + let paceOnTop: Bool var percentLabel: String { String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) @@ -105,32 +109,10 @@ struct UsageMenuCardView: View { VStack(alignment: .leading, spacing: 12) { if hasUsage { VStack(alignment: .leading, spacing: 12) { - ForEach(self.model.metrics) { metric in - VStack(alignment: .leading, spacing: 6) { - Text(metric.title) - .font(.body) - .fontWeight(.medium) - UsageProgressBar( - percent: metric.percent, - tint: self.model.progressColor, - accessibilityLabel: metric.percentStyle.accessibilityLabel) - HStack(alignment: .firstTextBaseline) { - Text(metric.percentLabel) - .font(.footnote) - Spacer() - if let reset = metric.resetText { - Text(reset) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - } - } - if let detail = metric.detailText { - Text(detail) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - } - } + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + progressColor: self.model.progressColor) } } } @@ -215,44 +197,25 @@ private struct UsageMenuCardHeaderView: View { .font(.subheadline) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } - if self.model.subtitleStyle == .error { - VStack(alignment: .leading, spacing: 4) { - Text(self.model.subtitleText) - .font(.footnote) - .foregroundStyle(self.subtitleColor) - .lineLimit(6) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - HStack(alignment: .firstTextBaseline) { - if !self.model.subtitleText.isEmpty { - CopyIconButton(copyText: self.model.subtitleText, isHighlighted: self.isHighlighted) - } - Spacer() - if let plan = self.model.planText { - Text(plan) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - } - } + let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline + HStack(alignment: subtitleAlignment) { + Text(self.model.subtitleText) + .font(.footnote) + .foregroundStyle(self.subtitleColor) + .lineLimit(self.model.subtitleStyle == .error ? 4 : 1) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + .padding(.bottom, self.model.subtitleStyle == .error ? 4 : 0) + Spacer() + if self.model.subtitleStyle == .error, !self.model.subtitleText.isEmpty { + CopyIconButton(copyText: self.model.subtitleText, isHighlighted: self.isHighlighted) } - } else { - HStack(alignment: .firstTextBaseline) { - Text(self.model.subtitleText) + if let plan = self.model.planText { + Text(plan) .font(.footnote) - .foregroundStyle(self.subtitleColor) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .layoutPriority(1) - Spacer() - if let plan = self.model.planText { - Text(plan) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - } } } } @@ -345,6 +308,64 @@ private struct ProviderCostContent: View { } } +private struct MetricRow: View { + let metric: UsageMenuCardView.Model.Metric + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(self.metric.title) + .font(.body) + .fontWeight(.medium) + UsageProgressBar( + percent: self.metric.percent, + tint: self.progressColor, + accessibilityLabel: self.metric.percentStyle.accessibilityLabel, + pacePercent: self.metric.pacePercent, + paceOnTop: self.metric.paceOnTop) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline) { + Text(self.metric.percentLabel) + .font(.footnote) + .lineLimit(1) + Spacer() + if let rightLabel = self.metric.resetText { + Text(rightLabel) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + if self.metric.detailLeftText != nil || self.metric.detailRightText != nil { + HStack(alignment: .firstTextBaseline) { + if let detailLeft = self.metric.detailLeftText { + Text(detailLeft) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + } + Spacer() + if let detailRight = self.metric.detailRightText { + Text(detailRight) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + } + } + if let detail = self.metric.detailText { + Text(detail) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + struct UsageMenuCardHeaderSectionView: View { let model: UsageMenuCardView.Model let showDivider: Bool @@ -381,32 +402,10 @@ struct UsageMenuCardUsageSectionView: View { .font(.subheadline) } } else { - ForEach(self.model.metrics) { metric in - VStack(alignment: .leading, spacing: 6) { - Text(metric.title) - .font(.body) - .fontWeight(.medium) - UsageProgressBar( - percent: metric.percent, - tint: self.model.progressColor, - accessibilityLabel: metric.percentStyle.accessibilityLabel) - HStack(alignment: .firstTextBaseline) { - Text(metric.percentLabel) - .font(.footnote) - Spacer() - if let reset = metric.resetText { - Text(reset) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - } - } - if let detail = metric.detailText { - Text(detail) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - } - } + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + progressColor: self.model.progressColor) } } if self.showBottomDivider { @@ -512,10 +511,7 @@ private struct CreditsBarContent: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) .overlay { - let copyText = self.hintCopyText ?? hintText - if !copyText.isEmpty { - ClickToCopyOverlay(copyText: copyText) - } + ClickToCopyOverlay(copyText: self.hintCopyText ?? hintText) } } } @@ -618,12 +614,13 @@ extension UsageMenuCardView.Model { } static func make(_ input: Input) -> UsageMenuCardView.Model { - let email = Self.email( + let email = PersonalInfoRedactor.redactEmail( + Self.email( for: input.provider, snapshot: input.snapshot, account: input.account, - metadata: input.metadata, - hidePersonalInfo: input.hidePersonalInfo) + metadata: input.metadata), + isEnabled: input.hidePersonalInfo) let planText = Self.plan( for: input.provider, snapshot: input.snapshot, @@ -635,15 +632,9 @@ extension UsageMenuCardView.Model { } else { Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) } - let creditsHintText = Self.dashboardHint( - provider: input.provider, - error: input.dashboardError, - hidePersonalInfo: input.hidePersonalInfo) - let creditsHintCopyText: String? = { - guard let error = input.dashboardError, !error.isEmpty else { return nil } - if input.hidePersonalInfo { return "" } - return error - }() + let creditsHintText = PersonalInfoRedactor.redactEmails( + in: Self.dashboardHint(provider: input.provider, error: input.dashboardError), + isEnabled: input.hidePersonalInfo) let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage { nil } else { @@ -653,26 +644,27 @@ extension UsageMenuCardView.Model { provider: input.provider, enabled: input.tokenCostUsageEnabled, snapshot: input.tokenSnapshot, - error: input.tokenError, - hidePersonalInfo: input.hidePersonalInfo) + error: input.tokenError) let subtitle = Self.subtitle( snapshot: input.snapshot, isRefreshing: input.isRefreshing, - lastError: input.lastError, - hidePersonalInfo: input.hidePersonalInfo) + lastError: input.lastError) + let subtitleText = PersonalInfoRedactor.redactEmails(in: subtitle.text, isEnabled: input.hidePersonalInfo) let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil return UsageMenuCardView.Model( providerName: input.metadata.displayName, email: email, - subtitleText: subtitle.text, + subtitleText: subtitleText ?? subtitle.text, subtitleStyle: subtitle.style, planText: planText, metrics: metrics, creditsText: creditsText, creditsRemaining: input.credits?.remaining, creditsHintText: creditsHintText, - creditsHintCopyText: creditsHintCopyText, + creditsHintCopyText: Self.creditsHintCopyText( + dashboardError: input.dashboardError, + hidePersonalInfo: input.hidePersonalInfo), providerCost: providerCost, tokenUsage: tokenUsage, placeholder: placeholder, @@ -683,16 +675,13 @@ extension UsageMenuCardView.Model { for provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo, - metadata: ProviderMetadata, - hidePersonalInfo: Bool) -> String + metadata: ProviderMetadata) -> String { - if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { - return PersonalInfoRedactor.redactEmail(email, isEnabled: hidePersonalInfo) - } + if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { return email } if metadata.usesAccountFallback, let email = account.email, !email.isEmpty { - return PersonalInfoRedactor.redactEmail(email, isEnabled: hidePersonalInfo) + return email } return "" } @@ -722,13 +711,10 @@ extension UsageMenuCardView.Model { private static func subtitle( snapshot: UsageSnapshot?, isRefreshing: Bool, - lastError: String?, - hidePersonalInfo: Bool) -> (text: String, style: SubtitleStyle) + lastError: String?) -> (text: String, style: SubtitleStyle) { if let lastError, !lastError.isEmpty { - let trimmed = lastError.trimmingCharacters(in: .whitespacesAndNewlines) - let redacted = PersonalInfoRedactor.redactEmails(in: trimmed, isEnabled: hidePersonalInfo) ?? trimmed - return (redacted, .error) + return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error) } if isRefreshing, snapshot == nil { @@ -742,6 +728,11 @@ extension UsageMenuCardView.Model { return ("Not fetched yet", .info) } + private static func creditsHintCopyText(dashboardError: String?, hidePersonalInfo: Bool) -> String? { + guard let dashboardError, !dashboardError.isEmpty else { return nil } + return hidePersonalInfo ? "" : dashboardError + } + private static func metrics(input: Input) -> [Metric] { guard let snapshot = input.snapshot else { return [] } var metrics: [Metric] = [] @@ -749,13 +740,6 @@ extension UsageMenuCardView.Model { let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) - let minimaxUsage = input.provider == .minimax ? snapshot.minimaxUsage : nil - let minimaxPromptDetail = Self.minimaxPromptDetailText(usage: minimaxUsage) - let primaryDetailText: String? = { - if input.provider == .zai { return zaiTokenDetail } - if input.provider == .minimax { return minimaxPromptDetail } - return nil - }() if let primary = snapshot.primary { metrics.append(Metric( id: "primary", @@ -764,17 +748,29 @@ extension UsageMenuCardView.Model { input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now), - detailText: primaryDetailText)) + detailText: input.provider == .zai ? zaiTokenDetail : nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) } if let weekly = snapshot.secondary { - let paceText = UsagePaceText.weekly(provider: input.provider, window: weekly, now: input.now) + let paceDetail = Self.weeklyPaceDetail( + provider: input.provider, + window: weekly, + now: input.now, + showUsed: input.usageBarsShowUsed) metrics.append(Metric( id: "secondary", title: input.metadata.weeklyLabel, percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now), - detailText: input.provider == .zai ? zaiTimeDetail : paceText)) + detailText: input.provider == .zai ? zaiTimeDetail : nil, + detailLeftText: paceDetail?.leftLabel, + detailRightText: paceDetail?.rightLabel, + pacePercent: paceDetail?.pacePercent, + paceOnTop: paceDetail?.paceOnTop ?? true)) } if input.metadata.supportsOpus, let opus = snapshot.tertiary { metrics.append(Metric( @@ -783,7 +779,11 @@ extension UsageMenuCardView.Model { percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now), - detailText: nil)) + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) } if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent { @@ -794,7 +794,11 @@ extension UsageMenuCardView.Model { percent: Self.clamped(percent), percentStyle: percentStyle, resetText: nil, - detailText: nil)) + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) } return metrics } @@ -807,18 +811,32 @@ extension UsageMenuCardView.Model { return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)" } - private static func minimaxPromptDetailText(usage: MiniMaxUsageSnapshot?) -> String? { - guard let usage else { return nil } - guard let current = usage.currentPrompts, - let total = usage.availablePrompts, - let remaining = usage.remainingPrompts - else { - return nil - } - let currentStr = UsageFormatter.tokenCountString(current) - let totalStr = UsageFormatter.tokenCountString(total) - let remainingStr = UsageFormatter.tokenCountString(remaining) - return "\(currentStr) / \(totalStr) (\(remainingStr) remaining)" + private struct PaceDetail { + let leftLabel: String + let rightLabel: String? + let pacePercent: Double? + let paceOnTop: Bool + } + + private static func weeklyPaceDetail( + provider: UsageProvider, + window: RateWindow, + now: Date, + showUsed: Bool) -> PaceDetail? + { + guard let detail = UsagePaceText.weeklyDetail(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 creditsLine( @@ -836,22 +854,17 @@ extension UsageMenuCardView.Model { return metadata.creditsHint } - private static func dashboardHint( - provider: UsageProvider, - error: String?, - hidePersonalInfo: Bool) -> String? - { + private static func dashboardHint(provider: UsageProvider, error: String?) -> String? { guard provider == .codex else { return nil } guard let error, !error.isEmpty else { return nil } - return PersonalInfoRedactor.redactEmails(in: error, isEnabled: hidePersonalInfo) ?? error + return error } private static func tokenUsageSection( provider: UsageProvider, enabled: Bool, snapshot: CostUsageTokenSnapshot?, - error: String?, - hidePersonalInfo: Bool) -> TokenUsageSection? + error: String?) -> TokenUsageSection? { guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } guard enabled else { return nil } @@ -877,13 +890,12 @@ extension UsageMenuCardView.Model { return "Last 30 days: \(monthCost)" }() let err = (error?.isEmpty ?? true) ? nil : error - let redacted = PersonalInfoRedactor.redactEmails(in: err, isEnabled: hidePersonalInfo) return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, hintLine: nil, - errorLine: redacted ?? err, - errorCopyText: (error?.isEmpty ?? true) ? nil : (redacted ?? error)) + errorLine: err, + errorCopyText: (error?.isEmpty ?? true) ? nil : error) } private static func providerCostSection( diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 7d96fb9b..b890c637 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -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)) diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 61818c7d..d289c03e 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -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" } @@ -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 + } } diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 6e2d31c6..54945978 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -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)) } } diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index ba648400..cf0598d7 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -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 = """ diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 2b927c79..86c49a8f 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -6,7 +6,7 @@ import Testing @Suite struct UsagePaceTextTests { @Test - func weeklyPaceText_includesEtaWhenRunningOutBeforeReset() { + func weeklyPaceDetail_providesLeftRightLabels() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -14,13 +14,14 @@ struct UsagePaceTextTests { resetsAt: now.addingTimeInterval(4 * 24 * 3600), resetDescription: nil) - let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, now: now) - #expect(text == "Pace: Ahead (+7%) · Runs out in 3d") + #expect(detail?.leftLabel == "7% in deficit") + #expect(detail?.rightLabel == "Runs out in 3d") } @Test - func weeklyPaceText_showsResetSafeWhenPaceIsSlow() { + func weeklyPaceDetail_reportsLastsUntilReset() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 10, @@ -28,13 +29,28 @@ struct UsagePaceTextTests { resetsAt: now.addingTimeInterval(4 * 24 * 3600), resetDescription: nil) - let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, now: now) - #expect(text == "Pace: Behind (-33%) · Lasts to reset") + #expect(detail?.leftLabel == "33% in reserve") + #expect(detail?.rightLabel == "Lasts until reset") } @Test - func weeklyPaceText_hidesWhenResetIsMissing() { + func weeklyPaceSummary_formatsSingleLineText() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let summary = UsagePaceText.weeklySummary(provider: .codex, window: window, now: now) + + #expect(summary == "Pace: 7% in deficit · Runs out in 3d") + } + + @Test + func weeklyPaceDetail_hidesWhenResetIsMissing() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 10, @@ -42,13 +58,13 @@ struct UsagePaceTextTests { resetsAt: nil, resetDescription: nil) - let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, now: now) - #expect(text == nil) + #expect(detail == nil) } @Test - func weeklyPaceText_hidesWhenResetIsInPastOrTooFar() { + func weeklyPaceDetail_hidesWhenResetIsInPastOrTooFar() { let now = Date(timeIntervalSince1970: 0) let pastWindow = RateWindow( usedPercent: 10, @@ -61,12 +77,12 @@ struct UsagePaceTextTests { resetsAt: now.addingTimeInterval(9 * 24 * 3600), resetDescription: nil) - #expect(UsagePaceText.weekly(provider: .codex, window: pastWindow, now: now) == nil) - #expect(UsagePaceText.weekly(provider: .codex, window: farFutureWindow, now: now) == nil) + #expect(UsagePaceText.weeklyDetail(provider: .codex, window: pastWindow, now: now) == nil) + #expect(UsagePaceText.weeklyDetail(provider: .codex, window: farFutureWindow, now: now) == nil) } @Test - func weeklyPaceText_hidesWhenNoElapsedButUsageExists() { + func weeklyPaceDetail_hidesWhenNoElapsedButUsageExists() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 5, @@ -74,13 +90,13 @@ struct UsagePaceTextTests { resetsAt: now.addingTimeInterval(7 * 24 * 3600), resetDescription: nil) - let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, now: now) - #expect(text == nil) + #expect(detail == nil) } @Test - func weeklyPaceText_hidesWhenTooEarlyInWindow() { + func weeklyPaceDetail_hidesWhenTooEarlyInWindow() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 40, @@ -88,13 +104,13 @@ struct UsagePaceTextTests { resetsAt: now.addingTimeInterval((7 * 24 * 3600) - (60 * 60)), resetDescription: nil) - let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, now: now) - #expect(text == nil) + #expect(detail == nil) } @Test - func weeklyPaceText_hidesWhenUsageIsDepleted() { + func weeklyPaceDetail_hidesWhenUsageIsDepleted() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 100, @@ -102,8 +118,8 @@ struct UsagePaceTextTests { resetsAt: now.addingTimeInterval(2 * 24 * 3600), resetDescription: nil) - let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, now: now) - #expect(text == nil) + #expect(detail == nil) } }