diff --git a/ClaudeMeter/Models/API/UsageAPIResponse.swift b/ClaudeMeter/Models/API/UsageAPIResponse.swift
index 9d69d31..32b7235 100644
--- a/ClaudeMeter/Models/API/UsageAPIResponse.swift
+++ b/ClaudeMeter/Models/API/UsageAPIResponse.swift
@@ -7,23 +7,38 @@
import Foundation
-/// API response for usage data
+/// `limits` is authoritative. The flat per-model fields (`seven_day_sonnet`,
+/// `seven_day_opus`, ...) come back null and the API adds no new ones, so only
+/// Sonnet is kept, for accounts still served the older shape.
struct UsageAPIResponse: Codable {
- let fiveHour: UsageLimitResponse
- let sevenDay: UsageLimitResponse
+ let limits: [LimitEntryResponse]?
+ let fiveHour: UsageLimitResponse?
+ let sevenDay: UsageLimitResponse?
let sevenDaySonnet: UsageLimitResponse?
+ init(
+ fiveHour: UsageLimitResponse? = nil,
+ sevenDay: UsageLimitResponse? = nil,
+ sevenDaySonnet: UsageLimitResponse? = nil,
+ limits: [LimitEntryResponse]? = nil
+ ) {
+ self.fiveHour = fiveHour
+ self.sevenDay = sevenDay
+ self.sevenDaySonnet = sevenDaySonnet
+ self.limits = limits
+ }
+
enum CodingKeys: String, CodingKey {
+ case limits
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDaySonnet = "seven_day_sonnet"
}
}
-/// Individual usage limit response from API
struct UsageLimitResponse: Codable {
- let utilization: Double // Percentage 0-100
- let resetsAt: String? // ISO8601 string, can be null
+ let utilization: Double
+ let resetsAt: String?
enum CodingKeys: String, CodingKey {
case utilization
@@ -31,7 +46,69 @@ struct UsageLimitResponse: Codable {
}
}
-/// Mapping error for API response conversion
+struct LimitEntryResponse: Codable {
+ let kind: String
+ let percent: Double
+ let resetsAt: String?
+ let scope: LimitScopeResponse?
+ let isActive: Bool?
+
+ init(
+ kind: String,
+ percent: Double,
+ resetsAt: String?,
+ scope: LimitScopeResponse? = nil,
+ isActive: Bool? = nil
+ ) {
+ self.kind = kind
+ self.percent = percent
+ self.resetsAt = resetsAt
+ self.scope = scope
+ self.isActive = isActive
+ }
+
+ enum Kind {
+ static let session = "session"
+ static let weeklyAll = "weekly_all"
+ static let headline = [session, weeklyAll]
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case kind
+ case percent
+ case resetsAt = "resets_at"
+ case scope
+ case isActive = "is_active"
+ }
+
+ var scopeDisplayName: String? {
+ let names = [scope?.model?.displayName, scope?.surface?.displayName].compactMap { $0 }
+ return names.isEmpty ? nil : names.joined(separator: " · ")
+ }
+}
+
+struct LimitScopeResponse: Codable {
+ let model: NamedScopeResponse?
+ let surface: NamedScopeResponse?
+
+ /// A scope shape we do not recognise must degrade to nil, not fail the response.
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ model = try? container.decodeIfPresent(NamedScopeResponse.self, forKey: .model)
+ surface = try? container.decodeIfPresent(NamedScopeResponse.self, forKey: .surface)
+ }
+}
+
+struct NamedScopeResponse: Codable {
+ let id: String?
+ let displayName: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case displayName = "display_name"
+ }
+}
+
enum MappingError: LocalizedError {
case invalidDateFormat
case missingCriticalField(field: String)
@@ -46,63 +123,99 @@ enum MappingError: LocalizedError {
}
}
-/// Extension to map API response to domain model
extension UsageAPIResponse {
- func toDomain() throws -> UsageData {
- let iso8601Formatter = ISO8601DateFormatter()
- iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
- let sessionResetDate = try parseResetDate(
- from: fiveHour.resetsAt,
- field: "fiveHour.resetsAt",
- formatter: iso8601Formatter,
- fallback: Constants.Pacing.sessionWindow
- )
- let weeklyResetDate = try parseResetDate(
- from: sevenDay.resetsAt,
- field: "sevenDay.resetsAt",
- formatter: iso8601Formatter,
- fallback: Constants.Pacing.weeklyWindow
- )
+ private static let fractionalSecondsFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
- // Handle optional sonnet usage
- let sonnetLimit: UsageLimit? = try sevenDaySonnet.flatMap { sonnet -> UsageLimit? in
- let sonnetResetDate = try parseResetDate(
- from: sonnet.resetsAt,
- field: "sevenDaySonnet.resetsAt",
- formatter: iso8601Formatter,
- fallback: Constants.Pacing.weeklyWindow
- )
- return UsageLimit(
- utilization: sonnet.utilization,
- resetAt: sonnetResetDate
- )
+ private static let plainFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter
+ }()
+
+ func toDomain() throws -> UsageData {
+ guard let sessionEntry = entry(ofKind: LimitEntryResponse.Kind.session, legacy: fiveHour) else {
+ throw MappingError.missingCriticalField(field: "five_hour")
+ }
+ guard let weeklyEntry = entry(ofKind: LimitEntryResponse.Kind.weeklyAll, legacy: sevenDay) else {
+ throw MappingError.missingCriticalField(field: "seven_day")
}
return UsageData(
- sessionUsage: UsageLimit(
- utilization: fiveHour.utilization,
- resetAt: sessionResetDate
- ),
- weeklyUsage: UsageLimit(
- utilization: sevenDay.utilization,
- resetAt: weeklyResetDate
- ),
- sonnetUsage: sonnetLimit,
+ sessionUsage: try usageLimit(from: sessionEntry, fallback: Constants.Pacing.sessionWindow),
+ weeklyUsage: try usageLimit(from: weeklyEntry, fallback: Constants.Pacing.weeklyWindow),
+ scopedUsage: try scopedUsage(),
lastUpdated: Date()
)
}
+ private func entry(ofKind kind: String, legacy: UsageLimitResponse?) -> LimitEntryResponse? {
+ if let entry = limits?.first(where: { $0.kind == kind }) {
+ return entry
+ }
+ return legacy.map {
+ LimitEntryResponse(kind: kind, percent: $0.utilization, resetsAt: $0.resetsAt)
+ }
+ }
+
+ /// Any kind may carry a scope, but headline kinds are excluded so an entry the API
+ /// later scopes cannot render both as a headline card and a scoped one.
+ private func scopedUsage() throws -> [ScopedUsageLimit] {
+ let scoped = try (limits ?? [])
+ .filter { !LimitEntryResponse.Kind.headline.contains($0.kind) }
+ .compactMap { entry -> ScopedUsageLimit? in
+ guard let name = entry.scopeDisplayName else { return nil }
+ return ScopedUsageLimit(
+ name: name,
+ limit: try usageLimit(from: entry, fallback: Constants.Pacing.weeklyWindow),
+ isActive: entry.isActive ?? false
+ )
+ }
+
+ guard scoped.isEmpty, let sonnet = sevenDaySonnet else {
+ return scoped
+ }
+
+ return [
+ ScopedUsageLimit(
+ name: "Sonnet",
+ limit: try usageLimit(
+ from: LimitEntryResponse(
+ kind: "seven_day_sonnet",
+ percent: sonnet.utilization,
+ resetsAt: sonnet.resetsAt
+ ),
+ fallback: Constants.Pacing.weeklyWindow
+ ),
+ isActive: false
+ )
+ ]
+ }
+
+ private func usageLimit(from entry: LimitEntryResponse, fallback: TimeInterval) throws -> UsageLimit {
+ UsageLimit(
+ utilization: entry.percent,
+ resetAt: try parseResetDate(
+ from: entry.resetsAt,
+ field: "\(entry.kind).resets_at",
+ fallback: fallback
+ )
+ )
+ }
+
private func parseResetDate(
from rawValue: String?,
field: String,
- formatter: ISO8601DateFormatter,
fallback: TimeInterval
) throws -> Date {
guard let rawValue else {
return Date().addingTimeInterval(fallback)
}
- guard let date = formatter.date(from: rawValue) else {
+ guard let date = Self.fractionalSecondsFormatter.date(from: rawValue)
+ ?? Self.plainFormatter.date(from: rawValue) else {
throw MappingError.missingCriticalField(field: field)
}
return date
diff --git a/ClaudeMeter/Models/AppSettings.swift b/ClaudeMeter/Models/AppSettings.swift
index 3522384..ab72a05 100644
--- a/ClaudeMeter/Models/AppSettings.swift
+++ b/ClaudeMeter/Models/AppSettings.swift
@@ -24,8 +24,9 @@ struct AppSettings: Codable, Equatable, Sendable {
/// Last known organization ID (cached)
var cachedOrganizationId: UUID?
- /// Whether to show Sonnet usage in the popover
- var isSonnetUsageShown: Bool
+ /// Model-scoped limits the user has opted into showing, by API display name.
+ /// Empty by default: nothing appears in the popover until the user asks for it.
+ var shownScopedModels: Set
/// Menu bar icon display style
var iconStyle: IconStyle
@@ -39,7 +40,7 @@ struct AppSettings: Codable, Equatable, Sendable {
notificationThresholds: .default,
isFirstLaunch: true,
cachedOrganizationId: nil,
- isSonnetUsageShown: false,
+ shownScopedModels: [],
iconStyle: .battery,
isColoredIcon: true
)
@@ -50,10 +51,16 @@ struct AppSettings: Codable, Equatable, Sendable {
case notificationThresholds = "notification_thresholds"
case isFirstLaunch = "is_first_launch"
case cachedOrganizationId = "cached_organization_id"
- case isSonnetUsageShown = "show_sonnet_usage"
+ case shownScopedModels = "shown_scoped_models"
case iconStyle = "icon_style"
case isColoredIcon = "is_colored_icon"
}
+
+ /// Read-only: migrates settings saved before `shownScopedModels` existed.
+ /// Kept out of `CodingKeys` so `encode` stays synthesized.
+ private enum LegacyCodingKeys: String, CodingKey {
+ case showSonnetUsage = "show_sonnet_usage"
+ }
}
extension AppSettings {
@@ -66,9 +73,16 @@ extension AppSettings {
notificationThresholds = try container.decodeIfPresent(NotificationThresholds.self, forKey: .notificationThresholds) ?? defaults.notificationThresholds
isFirstLaunch = try container.decodeIfPresent(Bool.self, forKey: .isFirstLaunch) ?? defaults.isFirstLaunch
cachedOrganizationId = try container.decodeIfPresent(UUID.self, forKey: .cachedOrganizationId)
- isSonnetUsageShown = try container.decodeIfPresent(Bool.self, forKey: .isSonnetUsageShown) ?? defaults.isSonnetUsageShown
iconStyle = try container.decodeIfPresent(IconStyle.self, forKey: .iconStyle) ?? defaults.iconStyle
isColoredIcon = try container.decodeIfPresent(Bool.self, forKey: .isColoredIcon) ?? defaults.isColoredIcon
+
+ if let shown = try container.decodeIfPresent(Set.self, forKey: .shownScopedModels) {
+ shownScopedModels = shown
+ } else {
+ let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self)
+ let wasSonnetShown = try legacy.decodeIfPresent(Bool.self, forKey: .showSonnetUsage) ?? false
+ shownScopedModels = wasSonnetShown ? ["Sonnet"] : defaults.shownScopedModels
+ }
}
}
@@ -77,4 +91,17 @@ extension AppSettings {
mutating func setRefreshInterval(_ interval: TimeInterval) {
refreshInterval = max(60, min(600, interval))
}
+
+ /// Whether a model-scoped limit should appear in the popover
+ func isScopedModelShown(_ name: String) -> Bool {
+ shownScopedModels.contains(name)
+ }
+
+ mutating func setScopedModel(_ name: String, isShown: Bool) {
+ if isShown {
+ shownScopedModels.insert(name)
+ } else {
+ shownScopedModels.remove(name)
+ }
+ }
}
diff --git a/ClaudeMeter/Models/ScopedUsageLimit.swift b/ClaudeMeter/Models/ScopedUsageLimit.swift
new file mode 100644
index 0000000..f6ff714
--- /dev/null
+++ b/ClaudeMeter/Models/ScopedUsageLimit.swift
@@ -0,0 +1,28 @@
+//
+// ScopedUsageLimit.swift
+// ClaudeMeter
+//
+
+import Foundation
+
+/// A weekly limit the API scopes to a model or surface. The API supplies `name`
+/// itself, so a model released after this build still surfaces here.
+struct ScopedUsageLimit: Codable, Equatable, Sendable, Identifiable {
+ let name: String
+ let limit: UsageLimit
+ let isActive: Bool
+
+ var id: String { name }
+
+ enum CodingKeys: String, CodingKey {
+ case name
+ case limit
+ case isActive = "is_active"
+ }
+}
+
+extension ScopedUsageLimit {
+ var title: String {
+ "Weekly \(name)"
+ }
+}
diff --git a/ClaudeMeter/Models/UsageData.swift b/ClaudeMeter/Models/UsageData.swift
index 78e62fb..6e2c7d1 100644
--- a/ClaudeMeter/Models/UsageData.swift
+++ b/ClaudeMeter/Models/UsageData.swift
@@ -15,8 +15,8 @@ struct UsageData: Codable, Equatable, Sendable {
/// 7-day weekly usage across all models
let weeklyUsage: UsageLimit
- /// 7-day Sonnet-specific usage (nil if not used)
- let sonnetUsage: UsageLimit?
+ /// 7-day limits scoped to a specific model, in the order the API reported them
+ let scopedUsage: [ScopedUsageLimit]
/// Timestamp of when this data was fetched
let lastUpdated: Date
@@ -24,9 +24,33 @@ struct UsageData: Codable, Equatable, Sendable {
enum CodingKeys: String, CodingKey {
case sessionUsage = "session_usage"
case weeklyUsage = "weekly_usage"
- case sonnetUsage = "sonnet_usage"
+ case scopedUsage = "scoped_usage"
case lastUpdated = "last_updated"
}
+
+ /// Read-only: caches written before scoped limits existed carry a single Sonnet entry.
+ /// Kept out of `CodingKeys` so `encode` stays synthesized.
+ private enum LegacyCodingKeys: String, CodingKey {
+ case sonnetUsage = "sonnet_usage"
+ }
+}
+
+extension UsageData {
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ sessionUsage = try container.decode(UsageLimit.self, forKey: .sessionUsage)
+ weeklyUsage = try container.decode(UsageLimit.self, forKey: .weeklyUsage)
+ lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)
+
+ if let scoped = try container.decodeIfPresent([ScopedUsageLimit].self, forKey: .scopedUsage) {
+ scopedUsage = scoped
+ } else {
+ let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self)
+ scopedUsage = try legacy.decodeIfPresent(UsageLimit.self, forKey: .sonnetUsage)
+ .map { [ScopedUsageLimit(name: "Sonnet", limit: $0, isActive: false)] } ?? []
+ }
+ }
}
extension UsageData {
diff --git a/ClaudeMeter/Repositories/CacheRepository.swift b/ClaudeMeter/Repositories/CacheRepository.swift
index 084df05..20970f5 100644
--- a/ClaudeMeter/Repositories/CacheRepository.swift
+++ b/ClaudeMeter/Repositories/CacheRepository.swift
@@ -91,7 +91,7 @@ actor CacheRepository: CacheRepositoryProtocol {
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- guard let jsonData = try? encoder.encode(data) else {
+ guard let jsonData = try? encoder.encode(UsageExportPayload(data)) else {
return
}
diff --git a/ClaudeMeter/Repositories/UsageExportPayload.swift b/ClaudeMeter/Repositories/UsageExportPayload.swift
new file mode 100644
index 0000000..35de842
--- /dev/null
+++ b/ClaudeMeter/Repositories/UsageExportPayload.swift
@@ -0,0 +1,37 @@
+//
+// UsageExportPayload.swift
+// ClaudeMeter
+//
+
+import Foundation
+
+/// The public `~/.claudemeter/usage.json` contract. Separate from `UsageData` so the
+/// domain model and disk cache can change shape without breaking external scripts.
+struct UsageExportPayload: Encodable {
+ let sessionUsage: UsageLimit
+ let weeklyUsage: UsageLimit
+ let scopedUsage: [ScopedUsageLimit]
+
+ /// Deprecated alias for `scopedUsage`, kept so existing statusline scripts keep working.
+ let sonnetUsage: UsageLimit?
+
+ let lastUpdated: Date
+
+ init(_ data: UsageData) {
+ sessionUsage = data.sessionUsage
+ weeklyUsage = data.weeklyUsage
+ scopedUsage = data.scopedUsage
+ sonnetUsage = data.scopedUsage
+ .first { $0.name.caseInsensitiveCompare("Sonnet") == .orderedSame }?
+ .limit
+ lastUpdated = data.lastUpdated
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case sessionUsage = "session_usage"
+ case weeklyUsage = "weekly_usage"
+ case scopedUsage = "scoped_usage"
+ case sonnetUsage = "sonnet_usage"
+ case lastUpdated = "last_updated"
+ }
+}
diff --git a/ClaudeMeter/Utilities/DemoDataFactory.swift b/ClaudeMeter/Utilities/DemoDataFactory.swift
index bea2d82..1b8c4c6 100644
--- a/ClaudeMeter/Utilities/DemoDataFactory.swift
+++ b/ClaudeMeter/Utilities/DemoDataFactory.swift
@@ -46,18 +46,18 @@ enum DemoDataFactory {
isLoading: false
)
- case .withSonnet:
+ case .withModelLimits:
appModel.applyDemoState(
usageData: makeUsageData(
sessionPercentage: 65,
weeklyPercentage: 40,
- sonnetPercentage: 25
+ scopedPercentages: [("Fable", 23), ("Opus", 58)]
),
isSetupComplete: true,
errorMessage: nil,
isLoading: false
)
- appModel.settings.isSonnetUsageShown = true
+ appModel.settings.shownScopedModels = ["Fable", "Opus"]
case .loading:
appModel.applyDemoState(
@@ -89,25 +89,21 @@ enum DemoDataFactory {
private static func makeUsageData(
sessionPercentage: Double,
weeklyPercentage: Double,
- sonnetPercentage: Double? = nil
+ scopedPercentages: [(name: String, percentage: Double)] = []
) -> UsageData {
let sessionResetAt = Date().addingTimeInterval(3 * 3600) // 3 hours from now
let weeklyResetAt = Date().addingTimeInterval(4 * 24 * 3600) // 4 days from now
- let sessionUsage = UsageLimit(utilization: sessionPercentage, resetAt: sessionResetAt)
- let weeklyUsage = UsageLimit(utilization: weeklyPercentage, resetAt: weeklyResetAt)
-
- let sonnetUsage: UsageLimit?
- if let sonnetPercentage {
- sonnetUsage = UsageLimit(utilization: sonnetPercentage, resetAt: weeklyResetAt)
- } else {
- sonnetUsage = nil
- }
-
return UsageData(
- sessionUsage: sessionUsage,
- weeklyUsage: weeklyUsage,
- sonnetUsage: sonnetUsage,
+ sessionUsage: UsageLimit(utilization: sessionPercentage, resetAt: sessionResetAt),
+ weeklyUsage: UsageLimit(utilization: weeklyPercentage, resetAt: weeklyResetAt),
+ scopedUsage: scopedPercentages.enumerated().map { index, scoped in
+ ScopedUsageLimit(
+ name: scoped.name,
+ limit: UsageLimit(utilization: scoped.percentage, resetAt: weeklyResetAt),
+ isActive: index == 0
+ )
+ },
lastUpdated: Date()
)
}
diff --git a/ClaudeMeter/Utilities/DemoMode.swift b/ClaudeMeter/Utilities/DemoMode.swift
index af6dbdb..d23a77e 100644
--- a/ClaudeMeter/Utilities/DemoMode.swift
+++ b/ClaudeMeter/Utilities/DemoMode.swift
@@ -15,7 +15,7 @@ enum DemoMode: String, CaseIterable {
case warningUsage
case criticalUsage
case exceededUsage
- case withSonnet
+ case withModelLimits
case loading
case error
case setupWizard
@@ -35,7 +35,7 @@ enum DemoMode: String, CaseIterable {
case .warningUsage: "Medium usage - warning state"
case .criticalUsage: "High usage - critical state"
case .exceededUsage: "Over limit - exceeded state"
- case .withSonnet: "Shows Sonnet usage card"
+ case .withModelLimits: "Shows model-specific usage cards"
case .loading: "Loading spinner visible"
case .error: "Error banner displayed"
case .setupWizard: "First-time setup screen"
diff --git a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift
index 199bf06..23d443b 100644
--- a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift
+++ b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift
@@ -103,11 +103,10 @@ struct UsagePopoverView: View {
windowDuration: Constants.Pacing.weeklyWindow
)
- // Sonnet usage card (conditional rendering)
- if appModel.settings.isSonnetUsageShown, let sonnetUsage = usageData.sonnetUsage {
+ ForEach(usageData.scopedUsage.filter { appModel.settings.isScopedModelShown($0.name) }) { scoped in
UsageCardView(
- title: "Weekly Sonnet",
- usageLimit: sonnetUsage,
+ title: scoped.title,
+ usageLimit: scoped.limit,
icon: "sparkles",
windowDuration: Constants.Pacing.weeklyWindow
)
diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift
index 4fd2588..992ae54 100644
--- a/ClaudeMeter/Views/Settings/SettingsView.swift
+++ b/ClaudeMeter/Views/Settings/SettingsView.swift
@@ -61,7 +61,7 @@ struct SettingsView: View {
} else {
sessionKeySection
refreshIntervalSection
- sonnetUsageSection
+ modelUsageSection
iconStyleSection
launchAtLoginSection
}
@@ -210,28 +210,58 @@ struct SettingsView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
- // MARK: - Sonnet Usage Section
+ // MARK: - Model Usage Section
- private var sonnetUsageSection: some View {
- HStack {
+ /// Includes opted-in names so a model that drops out of the response can still be switched off.
+ private var scopedModelNames: [String] {
+ let reported = appModel.usageData?.scopedUsage.map(\.name) ?? []
+ return Array(Set(reported).union(appModel.settings.shownScopedModels)).sorted()
+ }
+
+ private var modelUsageSection: some View {
+ let names = scopedModelNames
+
+ return VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
- Text("Show Sonnet Usage")
+ Text("Model Usage")
.font(.subheadline)
- Text("Display weekly Sonnet usage in the menu bar popover")
+ Text("Choose which model-specific weekly limits appear in the menu bar popover")
.font(.caption)
.foregroundStyle(.secondary)
}
- Spacer()
+ if names.isEmpty {
+ Text("Your account has no model-specific limits right now. Any that appear will be listed here.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ } else {
+ ForEach(names, id: \.self) { name in
+ HStack {
+ Text(name)
+ .font(.callout)
- Toggle("", isOn: $appModel.settings.isSonnetUsageShown)
- .labelsHidden()
+ Spacer()
+
+ Toggle("", isOn: scopedModelBinding(for: name))
+ .labelsHidden()
+ .accessibilityLabel("Show \(name) usage")
+ }
+ }
+ }
}
.padding()
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
+ private func scopedModelBinding(for name: String) -> Binding {
+ Binding(
+ get: { appModel.settings.isScopedModelShown(name) },
+ set: { appModel.settings.setScopedModel(name, isShown: $0) }
+ )
+ }
+
// MARK: - Icon Style Section
private var iconStyleSection: some View {
diff --git a/ClaudeMeterTests/AppModelTests.swift b/ClaudeMeterTests/AppModelTests.swift
index 363e660..1fee9e7 100644
--- a/ClaudeMeterTests/AppModelTests.swift
+++ b/ClaudeMeterTests/AppModelTests.swift
@@ -404,15 +404,3 @@ final class AppModelTests: XCTestCase {
// MARK: - Helpers
-private func makeUsageData(percentage: Double) -> UsageData {
- let resetDate = Date().addingTimeInterval(TestConstants.oneHourInterval)
- let sessionUsage = UsageLimit(utilization: percentage, resetAt: resetDate)
- let weeklyUsage = UsageLimit(utilization: TestConstants.weeklyPercentage, resetAt: resetDate)
-
- return UsageData(
- sessionUsage: sessionUsage,
- weeklyUsage: weeklyUsage,
- sonnetUsage: nil,
- lastUpdated: Date()
- )
-}
diff --git a/ClaudeMeterTests/NotificationServiceTests.swift b/ClaudeMeterTests/NotificationServiceTests.swift
index e6ef1d0..14c2a77 100644
--- a/ClaudeMeterTests/NotificationServiceTests.swift
+++ b/ClaudeMeterTests/NotificationServiceTests.swift
@@ -160,16 +160,3 @@ final class NotificationServiceTests: XCTestCase {
// MARK: - Helpers
-@MainActor
-private func makeUsageData(percentage: Double) -> UsageData {
- let resetDate = Date().addingTimeInterval(TestConstants.oneHourInterval)
- let sessionUsage = UsageLimit(utilization: percentage, resetAt: resetDate)
- let weeklyUsage = UsageLimit(utilization: TestConstants.weeklyPercentage, resetAt: resetDate)
-
- return UsageData(
- sessionUsage: sessionUsage,
- weeklyUsage: weeklyUsage,
- sonnetUsage: nil,
- lastUpdated: Date()
- )
-}
diff --git a/ClaudeMeterTests/ScopedUsageSettingsTests.swift b/ClaudeMeterTests/ScopedUsageSettingsTests.swift
new file mode 100644
index 0000000..24abc2e
--- /dev/null
+++ b/ClaudeMeterTests/ScopedUsageSettingsTests.swift
@@ -0,0 +1,126 @@
+//
+// ScopedUsageSettingsTests.swift
+// ClaudeMeterTests
+//
+
+import XCTest
+@testable import ClaudeMeter
+
+final class ScopedUsageSettingsTests: XCTestCase {
+
+ // MARK: - Settings migration
+
+ func test_decode_withLegacyShowSonnetTrue_showsSonnet() throws {
+ let settings = try decodeSettings(#"{"show_sonnet_usage": true}"#)
+
+ XCTAssertEqual(settings.shownScopedModels, ["Sonnet"])
+ XCTAssertTrue(settings.isScopedModelShown("Sonnet"))
+ }
+
+ func test_decode_withLegacyShowSonnetTrue_doesNotOptIntoOtherModels() throws {
+ let settings = try decodeSettings(#"{"show_sonnet_usage": true}"#)
+
+ XCTAssertFalse(settings.isScopedModelShown("Fable"))
+ }
+
+ func test_decode_withLegacyShowSonnetFalse_showsNothing() throws {
+ let settings = try decodeSettings(#"{"show_sonnet_usage": false}"#)
+
+ XCTAssertTrue(settings.shownScopedModels.isEmpty)
+ XCTAssertFalse(settings.isScopedModelShown("Sonnet"))
+ XCTAssertFalse(settings.isScopedModelShown("Fable"))
+ }
+
+ /// The settings shape written by the 1.4.0 release.
+ func test_decode_settingsFromPreviousRelease_migratesCleanly() throws {
+ let settings = try decodeSettings("""
+ {"is_first_launch":false,"refresh_interval":60,"icon_style":"dualBar",
+ "show_sonnet_usage":false,"is_colored_icon":false,"notifications_enabled":true,
+ "notification_thresholds":{"critical_threshold":90,"notify_on_reset":true,"warning_threshold":75},
+ "cached_organization_id":"00000000-0000-0000-0000-000000000000"}
+ """)
+
+ // Unrelated preferences survive
+ XCTAssertEqual(settings.iconStyle, .dualBar)
+ XCTAssertFalse(settings.isColoredIcon)
+ XCTAssertEqual(settings.refreshInterval, 60)
+
+ // The Sonnet opt-out is preserved, and nothing is opted in on the user's behalf
+ XCTAssertFalse(settings.isScopedModelShown("Sonnet"))
+ XCTAssertFalse(settings.isScopedModelShown("Fable"))
+ }
+
+ func test_decode_withShownScopedModels_roundTrips() throws {
+ let settings = try decodeSettings(#"{"shown_scoped_models": ["Fable"]}"#)
+
+ XCTAssertTrue(settings.isScopedModelShown("Fable"))
+ XCTAssertFalse(settings.isScopedModelShown("Opus"))
+ }
+
+ func test_decode_withNeitherKey_showsNothing() throws {
+ let settings = try decodeSettings("{}")
+
+ XCTAssertTrue(settings.shownScopedModels.isEmpty)
+ XCTAssertFalse(settings.isScopedModelShown("Fable"))
+ }
+
+ func test_setScopedModel_togglesVisibility() {
+ var settings = AppSettings.default
+
+ settings.setScopedModel("Fable", isShown: true)
+ XCTAssertTrue(settings.isScopedModelShown("Fable"))
+
+ settings.setScopedModel("Fable", isShown: false)
+ XCTAssertFalse(settings.isScopedModelShown("Fable"))
+ XCTAssertTrue(settings.shownScopedModels.isEmpty)
+ }
+
+ // MARK: - Public JSON export
+
+ /// `~/.claudemeter/usage.json` is a documented contract for statusline scripts.
+ func test_export_stillEmitsSonnetUsageForExternalTools() throws {
+ let json = try exportJSON(makeUsageData(percentage: 10, scoped: [("Sonnet", 25), ("Fable", 23)]))
+
+ let sonnet = try XCTUnwrap(json["sonnet_usage"] as? [String: Any])
+ XCTAssertEqual(sonnet["utilization"] as? Double, 25)
+
+ let scoped = try XCTUnwrap(json["scoped_usage"] as? [[String: Any]])
+ XCTAssertEqual(scoped.compactMap { $0["name"] as? String }, ["Sonnet", "Fable"])
+ }
+
+ func test_export_withoutSonnet_omitsLegacyKey() throws {
+ let json = try exportJSON(makeUsageData(percentage: 10, scoped: [("Fable", 23)]))
+
+ XCTAssertNil(json["sonnet_usage"])
+ XCTAssertEqual((json["scoped_usage"] as? [[String: Any]])?.count, 1)
+ }
+
+ func test_decode_legacyCacheWithSonnetUsage_becomesScopedLimit() throws {
+ let json = """
+ {
+ "session_usage": { "utilization": 40, "reset_at": "2026-07-13T21:19:59Z" },
+ "weekly_usage": { "utilization": 50, "reset_at": "2026-07-19T10:59:59Z" },
+ "sonnet_usage": { "utilization": 25, "reset_at": "2026-07-19T10:59:59Z" },
+ "last_updated": "2026-07-13T18:00:00Z"
+ }
+ """.data(using: .utf8)!
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let data = try decoder.decode(UsageData.self, from: json)
+
+ XCTAssertEqual(data.scopedUsage.map(\.name), ["Sonnet"])
+ XCTAssertEqual(data.scopedUsage.first?.limit.utilization, 25)
+ }
+
+ // MARK: - Helpers
+
+ private func decodeSettings(_ json: String) throws -> AppSettings {
+ try JSONDecoder().decode(AppSettings.self, from: json.data(using: .utf8)!)
+ }
+
+ private func exportJSON(_ data: UsageData) throws -> [String: Any] {
+ let encoded = try JSONEncoder().encode(UsageExportPayload(data))
+ return try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+ }
+}
diff --git a/ClaudeMeterTests/TestSupport/UsageTestFixtures.swift b/ClaudeMeterTests/TestSupport/UsageTestFixtures.swift
new file mode 100644
index 0000000..3f18bcf
--- /dev/null
+++ b/ClaudeMeterTests/TestSupport/UsageTestFixtures.swift
@@ -0,0 +1,51 @@
+//
+// UsageTestFixtures.swift
+// ClaudeMeterTests
+//
+
+import Foundation
+import XCTest
+@testable import ClaudeMeter
+
+func makeUsageData(
+ percentage: Double,
+ weeklyPercentage: Double = TestConstants.weeklyPercentage,
+ scoped: [(name: String, percentage: Double)] = [],
+ resetAt: Date = Date().addingTimeInterval(TestConstants.oneHourInterval)
+) -> UsageData {
+ UsageData(
+ sessionUsage: UsageLimit(utilization: percentage, resetAt: resetAt),
+ weeklyUsage: UsageLimit(utilization: weeklyPercentage, resetAt: resetAt),
+ scopedUsage: scoped.map {
+ ScopedUsageLimit(
+ name: $0.name,
+ limit: UsageLimit(utilization: $0.percentage, resetAt: resetAt),
+ isActive: false
+ )
+ },
+ lastUpdated: Date()
+ )
+}
+
+func assertDate(
+ _ date: Date,
+ equalsIso8601String isoString: String,
+ file: StaticString = #filePath,
+ line: UInt = #line
+) {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+
+ guard let expectedDate = formatter.date(from: isoString) else {
+ XCTFail("Invalid ISO8601 test date: \(isoString)", file: file, line: line)
+ return
+ }
+
+ XCTAssertEqual(
+ date.timeIntervalSince1970,
+ expectedDate.timeIntervalSince1970,
+ accuracy: 0.001,
+ file: file,
+ line: line
+ )
+}
diff --git a/ClaudeMeterTests/UsageAPIResponseTests.swift b/ClaudeMeterTests/UsageAPIResponseTests.swift
new file mode 100644
index 0000000..b9322a8
--- /dev/null
+++ b/ClaudeMeterTests/UsageAPIResponseTests.swift
@@ -0,0 +1,210 @@
+//
+// UsageAPIResponseTests.swift
+// ClaudeMeterTests
+//
+
+import XCTest
+@testable import ClaudeMeter
+
+final class UsageAPIResponseTests: XCTestCase {
+
+ // MARK: - Current API shape (`limits` array)
+
+ /// Mirrors the shape the API returns. The flat per-model fields are null — the
+ /// model-specific data lives only in `limits`.
+ private let liveResponse = """
+ {
+ "five_hour": {
+ "utilization": 30,
+ "resets_at": "2026-01-01T12:00:00.123456+00:00"
+ },
+ "seven_day": {
+ "utilization": 40,
+ "resets_at": "2026-01-05T00:00:00.123456+00:00"
+ },
+ "seven_day_opus": null,
+ "seven_day_sonnet": null,
+ "limits": [
+ {
+ "kind": "session",
+ "group": "session",
+ "percent": 30,
+ "severity": "normal",
+ "resets_at": "2026-01-01T12:00:00.123456+00:00",
+ "scope": null,
+ "is_active": false
+ },
+ {
+ "kind": "weekly_all",
+ "group": "weekly",
+ "percent": 40,
+ "severity": "normal",
+ "resets_at": "2026-01-05T00:00:00.123456+00:00",
+ "scope": null,
+ "is_active": false
+ },
+ {
+ "kind": "weekly_scoped",
+ "group": "weekly",
+ "percent": 50,
+ "severity": "normal",
+ "resets_at": "2026-01-05T00:00:00.123456+00:00",
+ "scope": {
+ "model": { "id": null, "display_name": "Fable" },
+ "surface": null
+ },
+ "is_active": true
+ }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ func test_toDomain_withLimitsArray_mapsSessionAndWeekly() throws {
+ let usageData = try decode(liveResponse).toDomain()
+
+ XCTAssertEqual(usageData.sessionUsage.utilization, 30)
+ XCTAssertEqual(usageData.weeklyUsage.utilization, 40)
+ }
+
+ func test_toDomain_withLimitsArray_surfacesScopedModelByDisplayName() throws {
+ let usageData = try decode(liveResponse).toDomain()
+
+ XCTAssertEqual(usageData.scopedUsage.count, 1)
+
+ let fable = try XCTUnwrap(usageData.scopedUsage.first)
+ XCTAssertEqual(fable.name, "Fable")
+ XCTAssertEqual(fable.limit.utilization, 50)
+ XCTAssertTrue(fable.isActive)
+ XCTAssertEqual(fable.title, "Weekly Fable")
+ }
+
+ func test_toDomain_withUnknownFutureModel_surfacesItAnyway() throws {
+ let json = """
+ {
+ "limits": [
+ { "kind": "session", "percent": 10, "resets_at": null, "scope": null, "is_active": false },
+ { "kind": "weekly_all", "percent": 20, "resets_at": null, "scope": null, "is_active": false },
+ {
+ "kind": "weekly_scoped",
+ "percent": 66,
+ "resets_at": null,
+ "scope": { "model": { "id": null, "display_name": "Nonesuch 9" }, "surface": null },
+ "is_active": true
+ }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let usageData = try decode(json).toDomain()
+
+ XCTAssertEqual(usageData.scopedUsage.map(\.name), ["Nonesuch 9"])
+ XCTAssertEqual(usageData.scopedUsage.first?.limit.utilization, 66)
+ }
+
+ func test_toDomain_withMultipleScopedLimits_preservesAPIOrder() throws {
+ let json = """
+ {
+ "limits": [
+ { "kind": "session", "percent": 1, "resets_at": null, "scope": null, "is_active": false },
+ { "kind": "weekly_all", "percent": 2, "resets_at": null, "scope": null, "is_active": false },
+ { "kind": "weekly_scoped", "percent": 23, "resets_at": null,
+ "scope": { "model": { "id": null, "display_name": "Fable" } }, "is_active": true },
+ { "kind": "weekly_scoped", "percent": 58, "resets_at": null,
+ "scope": { "model": { "id": null, "display_name": "Opus" } }, "is_active": false }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let usageData = try decode(json).toDomain()
+
+ XCTAssertEqual(usageData.scopedUsage.map(\.name), ["Fable", "Opus"])
+ }
+
+ func test_toDomain_withUnrecognisedScopeShape_doesNotThrow() throws {
+ let json = """
+ {
+ "limits": [
+ { "kind": "session", "percent": 3, "resets_at": null, "scope": null, "is_active": false },
+ { "kind": "weekly_all", "percent": 4, "resets_at": null, "scope": null, "is_active": false },
+ { "kind": "weekly_scoped", "percent": 30, "resets_at": null,
+ "scope": { "model": "just-a-string", "surface": 42 }, "is_active": true }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let usageData = try decode(json).toDomain()
+
+ XCTAssertEqual(usageData.sessionUsage.utilization, 3)
+ XCTAssertTrue(usageData.scopedUsage.isEmpty)
+ }
+
+ func test_toDomain_missingSessionLimit_throws() throws {
+ let json = """
+ { "limits": [ { "kind": "weekly_all", "percent": 4, "resets_at": null, "scope": null, "is_active": false } ] }
+ """.data(using: .utf8)!
+
+ XCTAssertThrowsError(try decode(json).toDomain())
+ }
+
+ func test_toDomain_withScopeOnHeadlineEntry_doesNotDuplicateIt() throws {
+ let json = """
+ {
+ "limits": [
+ { "kind": "session", "percent": 5, "resets_at": null, "scope": null, "is_active": false },
+ { "kind": "weekly_all", "percent": 14, "resets_at": null,
+ "scope": { "model": { "id": null, "display_name": "Fable" } }, "is_active": false }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let usageData = try decode(json).toDomain()
+
+ XCTAssertEqual(usageData.weeklyUsage.utilization, 14)
+ XCTAssertTrue(usageData.scopedUsage.isEmpty)
+ }
+
+ // MARK: - Legacy API shape (no `limits` array)
+
+ func test_toDomain_withoutLimits_fallsBackToFlatFields() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 40, "resets_at": null },
+ "seven_day": { "utilization": 50, "resets_at": null },
+ "seven_day_sonnet": { "utilization": 25, "resets_at": null }
+ }
+ """.data(using: .utf8)!
+
+ let usageData = try decode(json).toDomain()
+
+ XCTAssertEqual(usageData.sessionUsage.utilization, 40)
+ XCTAssertEqual(usageData.weeklyUsage.utilization, 50)
+ XCTAssertEqual(usageData.scopedUsage.map(\.name), ["Sonnet"])
+ XCTAssertEqual(usageData.scopedUsage.first?.limit.utilization, 25)
+ }
+
+ // MARK: - Timestamps
+
+ func test_toDomain_parsesTimestampsWithAndWithoutFractionalSeconds() throws {
+ let json = """
+ {
+ "limits": [
+ { "kind": "session", "percent": 5, "resets_at": "2026-01-01T12:00:00.123456+00:00",
+ "scope": null, "is_active": false },
+ { "kind": "weekly_all", "percent": 14, "resets_at": "2026-01-05T00:00:00Z",
+ "scope": null, "is_active": false }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let usageData = try decode(json).toDomain()
+
+ assertDate(usageData.sessionUsage.resetAt, equalsIso8601String: "2026-01-01T12:00:00.123456+00:00")
+ assertDate(usageData.weeklyUsage.resetAt, equalsIso8601String: "2026-01-05T00:00:00.000Z")
+ }
+
+ // MARK: - Helpers
+
+ private func decode(_ data: Data) throws -> UsageAPIResponse {
+ try JSONDecoder().decode(UsageAPIResponse.self, from: data)
+ }
+}
diff --git a/ClaudeMeterTests/UsageServiceTests.swift b/ClaudeMeterTests/UsageServiceTests.swift
index a40ae1d..d863148 100644
--- a/ClaudeMeterTests/UsageServiceTests.swift
+++ b/ClaudeMeterTests/UsageServiceTests.swift
@@ -299,12 +299,10 @@ final class UsageServiceTests: XCTestCase {
let usageData = try await service.fetchUsage(forceRefresh: true)
- XCTAssertEqual(usageData.sonnetUsage?.utilization, TestConstants.sonnetPercentage)
- if let resetAt = usageData.sonnetUsage?.resetAt {
- assertDate(resetAt, equalsIso8601String: TestConstants.sonnetResetDateString)
- } else {
- XCTFail("Expected sonnet usage reset date")
- }
+ let sonnet = try XCTUnwrap(usageData.scopedUsage.first)
+ XCTAssertEqual(sonnet.name, "Sonnet")
+ XCTAssertEqual(sonnet.limit.utilization, TestConstants.sonnetPercentage)
+ assertDate(sonnet.limit.resetAt, equalsIso8601String: TestConstants.sonnetResetDateString)
}
}
@@ -340,27 +338,4 @@ private func makeUsageResponseData(
return try JSONEncoder().encode(response)
}
-private func makeUsageData(percentage: Double) -> UsageData {
- let resetDate = Date().addingTimeInterval(TestConstants.oneHourInterval)
- let sessionUsage = UsageLimit(utilization: percentage, resetAt: resetDate)
- let weeklyUsage = UsageLimit(utilization: TestConstants.weeklyPercentage, resetAt: resetDate)
-
- return UsageData(
- sessionUsage: sessionUsage,
- weeklyUsage: weeklyUsage,
- sonnetUsage: nil,
- lastUpdated: Date()
- )
-}
-
-private func assertDate(_ date: Date, equalsIso8601String isoString: String) {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
- guard let expectedDate = formatter.date(from: isoString) else {
- XCTFail("Invalid ISO8601 test date: \(isoString)")
- return
- }
- XCTAssertEqual(date.timeIntervalSince1970, expectedDate.timeIntervalSince1970, accuracy: 0.001)
-}
diff --git a/README.md b/README.md
index 4eb2797..965b6b2 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,8 @@ Keep track of your Claude.ai plan usage at a glance.
## Features
-- **Real-time usage monitoring** - Track your 5-hour session, 7-day weekly, and Sonnet-specific usage limits
+- **Real-time usage monitoring** - Track your 5-hour session, 7-day weekly, and model-specific usage limits
+- **Any model, no update needed** - Model-specific limits (Fable, Opus, Sonnet, ...) are read from whatever the API reports, so a newly launched model is listed in Settings without an app update; switch on the ones you want shown in the popover
- **Menu bar integration** - Clean, colour-coded usage indicator that lives in your macOS menu bar
- **Multiple icon styles** - Choose from 6 icon styles: Battery, Circular, Minimal, Segments, Dual Bar, or Gauge
- **Pacing indicator** - Flame icon warns when you're using Claude faster than sustainable pace
@@ -25,7 +26,7 @@ The menu bar icon changes colour based on your usage levels:
-When using Sonnet models, an additional indicator shows your Sonnet-specific usage:
+When a model has its own weekly cap, an additional card shows that model's usage:
@@ -134,17 +135,31 @@ ClaudeMeter exports usage data to `~/.claudemeter/usage.json` for use with exter
"reset_at": "2025-12-24T12:00:00Z",
"utilization": 29
},
- "sonnet_usage": {
- "reset_at": "2025-12-30T00:00:00Z",
- "utilization": 15
- },
"weekly_usage": {
"reset_at": "2025-12-30T00:00:00Z",
"utilization": 45
+ },
+ "scoped_usage": [
+ {
+ "name": "Fable",
+ "is_active": true,
+ "limit": {
+ "reset_at": "2025-12-30T00:00:00Z",
+ "utilization": 23
+ }
+ }
+ ],
+ "sonnet_usage": {
+ "reset_at": "2025-12-30T00:00:00Z",
+ "utilization": 15
}
}
```
+`scoped_usage` lists every model-specific limit the API reports, named as the API names it.
+`sonnet_usage` is kept as a deprecated alias so existing statusline scripts keep working; it is
+present only when a Sonnet limit exists. Prefer `scoped_usage` for new scripts.
+
**Example: Claude Code statusline**
Create `~/.claude/statusline.sh`:
diff --git a/scripts/demo.sh b/scripts/demo.sh
index 98f78a0..b496df7 100755
--- a/scripts/demo.sh
+++ b/scripts/demo.sh
@@ -13,7 +13,7 @@ MODES=(
"warningUsage|Medium usage - warning state"
"criticalUsage|High usage - critical state"
"exceededUsage|Over limit - exceeded state"
- "withSonnet|Shows Sonnet usage card"
+ "withModelLimits|Shows model-specific usage cards"
"loading|Loading spinner visible"
"error|Error banner displayed"
"setupWizard|First-time setup screen"