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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>CFBundleVersion</key>
<string>7</string>
<string>8</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
2 changes: 2 additions & 0 deletions Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 2 additions & 0 deletions Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions Sources/TokenGaugeApp/Support/UsageFormatters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 38 additions & 15 deletions Sources/TokenGaugeApp/Views/ProviderCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
}
}

Expand Down
17 changes: 17 additions & 0 deletions Sources/TokenGaugeApp/Views/ResetCreditsBadge.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
61 changes: 61 additions & 0 deletions Tests/TokenGaugeAppTests/ResetCreditsBadgeRenderingTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading