From 6aadf0d1fd5265781a2495a7d24407e8f7efba0c Mon Sep 17 00:00:00 2001 From: Shun Min Chang Date: Wed, 15 Jul 2026 10:16:31 +0800 Subject: [PATCH 1/3] Improve usage detail layout Split usage pace ETA and run-out risk across two rows. Show all reset-credit expiries with consistent compact dates. Keep Preferences, accessibility, and height fingerprints aligned. --- .../CodexBar/MenuCardHeightFingerprint.swift | 6 +- .../MenuCardView+CodexResetCredits.swift | 54 +++++++++++---- .../CodexBar/MenuCardView+ModelHelpers.swift | 9 +++ Sources/CodexBar/MenuCardView.swift | 36 +++++----- .../PreferencesProviderDetailView.swift | 28 ++++++-- Sources/CodexBar/UsageMenuCardLayout.swift | 67 ++++++++++++++++++ Sources/CodexBar/UsagePaceText.swift | 45 ++++++++---- .../CodexResetCreditsMenuCardTests.swift | 23 ++++--- .../HistoricalUsagePaceTests.swift | 3 +- .../MenuCardHeightFingerprintTests.swift | 35 +++++++++- .../UsageMenuCardLayoutTests.swift | 68 +++++++++++++++++++ Tests/CodexBarTests/UsagePaceTextTests.swift | 12 ++-- 12 files changed, 316 insertions(+), 70 deletions(-) diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 834e678008..40a6db7cd1 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -86,6 +86,7 @@ extension UsageMenuCardView.Model.Metric { MenuCardHeightFingerprint.field("detail", self.detailText), MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText), MenuCardHeightFingerprint.field("detailRight", self.detailRightText), + self.detailRightSecondaryText == nil ? "detailRightSecondary=0" : "detailRightSecondary=1", self.pacePercent == nil ? "pace=0" : "pace=1", self.paceOnTop ? "paceTop=1" : "paceTop=0", self.cardStyle ? "card=1" : "card=0", @@ -122,10 +123,7 @@ extension UsageMenuCardView.Model.TokenUsageSection { extension CodexResetCreditsPresentation { fileprivate var heightFingerprint: String { - MenuCardHeightFingerprint.join([ - MenuCardHeightFingerprint.field("text", self.text), - MenuCardHeightFingerprint.field("expirySummary", self.expirySummaryText), - ]) + "items=\(self.items.count)" } } diff --git a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift index fa6b2d4da3..ab0e14b1b7 100644 --- a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift +++ b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift @@ -10,11 +10,8 @@ struct CodexResetCreditsPresentation: Equatable { let text: String let items: [CodexResetCreditPresentationItem] - var expirySummaryText: String { - let visibleItems = self.items.prefix(4).map(\.compactExpiryText) - let hiddenCount = self.items.count - visibleItems.count - let suffix = hiddenCount > 0 ? ["+\(hiddenCount)"] : [] - return (visibleItems + suffix).joined(separator: " · ") + var compactExpiryTexts: [String] { + self.items.map(\.compactExpiryText) } var helpText: String { @@ -57,14 +54,37 @@ struct CodexResetCreditsPresentation: Equatable { return CodexResetCreditPresentationItem(expiryText: L("No expiry"), compactExpiryText: L("No expiry")) } let formattedTime = Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now) - let compactExpiryText = resetStyle == .countdown && formattedTime.hasPrefix("in ") - ? String(formattedTime.dropFirst(3)) - : formattedTime + let compactExpiryText = Self.compactExpiryText( + expiresAt, + resetStyle: resetStyle, + formattedTime: formattedTime) return CodexResetCreditPresentationItem( expiryText: String(format: L("Expires %@"), formattedTime), compactExpiryText: compactExpiryText) } + private static func compactExpiryText( + _ expiresAt: Date, + resetStyle: ResetTimeDisplayStyle, + formattedTime: String) -> String + { + switch resetStyle { + case .absolute: + self.fixedCompactDateText(expiresAt) + case .countdown: + formattedTime.hasPrefix("in ") ? String(formattedTime.dropFirst(3)) : formattedTime + } + } + + private static func fixedCompactDateText(_ date: Date) -> String { + let components = Calendar.current.dateComponents([.month, .day, .hour, .minute], from: date) + let month = components.month ?? 0 + let day = components.day ?? 0 + let hour = components.hour ?? 0 + let minute = components.minute ?? 0 + return String(format: "%02d/%02d %02d:%02d", month, day, hour, minute) + } + private static func formattedTime( _ expiresAt: Date, resetStyle: ResetTimeDisplayStyle, @@ -90,21 +110,25 @@ struct CodexResetCreditsContent: View { .font(.body) .fontWeight(.medium) .lineLimit(1) - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .top, spacing: 8) { Text(self.presentation.text) .font(.footnote.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) .lineLimit(1) .layoutPriority(1) Spacer(minLength: 8) - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .top, spacing: 4) { Image(systemName: "clock") .font(.caption2) - Text(self.presentation.expirySummaryText) - .font(.caption) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - .minimumScaleFactor(0.8) + VStack(alignment: .leading, spacing: 2) { + ForEach(self.presentation.items.indices, id: \.self) { index in + Text(self.presentation.items[index].compactExpiryText) + .font(.caption) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + } } .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .accessibilityHidden(true) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 5668429c77..45e76d1bad 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -5,6 +5,7 @@ extension UsageMenuCardView.Model { struct PaceDetail { let leftLabel: String let rightLabel: String? + var riskLabel: String? let pacePercent: Double? let paceOnTop: Bool } @@ -41,6 +42,9 @@ extension UsageMenuCardView.Model { metricID: metric.id), detailLeftText: PersonalInfoRedactor.redactEmails(in: metric.detailLeftText, isEnabled: true), detailRightText: PersonalInfoRedactor.redactEmails(in: metric.detailRightText, isEnabled: true), + detailRightSecondaryText: PersonalInfoRedactor.redactEmails( + in: metric.detailRightSecondaryText, + isEnabled: true), pacePercent: metric.pacePercent, paceOnTop: metric.paceOnTop, warningMarkerPercents: metric.warningMarkerPercents, @@ -185,6 +189,7 @@ extension UsageMenuCardView.Model { (current.detailText == nil) == (candidate.detailText == nil) && (current.detailLeftText == nil) == (candidate.detailLeftText == nil) && (current.detailRightText == nil) == (candidate.detailRightText == nil) && + (current.detailRightSecondaryText == nil) == (candidate.detailRightSecondaryText == nil) && current.cardStyle == candidate.cardStyle } @@ -453,6 +458,7 @@ extension UsageMenuCardView.Model { return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, + riskLabel: detail.riskLabel, pacePercent: pacePercent, paceOnTop: paceOnTop) } @@ -482,6 +488,7 @@ extension UsageMenuCardView.Model { return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, + riskLabel: detail.riskLabel, pacePercent: pacePercent, paceOnTop: paceOnTop) } @@ -668,6 +675,7 @@ extension UsageMenuCardView.Model { detailText: usageKnown ? detailText : nil, detailLeftText: usageKnown ? paceDetail?.leftLabel : nil, detailRightText: usageKnown ? paceDetail?.rightLabel : nil, + detailRightSecondaryText: usageKnown ? paceDetail?.riskLabel : nil, pacePercent: usageKnown ? paceDetail?.pacePercent : nil, paceOnTop: paceDetail?.paceOnTop ?? true) } @@ -819,6 +827,7 @@ extension UsageMenuCardView.Model { detailText: nil, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, + detailRightSecondaryText: paceDetail?.riskLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true) } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 6da522607f..bfcda73e50 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -34,6 +34,7 @@ struct UsageMenuCardView: View { let detailText: String? let detailLeftText: String? let detailRightText: String? + let detailRightSecondaryText: String? let pacePercent: Double? let paceOnTop: Bool let warningMarkerPercents: [Double] @@ -50,6 +51,7 @@ struct UsageMenuCardView: View { detailText: String?, detailLeftText: String?, detailRightText: String?, + detailRightSecondaryText: String? = nil, pacePercent: Double?, paceOnTop: Bool, warningMarkerPercents: [Double] = [], @@ -65,6 +67,7 @@ struct UsageMenuCardView: View { self.detailText = detailText self.detailLeftText = detailLeftText self.detailRightText = detailRightText + self.detailRightSecondaryText = detailRightSecondaryText self.pacePercent = pacePercent self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents @@ -500,22 +503,14 @@ private struct MetricRow: View { .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 self.metric.detailLeftText != nil || + self.metric.detailRightText != nil || + self.metric.detailRightSecondaryText != nil + { + MetricDetailRow( + leftText: self.metric.detailLeftText, + rightText: self.metric.detailRightText, + secondaryRightText: self.metric.detailRightSecondaryText) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -1205,6 +1200,7 @@ extension UsageMenuCardView.Model { detailText: tertiaryDetailText, detailLeftText: tertiaryPaceDetail?.leftLabel, detailRightText: tertiaryPaceDetail?.rightLabel, + detailRightSecondaryText: tertiaryPaceDetail?.riskLabel, pacePercent: tertiaryPaceDetail?.pacePercent, paceOnTop: tertiaryPaceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( @@ -1263,6 +1259,7 @@ extension UsageMenuCardView.Model { var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) var primaryDetailLeft: String? var primaryDetailRight: String? + var primaryDetailRightSecondary: String? if input.provider == .crof, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty @@ -1325,6 +1322,7 @@ extension UsageMenuCardView.Model { { primaryDetailLeft = paceDetail.leftLabel primaryDetailRight = paceDetail.rightLabel + primaryDetailRightSecondary = paceDetail.riskLabel primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } @@ -1347,6 +1345,7 @@ extension UsageMenuCardView.Model { if let paceDetail { primaryDetailLeft = paceDetail.leftLabel primaryDetailRight = paceDetail.rightLabel + primaryDetailRightSecondary = paceDetail.riskLabel primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } @@ -1354,6 +1353,7 @@ extension UsageMenuCardView.Model { } else if let paceDetail = Self.resetWindowPaceDetail(window: primary, input: input) { primaryDetailLeft = paceDetail.leftLabel primaryDetailRight = paceDetail.rightLabel + primaryDetailRightSecondary = paceDetail.riskLabel primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } @@ -1374,6 +1374,7 @@ extension UsageMenuCardView.Model { primaryResetText = regen.resetText primaryDetailLeft = regen.pace.leftLabel primaryDetailRight = regen.pace.rightLabel + primaryDetailRightSecondary = regen.pace.riskLabel primaryPacePercent = regen.pace.pacePercent primaryPaceOnTop = regen.pace.paceOnTop } @@ -1393,6 +1394,7 @@ extension UsageMenuCardView.Model { detailText: primaryDetailText, detailLeftText: primaryDetailLeft, detailRightText: primaryDetailRight, + detailRightSecondaryText: primaryDetailRightSecondary, pacePercent: primaryPacePercent, paceOnTop: primaryPaceOnTop, warningMarkerPercents: Self.warningMarkerPercents( @@ -1505,6 +1507,7 @@ extension UsageMenuCardView.Model { detailText: weeklyDetailText, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, + detailRightSecondaryText: paceDetail?.riskLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( @@ -1555,6 +1558,7 @@ extension UsageMenuCardView.Model { detailText: nil, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, + detailRightSecondaryText: paceDetail?.riskLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index f2cf4da0c3..15ff031541 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -486,20 +486,31 @@ private struct ProviderMetricInlineRow: View { Text(leftDetail) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(1) } Spacer(minLength: 8) if let rightDetail = self.metric.detailRightText, !rightDetail.isEmpty { Text(rightDetail) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(1) } else if !resetText.isEmpty { Text(resetText) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(1) } } } + if let secondaryRightDetail = self.metric.detailRightSecondaryText, !secondaryRightDetail.isEmpty { + Text(secondaryRightDetail) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + } + if hasRightDetail, !resetText.isEmpty { Text(resetText) .font(.footnote) @@ -531,19 +542,24 @@ private struct ProviderCodexResetCreditsInlineRow: View { .font(.footnote) .foregroundStyle(.secondary) } - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .top, spacing: 4) { Image(systemName: "clock") .font(.caption2) - Text(self.presentation.expirySummaryText) - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(1) - .minimumScaleFactor(0.8) + VStack(alignment: .leading, spacing: 2) { + ForEach(self.presentation.items.indices, id: \.self) { index in + Text(self.presentation.items[index].compactExpiryText) + .font(.caption) + .foregroundStyle(.tertiary) + .monospacedDigit() + .lineLimit(1) + } + } } .frame(maxWidth: .infinity, alignment: .trailing) .accessibilityHidden(true) } .padding(.vertical, 2) + .help(self.presentation.helpText) .accessibilityElement(children: .combine) .accessibilityLabel(self.presentation.accessibilityLabel) } diff --git a/Sources/CodexBar/UsageMenuCardLayout.swift b/Sources/CodexBar/UsageMenuCardLayout.swift index 8814a37cc6..447609a131 100644 --- a/Sources/CodexBar/UsageMenuCardLayout.swift +++ b/Sources/CodexBar/UsageMenuCardLayout.swift @@ -1,4 +1,5 @@ import CoreGraphics +import SwiftUI enum UsageMenuCardLayout { static let horizontalPadding: CGFloat = 20 @@ -15,3 +16,69 @@ enum UsageMenuCardLayout { sectionBottomPadding + usageSectionTopPadding } } + +struct MetricDetailRow: View { + let leftText: String? + let rightText: String? + let secondaryRightText: String? + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + if let secondaryRightText { + self.fixedTwoRow(secondaryRightText: secondaryRightText) + } else { + ViewThatFits(in: .horizontal) { + self.oneRow + self.twoRow + } + } + } + + private func fixedTwoRow(secondaryRightText: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let leftText { + self.detailText(leftText, color: MenuHighlightStyle.primary(self.isHighlighted)) + } + Spacer(minLength: 8) + if let rightText { + self.detailText(rightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .layoutPriority(1) + } + } + self.detailText(secondaryRightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private var oneRow: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let leftText { + self.detailText(leftText, color: MenuHighlightStyle.primary(self.isHighlighted)) + .fixedSize(horizontal: true, vertical: false) + } + Spacer(minLength: 8) + if let rightText { + self.detailText(rightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .fixedSize(horizontal: true, vertical: false) + } + } + } + + private var twoRow: some View { + VStack(alignment: .leading, spacing: 0) { + if let leftText { + self.detailText(leftText, color: MenuHighlightStyle.primary(self.isHighlighted)) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let rightText { + self.detailText(rightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + + private func detailText(_ text: String, color: Color) -> some View { + Text(text).font(.footnote).foregroundStyle(color).lineLimit(1) + } +} diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index fe374a09ea..0f6379f01b 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -5,6 +5,7 @@ enum UsagePaceText { struct WeeklyDetail { let leftLabel: String let rightLabel: String? + let riskLabel: String? let expectedUsedPercent: Double let stage: UsagePace.Stage } @@ -16,16 +17,31 @@ enum UsagePaceText { static func weeklySummary(provider: UsageProvider, pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(provider: provider, pace: pace, now: now) - if let rightLabel = detail.rightLabel { - return L("Pace: %@ · %@", detail.leftLabel, rightLabel) + if let combined = self.joinedRightPortion(right: detail.rightLabel, risk: detail.riskLabel) { + return L("Pace: %@ · %@", detail.leftLabel, combined) } return L("Pace: %@", detail.leftLabel) } + private static func joinedRightPortion(right: String?, risk: String?) -> String? { + switch (right, risk) { + case let (right?, risk?): + L("%@ · %@", right, risk) + case let (right?, nil): + right + case let (nil, risk?): + risk + case (nil, nil): + nil + } + } + static func weeklyDetail(provider: UsageProvider, pace: UsagePace, now: Date = .init()) -> WeeklyDetail { - WeeklyDetail( + let (right, risk) = self.detailRightAndRisk(for: pace, provider: provider, context: .weekly, now: now) + return WeeklyDetail( leftLabel: self.detailLeftLabel(for: pace), - rightLabel: self.detailRightLabel(for: pace, provider: provider, context: .weekly, now: now), + rightLabel: right, + riskLabel: risk, expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } @@ -45,11 +61,11 @@ enum UsagePaceText { } } - private static func detailRightLabel( + private static func detailRightAndRisk( for pace: UsagePace, provider: UsageProvider, context: DetailContext, - now: Date) -> String? + now: Date) -> (right: String?, risk: String?) { let etaLabel: String? if pace.willLastToReset { @@ -65,16 +81,13 @@ enum UsagePaceText { etaLabel = nil } - guard let runOutProbability = pace.runOutProbability else { return etaLabel } + guard let runOutProbability = pace.runOutProbability else { return (etaLabel, nil) } let roundedRisk = self.roundedRiskPercent(runOutProbability) let riskLabel = L("≈ %d%% run-out risk", roundedRisk) if pace.willLastToReset, roundedRisk > 0 { - return riskLabel - } - if let etaLabel { - return L("%@ · %@", etaLabel, riskLabel) + return (nil, riskLabel) } - return riskLabel + return (etaLabel, riskLabel) } private static func combinedLastsLabel(for pace: UsagePace, provider: UsageProvider) -> String { @@ -120,17 +133,19 @@ enum UsagePaceText { static func sessionDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? { guard let pace = sessionPace(provider: provider, window: window, now: now) else { return nil } + let (right, risk) = Self.detailRightAndRisk(for: pace, provider: provider, context: .session, now: now) return WeeklyDetail( leftLabel: Self.detailLeftLabel(for: pace), - rightLabel: Self.detailRightLabel(for: pace, provider: provider, context: .session, now: now), + rightLabel: right, + riskLabel: risk, 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 L("Pace: %@ · %@", detail.leftLabel, rightLabel) + if let combined = self.joinedRightPortion(right: detail.rightLabel, risk: detail.riskLabel) { + return L("Pace: %@ · %@", detail.leftLabel, combined) } return L("Pace: %@", detail.leftLabel) } diff --git a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift index c288149d44..a2170cf0de 100644 --- a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift +++ b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift @@ -23,7 +23,7 @@ struct CodexResetCreditsMenuCardTests { #expect(presentation.text == "3 available") #expect(presentation.items.map(\.expiryText) == ["Expires in 1d", "Expires in 2d", "No expiry"]) - #expect(presentation.expirySummaryText == "1d · 2d · No expiry") + #expect(presentation.compactExpiryTexts == ["1d", "2d", "No expiry"]) #expect(presentation.helpText == "1. Expires in 1d\n2. Expires in 2d\n3. No expiry") #expect(presentation.accessibilityLabel.contains(presentation.helpText)) } @@ -40,14 +40,21 @@ struct CodexResetCreditsMenuCardTests { #expect(presentation.text == "1 available") #expect(presentation.items.map(\.expiryText) == ["No expiry"]) - #expect(presentation.expirySummaryText == "No expiry") + #expect(presentation.compactExpiryTexts == ["No expiry"]) #expect(model.hasUsageContent) } @Test func `inventory respects absolute reset-time style`() throws { - let now = Date(timeIntervalSince1970: 1_781_726_400) - let expiresAt = now.addingTimeInterval(86400) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let expiresAt = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 18, + hour: 8, + minute: 36))) + let now = expiresAt.addingTimeInterval(-86400) let model = try Self.model( snapshot: Self.snapshot( now: now, @@ -58,7 +65,7 @@ struct CodexResetCreditsMenuCardTests { let formatted = UsageFormatter.resetDescription(from: expiresAt, now: now) #expect(presentation.items.map(\.expiryText) == ["Expires \(formatted)"]) - #expect(presentation.expirySummaryText == formatted) + #expect(presentation.compactExpiryTexts == ["07/18 08:36"]) } @Test @@ -72,11 +79,11 @@ struct CodexResetCreditsMenuCardTests { now: now) #expect(model.codexResetCredits?.text == "1 available") - #expect(model.codexResetCredits?.expirySummaryText == "1d") + #expect(model.codexResetCredits?.compactExpiryTexts == ["1d"]) } @Test - func `compact expiry summary caps visible dates`() throws { + func `compact expiry texts preserve full inventory`() throws { let now = Date(timeIntervalSince1970: 1_781_726_400) let credits = (1...6).map { day in Self.credit(id: "day-\(day)", status: .available, now: now, expiresIn: Double(day * 86400)) @@ -84,7 +91,7 @@ struct CodexResetCreditsMenuCardTests { let model = try Self.model(snapshot: Self.snapshot(now: now, credits: credits), now: now) let presentation = try #require(model.codexResetCredits) - #expect(presentation.expirySummaryText == "1d · 2d · 3d · 4d · +2") + #expect(presentation.compactExpiryTexts == ["1d", "2d", "3d", "4d", "5d", "6d"]) #expect(presentation.helpText.split(separator: "\n").count == 6) } diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index e294baa6e7..5f6f1c29b7 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -817,7 +817,8 @@ struct HistoricalUsagePaceTests { #expect(pace.runOutProbability == 1) let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "Runs out now · ≈ 100% run-out risk") + #expect(detail.rightLabel == "Runs out now") + #expect(detail.riskLabel == "≈ 100% run-out risk") } } } diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift index 7e2ec20c12..d3bf1add7b 100644 --- a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -48,10 +48,42 @@ struct MenuCardHeightFingerprintTests { #expect(one.heightFingerprint(section: "card") != two.heightFingerprint(section: "card")) } + @Test + func `height fingerprint ignores reset-credit text when item count is unchanged`() { + func items(fifthCompactExpiryText: String) -> [CodexResetCreditPresentationItem] { + (1...4).map { day in + CodexResetCreditPresentationItem(expiryText: "Expires in \(day)d", compactExpiryText: "\(day)d") + } + [CodexResetCreditPresentationItem( + expiryText: "Expires in 5d", + compactExpiryText: fifthCompactExpiryText)] + } + let one = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "5 available", + items: items(fifthCompactExpiryText: "5d"))) + let two = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "5 available", + items: items(fifthCompactExpiryText: "6d"))) + + #expect(one.heightFingerprint(section: "card") == two.heightFingerprint(section: "card")) + } + + @Test + func `height fingerprint changes when detail right secondary text changes`() { + let withoutRisk = Self.model(detailRightSecondaryText: nil).heightFingerprint(section: "card") + let withRisk = Self.model(detailRightSecondaryText: "≈ 45% run-out risk").heightFingerprint(section: "card") + let withChangedRisk = Self.model(detailRightSecondaryText: "≈ 70% run-out risk") + .heightFingerprint(section: "card") + + #expect(withoutRisk != withRisk) + #expect(withRisk == withChangedRisk) + #expect(!withRisk.contains("45% run-out risk")) + } + private static func model( percent: Double = 42, percentStyle: UsageMenuCardView.Model.PercentStyle = .left, - resetCredits: CodexResetCreditsPresentation? = nil) -> UsageMenuCardView.Model + resetCredits: CodexResetCreditsPresentation? = nil, + detailRightSecondaryText: String? = nil) -> UsageMenuCardView.Model { UsageMenuCardView.Model( provider: .codex, @@ -71,6 +103,7 @@ struct MenuCardHeightFingerprintTests { detailText: nil, detailLeftText: nil, detailRightText: nil, + detailRightSecondaryText: detailRightSecondaryText, pacePercent: nil, paceOnTop: true), ], diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift index 1893cff4e5..387c411885 100644 --- a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -85,6 +85,74 @@ struct UsageMenuCardLayoutTests { UsageMenuCardLayout.sectionBottomPadding) } + @Test + func `metric detail uses one row when it fits and at most two when it does not`() { + let width: CGFloat = 296 + func metricModel(detailLeftText: String, detailRightText: String) -> UsageMenuCardView.Model { + Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: detailLeftText, + detailRightText: detailRightText, + pacePercent: nil, + paceOnTop: true), + ]) + } + let shortModel = metricModel(detailLeftText: "5% ahead", detailRightText: "Done in 1d") + let longModel = metricModel( + detailLeftText: "5% more than current pace", + detailRightText: "Done in 1d 36m · about 75% likely to finish") + let veryLongModel = metricModel( + detailLeftText: "5% more than the current projected pace with additional context", + detailRightText: "Done in 1d 36m · about 75% likely to finish before the weekly reset window") + + let shortHeight = NSHostingController(rootView: UsageMenuCardView(model: shortModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + let longHeight = NSHostingController(rootView: UsageMenuCardView(model: longModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + let veryLongHeight = NSHostingController(rootView: UsageMenuCardView(model: veryLongModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + + #expect(longHeight > shortHeight) + #expect(abs(veryLongHeight - longHeight) < Self.heightTolerance) + } + + @Test + func `metric detail with risk stays at most two rows regardless of secondary length`() { + let width: CGFloat = 296 + func metricModel(detailRightSecondaryText: String) -> UsageMenuCardView.Model { + Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: "5% ahead", + detailRightText: "Runs out in 2d", + detailRightSecondaryText: detailRightSecondaryText, + pacePercent: nil, + paceOnTop: true), + ]) + } + let shortRiskModel = metricModel(detailRightSecondaryText: "≈ 45% run-out risk") + let longRiskModel = metricModel( + detailRightSecondaryText: "≈ 45% run-out risk with a lot of additional descriptive context appended") + + let shortRiskHeight = NSHostingController(rootView: UsageMenuCardView(model: shortRiskModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + let longRiskHeight = NSHostingController(rootView: UsageMenuCardView(model: longRiskModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + + #expect(abs(longRiskHeight - shortRiskHeight) < Self.heightTolerance) + } + private static func model( metrics: [UsageMenuCardView.Model.Metric] = [], usageNotes: [String] = [], diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 69c39d9815..ca254f13c4 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -161,7 +161,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk") + #expect(detail.rightLabel == "Runs out in 2d") + #expect(detail.riskLabel == "≈ 70% run-out risk") } @Test @@ -179,7 +180,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) #expect(detail.leftLabel == "9% in reserve") - #expect(detail.rightLabel == "≈ 45% run-out risk") + #expect(detail.rightLabel == nil) + #expect(detail.riskLabel == "≈ 45% run-out risk") } @Test @@ -197,7 +199,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom · ≈ 0% run-out risk") + #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom") + #expect(detail.riskLabel == "≈ 0% run-out risk") } @Test @@ -214,7 +217,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "≈ 5% run-out risk") + #expect(detail.rightLabel == nil) + #expect(detail.riskLabel == "≈ 5% run-out risk") } // MARK: - Session pace (5-hour window) From c9ca1670cf0c476340db65fdbd65b5e5aed7f40a Mon Sep 17 00:00:00 2001 From: Shun Min Chang Date: Wed, 15 Jul 2026 11:45:10 +0800 Subject: [PATCH 2/3] Fix adaptive metric detail remeasurement Reject live metric updates when adaptive detail text can change row count. Add regression coverage for short-to-long detail transitions. --- .../CodexBar/MenuCardView+ModelHelpers.swift | 7 +++++- .../UsageMenuCardLayoutTests.swift | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 45e76d1bad..582c54f6db 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -181,7 +181,11 @@ extension UsageMenuCardView.Model { } private static func hasCompatibleMetricLayout(_ current: Metric, _ candidate: Metric) -> Bool { - current.id == candidate.id && + // Adaptive detail text can cross the one-row/two-row boundary without changing nil presence. + let hasCompatibleAdaptiveDetailText = current.detailRightSecondaryText != nil || + (current.detailLeftText == candidate.detailLeftText && + current.detailRightText == candidate.detailRightText) + return current.id == candidate.id && current.title == candidate.title && current.percentStyle == candidate.percentStyle && (current.statusText == nil) == (candidate.statusText == nil) && @@ -190,6 +194,7 @@ extension UsageMenuCardView.Model { (current.detailLeftText == nil) == (candidate.detailLeftText == nil) && (current.detailRightText == nil) == (candidate.detailRightText == nil) && (current.detailRightSecondaryText == nil) == (candidate.detailRightSecondaryText == nil) && + hasCompatibleAdaptiveDetailText && current.cardStyle == candidate.cardStyle } diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift index 387c411885..273b2e06b9 100644 --- a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -122,6 +122,31 @@ struct UsageMenuCardLayoutTests { #expect(abs(veryLongHeight - longHeight) < Self.heightTolerance) } + @Test + func `tracked metric layout rejects adaptive detail text changes`() { + func metricModel(detailLeftText: String, detailRightText: String) -> UsageMenuCardView.Model { + Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: detailLeftText, + detailRightText: detailRightText, + pacePercent: nil, + paceOnTop: true), + ]) + } + let shortModel = metricModel(detailLeftText: "5% ahead", detailRightText: "Done in 1d") + let longModel = metricModel( + detailLeftText: "5% more than current pace", + detailRightText: "Done in 1d 36m · about 75% likely to finish") + + #expect(!shortModel.hasCompatibleTrackedLayout(with: longModel)) + } + @Test func `metric detail with risk stays at most two rows regardless of secondary length`() { let width: CGFloat = 296 From a959cade8d9fe943b2e06b3cc40019d2e9778e7c Mon Sep 17 00:00:00 2001 From: Shun Min Chang Date: Wed, 15 Jul 2026 14:04:37 +0800 Subject: [PATCH 3/3] fix(menu): preserve adaptive detail refreshes Normalize numeric detail changes to stable text shapes so refreshes do not retain stale data. Preserve remeasurement for wording changes and cover compatible text shapes with a regression test. Focused tests and make check pass. Full make test remains blocked by existing Claude credentials test failures. --- Sources/CodexBar/MenuCardView+ModelHelpers.swift | 12 +++++++++--- Tests/CodexBarTests/UsageMenuCardLayoutTests.swift | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 582c54f6db..7d4d07b519 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -181,10 +181,12 @@ extension UsageMenuCardView.Model { } private static func hasCompatibleMetricLayout(_ current: Metric, _ candidate: Metric) -> Bool { - // Adaptive detail text can cross the one-row/two-row boundary without changing nil presence. + // Numeric substitutions retain the same text shape; wording changes can cross the one-row/two-row boundary. let hasCompatibleAdaptiveDetailText = current.detailRightSecondaryText != nil || - (current.detailLeftText == candidate.detailLeftText && - current.detailRightText == candidate.detailRightText) + (Self.adaptiveDetailTextShape(current.detailLeftText) == + Self.adaptiveDetailTextShape(candidate.detailLeftText) && + Self.adaptiveDetailTextShape(current.detailRightText) == + Self.adaptiveDetailTextShape(candidate.detailRightText)) return current.id == candidate.id && current.title == candidate.title && current.percentStyle == candidate.percentStyle && @@ -198,6 +200,10 @@ extension UsageMenuCardView.Model { current.cardStyle == candidate.cardStyle } + private static func adaptiveDetailTextShape(_ text: String?) -> String? { + text.map { String($0.map { character in character.isNumber ? "#" : character }) } + } + private static func hasCompatibleCreditsLayout( currentText: String?, currentRemaining: Double?, diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift index 273b2e06b9..299bfe16cf 100644 --- a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -123,7 +123,7 @@ struct UsageMenuCardLayoutTests { } @Test - func `tracked metric layout rejects adaptive detail text changes`() { + func `tracked metric layout distinguishes adaptive detail text shapes`() { func metricModel(detailLeftText: String, detailRightText: String) -> UsageMenuCardView.Model { Self.model(metrics: [ UsageMenuCardView.Model.Metric( @@ -140,10 +140,12 @@ struct UsageMenuCardLayoutTests { ]) } let shortModel = metricModel(detailLeftText: "5% ahead", detailRightText: "Done in 1d") + let sameShapeModel = metricModel(detailLeftText: "6% ahead", detailRightText: "Done in 2d") let longModel = metricModel( detailLeftText: "5% more than current pace", detailRightText: "Done in 1d 36m · about 75% likely to finish") + #expect(shortModel.hasCompatibleTrackedLayout(with: sameShapeModel)) #expect(!shortModel.hasCompatibleTrackedLayout(with: longModel)) }