From 94bbe439b0fd2a60887ccc5a713d2024a3fec382 Mon Sep 17 00:00:00 2001 From: Steven Coaila Date: Tue, 22 Sep 2026 17:04:42 -0500 Subject: [PATCH 1/2] fix(ui): keep reset credits readable in narrow provider cards --- .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Support/UsageFormatters.swift | 4 ++ .../TokenGaugeApp/Views/ProviderCard.swift | 53 +++++++++++----- .../Views/ResetCreditsBadge.swift | 17 ++++++ .../ResetCreditsBadgeRenderingTests.swift | 61 +++++++++++++++++++ 6 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 Sources/TokenGaugeApp/Views/ResetCreditsBadge.swift create mode 100644 Tests/TokenGaugeAppTests/ResetCreditsBadgeRenderingTests.swift diff --git a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings index 7c6fc8d..7e5a756 100644 --- a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings @@ -26,6 +26,8 @@ "reset.tomorrow_at" = "Resets tomorrow · %@"; "reset_credits.one" = "1 reset"; "reset_credits.many" = "%d resets"; +"reset_credits.available_one" = "1 limit reset available"; +"reset_credits.available_many" = "%d limit resets available"; "updated.now" = "now"; "updated.never" = "no reading"; "model.unknown" = "Other"; diff --git a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings index 326d604..d3ffa92 100644 --- a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings @@ -26,6 +26,8 @@ "reset.tomorrow_at" = "Reinicia mañana · %@"; "reset_credits.one" = "1 reinicio"; "reset_credits.many" = "%d reinicios"; +"reset_credits.available_one" = "1 reinicio de límite disponible"; +"reset_credits.available_many" = "%d reinicios de límite disponibles"; "updated.now" = "ahora"; "updated.never" = "sin lectura"; "model.unknown" = "Otros"; diff --git a/Sources/TokenGaugeApp/Support/UsageFormatters.swift b/Sources/TokenGaugeApp/Support/UsageFormatters.swift index 06f0022..5dde254 100644 --- a/Sources/TokenGaugeApp/Support/UsageFormatters.swift +++ b/Sources/TokenGaugeApp/Support/UsageFormatters.swift @@ -67,6 +67,10 @@ enum UsageFormatters { count == 1 ? "reset_credits.one".localized : "reset_credits.many".localized(count) } + @MainActor static func resetCreditsAvailable(_ count: Int) -> String { + count == 1 ? "reset_credits.available_one".localized : "reset_credits.available_many".localized(count) + } + @MainActor static func lastUpdated(_ date: Date?) -> String { guard let date else { return "updated.never".localized } let age = Date().timeIntervalSince(date) diff --git a/Sources/TokenGaugeApp/Views/ProviderCard.swift b/Sources/TokenGaugeApp/Views/ProviderCard.swift index 4cb0806..f5616d1 100644 --- a/Sources/TokenGaugeApp/Views/ProviderCard.swift +++ b/Sources/TokenGaugeApp/Views/ProviderCard.swift @@ -152,20 +152,40 @@ struct ProviderCard: View { return "quota.remaining_value".localized(UsageFormatters.percentage(window.remainingPercentage)) } + @ViewBuilder private var header: some View { + if state.status == .ready, let credits = state.snapshot?.availableResetCredits, credits > 0 { + ViewThatFits(in: .horizontal) { + headerRow(credits: credits, reservingStatus: widestFreshness) + VStack(alignment: .trailing, spacing: 6) { + headerRow(credits: nil, reservingStatus: nil) + ResetCreditsBadge(count: credits) + } + } + } else { + headerRow(credits: nil, reservingStatus: nil) + } + } + + private var widestFreshness: String { + UsageFormatters.lastUpdated(Date().addingTimeInterval(-59 * 60)) + } + + private func headerRow(credits: Int?, reservingStatus reserved: String?) -> some View { let showsTitle = showProviderTitle || panelStyle == .rings return HStack(spacing: 5) { if showsTitle { ProviderLogo(provider: provider, size: 13) - Text("provider.\(provider.rawValue)".localized).font(.system(size: 11, weight: .semibold)) + Text("provider.\(provider.rawValue)".localized) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) Spacer(minLength: 4) } - Circle().fill(statusColor).frame(width: 5, height: 5) - Text(statusSubtitle) - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .lineLimit(1) - .layoutPriority(2) + ZStack(alignment: showsTitle ? .trailing : .leading) { + if let reserved { freshness(reserved).hidden() } + freshness(statusSubtitle) + } + .layoutPriority(2) if provider == .claude, let account = accountLabel, !account.isEmpty { Text(account) .font(.system(size: 10)) @@ -176,14 +196,17 @@ struct ProviderCard: View { .accessibilityLabel("account.current".localized(account)) } if !showsTitle { Spacer(minLength: 4) } - if state.status == .ready, let credits = state.snapshot?.availableResetCredits, credits > 0 { - Text(UsageFormatters.resetCredits(credits)) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(Color.primary.opacity(0.05))) - } + if let credits { ResetCreditsBadge(count: credits) } + } + } + + private func freshness(_ text: String) -> some View { + HStack(spacing: 5) { + Circle().fill(statusColor).frame(width: 5, height: 5) + Text(text) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) } } diff --git a/Sources/TokenGaugeApp/Views/ResetCreditsBadge.swift b/Sources/TokenGaugeApp/Views/ResetCreditsBadge.swift new file mode 100644 index 0000000..169ad1d --- /dev/null +++ b/Sources/TokenGaugeApp/Views/ResetCreditsBadge.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct ResetCreditsBadge: View { + let count: Int + + var body: some View { + Text(UsageFormatters.resetCredits(count)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 6) + .background(Capsule().fill(Color.primary.opacity(0.05)).padding(.vertical, -2)) + .help(UsageFormatters.resetCreditsAvailable(count)) + .accessibilityLabel(UsageFormatters.resetCreditsAvailable(count)) + } +} diff --git a/Tests/TokenGaugeAppTests/ResetCreditsBadgeRenderingTests.swift b/Tests/TokenGaugeAppTests/ResetCreditsBadgeRenderingTests.swift new file mode 100644 index 0000000..8583025 --- /dev/null +++ b/Tests/TokenGaugeAppTests/ResetCreditsBadgeRenderingTests.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI +import TokenGaugeCore +import XCTest + +@testable import TokenGaugeApp + +@MainActor +final class ResetCreditsBadgeRenderingTests: XCTestCase { + private let narrowRingCard = Theme.Layout.minimumRingCardWidth + + func testWideCardsKeepResetCreditsOnTheHeaderRow() throws { + try withEachLanguage { language in + for style in [QuotaPanelStyle.rings, .standard] { + let plain = fittingSize(card(credits: nil, width: Theme.Layout.compactPanelWidth, age: 0, style: style)) + for age: TimeInterval in [0, -720, -3_540] { + for credits in [1, 3, 12, 120] { + let size = fittingSize( + card(credits: credits, width: Theme.Layout.compactPanelWidth, age: age, style: style)) + XCTAssertEqual(size.height, plain.height, accuracy: 1, "\(language) \(style) \(age) \(credits)") + } + } + } + } + } + + func testNarrowCardsMoveResetCreditsToOneStableRowWhateverTheFreshness() throws { + try withEachLanguage { language in + for style in [QuotaPanelStyle.rings, .standard] { + let plain = fittingSize(card(credits: nil, width: narrowRingCard, age: 0, style: style)) + let reference = fittingSize(card(credits: 1, width: narrowRingCard, age: 0, style: style)) + XCTAssertGreaterThan(reference.height, plain.height + 10, "\(language) \(style)") + XCTAssertLessThan(reference.height, plain.height + 30, "\(language) \(style)") + for age: TimeInterval in [0, -720, -3_540] { + for credits in [1, 3, 12, 120] { + let size = fittingSize(card(credits: credits, width: narrowRingCard, age: age, style: style)) + XCTAssertEqual( + size.height, reference.height, accuracy: 1, "\(language) \(style) \(age) \(credits)") + } + } + } + } + } + + private func card(credits: Int?, width: CGFloat, age: TimeInterval, style: QuotaPanelStyle) -> some View { + ProviderCard( + provider: .codex, + state: ProviderViewState( + snapshot: Fixture.snapshot( + .codex, + windows: [ + Fixture.window( + id: "codex.secondary", usedPercentage: 90, resetsAt: Date().addingTimeInterval(432_000), + durationMinutes: 10_080) + ], availableResetCredits: credits, capturedAt: Date().addingTimeInterval(age)), + status: .ready, isRefreshing: false), + panelStyle: style, showProviderTitle: true + ) + .frame(width: width) + } +} From ec88fbeacb78ab5b37db4196ecc871f7db16c9c9 Mon Sep 17 00:00:00 2001 From: Steven Coaila Date: Tue, 22 Sep 2026 17:04:42 -0500 Subject: [PATCH 2/2] chore(release): prepare version 1.5.1 --- CHANGELOG.md | 6 ++++++ Resources/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4052910..1ad9fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1] - 2026-09-22 + +### Fixed + +- Available Codex limit resets no longer break into a letter-by-letter column. Narrow cards show the full label on its own line below the update time, and wider cards keep it beside the time, with a tooltip explaining the count. + ## [1.5.0] - 2026-09-20 ### Added diff --git a/Resources/Info.plist b/Resources/Info.plist index f3ed138..fe15949 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.5.0 + 1.5.1 CFBundleVersion - 7 + 8 LSMinimumSystemVersion 14.0 LSUIElement