From 88fe85af83c7a536f14403d768bce2da1afbfd00 Mon Sep 17 00:00:00 2001 From: Chris Osegueda Date: Mon, 2 Nov 2026 15:02:46 -0700 Subject: [PATCH] feat: show five-hour windows only when reported (#9) Apply one rule to both providers: the five-hour window appears only when the current usage data has a value for it. Accounts without one lose the `--` in the menu bar label and the "5-hour Unavailable" popover row, the compact label falls through to the weekly value, and a provider with no values shows a single `--`. No plan lookup and no new credential reads. Also stop the rollout fallback from returning a five-hour value whose window has reset since the event was written. The scan only compares reset times with the event's own timestamp, so without this a stale snapshot would revive the window while the CLI cannot report limits. The check runs after the scan and leaves the parsers and scan unchanged. Claude-Session: https://claude.ai/code/session_01EFLAijReeyVZHXSemDq6rZ --- CHANGELOG.md | 5 + InferenceMeter/Model/Usage.swift | 7 + InferenceMeter/Providers/CodexProvider.swift | 28 ++- InferenceMeter/UI/DetailPopover.swift | 70 ++++--- InferenceMeter/UI/MenuBarLabel.swift | 11 +- InferenceMeterTests/CodexProviderTests.swift | 20 +- .../FiveHourWindowVisibilityTests.swift | 176 ++++++++++++++++++ InferenceMeterTests/MenuBarLabelTests.swift | 56 ++++-- InferenceMeterTests/NotifierTests.swift | 15 ++ InferenceMeterTests/RefreshEngineTests.swift | 50 +++++ README.md | 6 +- 11 files changed, 379 insertions(+), 65 deletions(-) create mode 100644 InferenceMeterTests/FiveHourWindowVisibilityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e89f40b..27a7592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,13 @@ All notable changes to InferenceMeter are documented here. The project follows [ ## [Unreleased] +### Changed + +- Show the five-hour window for Claude and Codex only when the provider reports it. Accounts without one no longer see `--` in the menu bar label or a "5-hour Unavailable" row, and the compact label shows the weekly value instead. The window returns on the next refresh if the provider reports it again. + ### Fixed +- Stop a Codex session file from reviving a five-hour value whose window has since reset when the CLI cannot report current limits. - Stop the repeated macOS Keychain password prompts for `Claude Code-credentials`. The app now reads the credential through `/usr/bin/security`, as Claude Code does, instead of requesting direct access that **Always Allow** could not satisfy. ## [0.1.4] - 2026-07-13 diff --git a/InferenceMeter/Model/Usage.swift b/InferenceMeter/Model/Usage.swift index 4862cf8..7712755 100644 --- a/InferenceMeter/Model/Usage.swift +++ b/InferenceMeter/Model/Usage.swift @@ -33,6 +33,13 @@ struct Usage: Sendable, Equatable { } extension Usage { + /// The single rule for showing a five-hour window, for every provider: the current + /// data has a value for it. Plans do not predict this, and an idle window is still + /// reported at 0%, so an absent value means the window does not apply right now. + var reportsFiveHourWindow: Bool { + fiveHourPct != nil + } + static func unavailable( provider: Provider, source: UsageSource = .localFile, diff --git a/InferenceMeter/Providers/CodexProvider.swift b/InferenceMeter/Providers/CodexProvider.swift index 0d2d0ee..17f75d9 100644 --- a/InferenceMeter/Providers/CodexProvider.swift +++ b/InferenceMeter/Providers/CodexProvider.swift @@ -19,6 +19,7 @@ struct CodexProvider: UsageProvider { private let authFileReader: FileReader private let tokenStore: TokenStore private let appServerClient: CodexAppServerClient? + private let now: @Sendable () -> Date init( homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, @@ -26,7 +27,8 @@ struct CodexProvider: UsageProvider { session: URLSession = .shared, authFileReader: FileReader = FileReader(), tokenStore: TokenStore = TokenStore(), - appServerClient: CodexAppServerClient? = nil + appServerClient: CodexAppServerClient? = nil, + now: @escaping @Sendable () -> Date = { Date() } ) { self.homeDirectory = homeDirectory self.endpointConfiguration = endpointConfiguration @@ -36,6 +38,7 @@ struct CodexProvider: UsageProvider { self.appServerClient = appServerClient ?? Self.defaultAppServerClient( homeDirectory: homeDirectory ) + self.now = now } func refresh() async -> Usage { @@ -135,7 +138,28 @@ private extension CodexProvider { } } - return accumulator.usage ?? unavailableUsage(source: .localFile) + guard let usage = accumulator.usage else { + return unavailableUsage(source: .localFile) + } + + return withoutElapsedFiveHourWindow(usage) + } + + /// The scan judges a window against when its event was written. A snapshot can outlive + /// that window, and it must not bring back a five-hour window that has since reset. + func withoutElapsedFiveHourWindow(_ usage: Usage) -> Usage { + guard let resetsAt = usage.fiveHourResetsAt, resetsAt <= now() else { + return usage + } + + guard usage.weeklyPct != nil else { + return unavailableUsage(source: .localFile) + } + + var currentUsage = usage + currentUsage.fiveHourPct = nil + currentUsage.fiveHourResetsAt = nil + return currentUsage } func recentRolloutFiles(limit: Int = Self.maxRecentRolloutFiles) throws -> [URL] { diff --git a/InferenceMeter/UI/DetailPopover.swift b/InferenceMeter/UI/DetailPopover.swift index 6393f02..7ad26a3 100644 --- a/InferenceMeter/UI/DetailPopover.swift +++ b/InferenceMeter/UI/DetailPopover.swift @@ -276,29 +276,11 @@ private struct ProviderUsageSection: View { } VStack(alignment: .leading, spacing: 8) { - if configuration.showsFiveHourUsage { + ForEach(configuration.displayedWindows(for: usage), id: \.title) { window in UsageWindowRow( - title: "5-hour", - percentage: usage.fiveHourPct, - resetsAt: usage.fiveHourResetsAt, - state: usage.state, - now: now - ) - } - - UsageWindowRow( - title: "Weekly", - percentage: usage.weeklyPct, - resetsAt: usage.weeklyResetsAt, - state: usage.state, - now: now - ) - - if configuration.showsFableUsage { - UsageWindowRow( - title: "Fable", - percentage: usage.fablePct, - resetsAt: usage.fableResetsAt, + title: window.title, + percentage: window.percentage, + resetsAt: window.resetsAt, state: usage.state, now: now ) @@ -477,18 +459,22 @@ private struct StatusBadge: View { } } -private struct ProviderDisplayConfiguration { +struct DisplayedUsageWindow: Equatable { + let title: String + let percentage: Double? + let resetsAt: Date? +} + +struct ProviderDisplayConfiguration { let glyph: String let name: String let signInName: String - let showsFiveHourUsage: Bool let showsFableUsage: Bool static let claude = ProviderDisplayConfiguration( glyph: "✳", name: "Claude Code", signInName: "Claude Code", - showsFiveHourUsage: true, showsFableUsage: true ) @@ -496,7 +482,39 @@ private struct ProviderDisplayConfiguration { glyph: "⬡", name: "Codex", signInName: "Codex", - showsFiveHourUsage: true, showsFableUsage: false ) + + func displayedWindows(for usage: Usage) -> [DisplayedUsageWindow] { + var windows = [ + DisplayedUsageWindow( + title: "Weekly", + percentage: usage.weeklyPct, + resetsAt: usage.weeklyResetsAt + ) + ] + + if usage.reportsFiveHourWindow { + windows.insert( + DisplayedUsageWindow( + title: "5-hour", + percentage: usage.fiveHourPct, + resetsAt: usage.fiveHourResetsAt + ), + at: 0 + ) + } + + if showsFableUsage { + windows.append( + DisplayedUsageWindow( + title: "Fable", + percentage: usage.fablePct, + resetsAt: usage.fableResetsAt + ) + ) + } + + return windows + } } diff --git a/InferenceMeter/UI/MenuBarLabel.swift b/InferenceMeter/UI/MenuBarLabel.swift index 01885f5..1e1f9c0 100644 --- a/InferenceMeter/UI/MenuBarLabel.swift +++ b/InferenceMeter/UI/MenuBarLabel.swift @@ -78,14 +78,9 @@ private func providerSegments(usage: Usage, compact: Bool) -> [MenuBarLabelSegme } private func displayedPercentages(for usage: Usage, compact: Bool) -> [Double?] { - let percentages: [Double?] - - switch usage.provider { - case .claude: - percentages = [usage.fiveHourPct, usage.weeklyPct] - case .codex: - percentages = [usage.fiveHourPct, usage.weeklyPct] - } + let percentages = usage.reportsFiveHourWindow + ? [usage.fiveHourPct, usage.weeklyPct] + : [usage.weeklyPct] return compact ? Array(percentages.prefix(1)) : percentages } diff --git a/InferenceMeterTests/CodexProviderTests.swift b/InferenceMeterTests/CodexProviderTests.swift index 943fd86..58151b0 100644 --- a/InferenceMeterTests/CodexProviderTests.swift +++ b/InferenceMeterTests/CodexProviderTests.swift @@ -53,7 +53,8 @@ func codexProviderFallsBackWhenCLIInterfaceIsUnavailable() async throws { ) let provider = CodexProvider( homeDirectory: home, - appServerClient: CodexAppServerClient { nil } + appServerClient: CodexAppServerClient { nil }, + now: { beforeFixtureResets } ) let usage = await provider.refresh() @@ -107,7 +108,7 @@ func codexProviderMapsShuffledWindowsByMinutes() async throws { contents: try fixtureString(named: "codex-rollout-shuffled-windows.jsonl") ) - let usage = await CodexProvider(homeDirectory: home).refresh() + let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh() #expect(usage.state == .ok) #expect(usage.source == .localFile) @@ -127,7 +128,7 @@ func codexProviderTailScanSelectsLastRateLimitEvent() async throws { contents: try fixtureString(named: "codex-rollout-multi-event.jsonl") ) - let usage = await CodexProvider(homeDirectory: home).refresh() + let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh() #expect(usage.state == .ok) #expect(isClose(usage.fiveHourPct, to: 33)) @@ -148,7 +149,7 @@ func codexProviderMergesSparseRateLimitUpdatesByWindowDuration() async throws { """ ) - let usage = await CodexProvider(homeDirectory: home).refresh() + let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh() #expect(usage.state == .ok) #expect(isClose(usage.fiveHourPct, to: 3)) @@ -179,7 +180,7 @@ func codexProviderMergesCurrentWindowsAcrossRecentRolloutFiles() async throws { modificationDate: isoDate("2026-07-12T19:55:32Z") ) - let usage = await CodexProvider(homeDirectory: home).refresh() + let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh() #expect(usage.state == .ok) #expect(isClose(usage.fiveHourPct, to: 3)) @@ -217,7 +218,7 @@ func codexProviderParsesNestedRateLimitPayload() async throws { """ ) - let usage = await CodexProvider(homeDirectory: home).refresh() + let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh() #expect(usage.state == .ok) #expect(isClose(usage.fiveHourPct, to: 17)) @@ -278,7 +279,7 @@ func codexProviderWalksNewestSessionDirectoryWithoutGlobalFileSort() async throw modificationDate: Date(timeIntervalSince1970: 1_800_000_200) ) - let usage = await CodexProvider(homeDirectory: home).refresh() + let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh() #expect(usage.state == .ok) #expect(isClose(usage.fiveHourPct, to: 55)) @@ -300,7 +301,7 @@ func codexProviderReadsLargeRolloutTail() async throws { try writeRollout(home: home, contents: contents) - let usage = await CodexProvider(homeDirectory: home).refresh() + let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh() #expect(usage.state == .ok) #expect(isClose(usage.fiveHourPct, to: 61)) @@ -508,6 +509,9 @@ func codexProviderUnauthorizedEndpointDoesNotFallBackToLocalUsage() async throws } } +// Earlier than every reset time in the rollout data these tests write and read. +private let beforeFixtureResets = Date(timeIntervalSince1970: 1_782_000_000) + private final class CodexProviderFixtureBundleMarker {} private func withTemporaryHome(_ body: (URL) async throws -> Void) async throws { diff --git a/InferenceMeterTests/FiveHourWindowVisibilityTests.swift b/InferenceMeterTests/FiveHourWindowVisibilityTests.swift new file mode 100644 index 0000000..fe85c74 --- /dev/null +++ b/InferenceMeterTests/FiveHourWindowVisibilityTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Testing +@testable import InferenceMeter + +@Test("Claude's fixture with a five-hour window renders every window") +func claudeFixtureWithFiveHourWindowRendersEveryWindow() throws { + let usage = UsageNormalizer.claudeEndpoint(from: try fixtureData(named: "claude-usage-response.json")) + + #expect(usage.reportsFiveHourWindow) + #expect(ProviderDisplayConfiguration.claude.displayedWindows(for: usage).map(\.title) == [ + "5-hour", + "Weekly", + "Fable" + ]) + #expect(labelText(claude: usage, compact: false).hasPrefix("✳ \(rounded(usage.fiveHourPct))·\(rounded(usage.weeklyPct))")) + #expect(labelText(claude: usage, compact: true).hasPrefix("✳ \(rounded(usage.fiveHourPct)) ")) +} + +@Test("Claude's fixture without a five-hour window leaves it out") +func claudeFixtureWithoutFiveHourWindowLeavesItOut() throws { + let usage = UsageNormalizer.claudeEndpoint( + from: try fixtureData(named: "claude-usage-absent-five-hour.json") + ) + + #expect(usage.state == .ok) + #expect(!usage.reportsFiveHourWindow) + #expect(ProviderDisplayConfiguration.claude.displayedWindows(for: usage).map(\.title) == [ + "Weekly", + "Fable" + ]) + #expect(labelText(claude: usage, compact: false).hasPrefix("✳ 41 ")) + #expect(labelText(claude: usage, compact: true).hasPrefix("✳ 41 ")) +} + +@Test("Codex's fixture with a five-hour window renders both windows") +func codexFixtureWithFiveHourWindowRendersBothWindows() throws { + let usage = UsageNormalizer.codexRateLimits( + from: try fixtureData(named: "codex-rollout-rate-limits.jsonl"), + parsedAt: Date(timeIntervalSince1970: 1_782_900_000) + ) + + #expect(ProviderDisplayConfiguration.codex.displayedWindows(for: usage).map(\.title) == [ + "5-hour", + "Weekly" + ]) + #expect(labelText(codex: usage, compact: false).hasSuffix("⬡ 10·15")) + #expect(labelText(codex: usage, compact: true).hasSuffix("⬡ 10")) +} + +@Test("Codex's weekly-only app-server response leaves the five-hour window out") +func codexWeeklyOnlyResponseLeavesFiveHourWindowOut() { + let usage = UsageNormalizer.codexAppServerRateLimits( + from: Data(""" + {"id":2,"result":{"rateLimits":{"limitId":"codex","primary":{"usedPercent":42,"windowDurationMins":10080,"resetsAt":1800604800},"secondary":null}}} + """.utf8), + parsedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + + #expect(usage.state == .ok) + #expect(ProviderDisplayConfiguration.codex.displayedWindows(for: usage).map(\.title) == ["Weekly"]) + #expect(labelText(codex: usage, compact: false).hasSuffix("⬡ 42")) + #expect(labelText(codex: usage, compact: true).hasSuffix("⬡ 42")) +} + +@Test("A provider with no usage values shows one dash and no five-hour row") +func providerWithNoUsageValuesShowsOneDashAndNoFiveHourRow() { + let claude = Usage.unavailable(provider: .claude) + let codex = Usage.unavailable(provider: .codex) + + #expect(labelText(claude: claude, codex: codex, compact: false) == "✳ -- ⬡ --") + #expect(labelText(claude: claude, codex: codex, compact: true) == "✳ -- ⬡ --") + #expect(ProviderDisplayConfiguration.claude.displayedWindows(for: claude).map(\.title) == [ + "Weekly", + "Fable" + ]) + #expect(ProviderDisplayConfiguration.codex.displayedWindows(for: codex).map(\.title) == ["Weekly"]) +} + +@Test("An expired Codex five-hour window is not shown and its value never appears as weekly") +func expiredCodexFiveHourWindowIsNotShownOrShownAsWeekly() { + let parsedAt = Date(timeIntervalSince1970: 1_800_000_000) + let usage = UsageNormalizer.codexAppServerRateLimits( + from: Data(""" + {"id":2,"result":{"rateLimits":{"limitId":"codex","primary":{"usedPercent":88,"windowDurationMins":300,"resetsAt":1799999999},"secondary":{"usedPercent":42,"windowDurationMins":10080,"resetsAt":1800604800}}}} + """.utf8), + parsedAt: parsedAt + ) + + #expect(usage.fiveHourPct == nil) + #expect(ProviderDisplayConfiguration.codex.displayedWindows(for: usage).map(\.title) == ["Weekly"]) + #expect(labelText(codex: usage, compact: false).hasSuffix("⬡ 42")) + #expect(labelText(codex: usage, compact: true).hasSuffix("⬡ 42")) +} + +@Test("A Codex rollout window whose reset has passed since it was written is not shown") +func codexRolloutWindowWhoseResetHasPassedIsNotShown() async throws { + // Valid when written on 2026-10-01: the five-hour window resets two hours later. + let home = try temporaryCodexHome(rollout: """ + {"timestamp":"2026-10-01T10:00:00Z","rate_limits":{"primary":{"used_percent":88.0,"window_minutes":300,"resets_at":1790856000},"secondary":{"used_percent":42.0,"window_minutes":10080,"resets_at":1791300000}}} + """) + defer { try? FileManager.default.removeItem(at: home) } + + let provider = { (now: Date) in + CodexProvider( + homeDirectory: home, + appServerClient: CodexAppServerClient { nil }, + now: { now } + ) + } + + let beforeReset = await provider(Date(timeIntervalSince1970: 1_790_855_999)).refresh() + #expect(beforeReset.fiveHourPct == 88) + #expect(beforeReset.weeklyPct == 42) + + let atReset = await provider(Date(timeIntervalSince1970: 1_790_856_000)).refresh() + #expect(atReset.state == .ok) + #expect(atReset.fiveHourPct == nil) + #expect(atReset.fiveHourResetsAt == nil) + #expect(atReset.weeklyPct == 42) + #expect(labelText(codex: atReset, compact: false).hasSuffix("⬡ 42")) +} + +@Test("A Codex rollout holding only a reset five-hour window is a failed refresh") +func codexRolloutHoldingOnlyResetFiveHourWindowIsFailedRefresh() async throws { + let home = try temporaryCodexHome(rollout: """ + {"timestamp":"2026-10-01T10:00:00Z","rate_limits":{"primary":{"used_percent":88.0,"window_minutes":300,"resets_at":1790856000}}} + """) + defer { try? FileManager.default.removeItem(at: home) } + + let usage = await CodexProvider( + homeDirectory: home, + appServerClient: CodexAppServerClient { nil }, + now: { Date(timeIntervalSince1970: 1_790_856_000) } + ).refresh() + + #expect(usage.state == .unavailable) + #expect(usage.fiveHourPct == nil) +} + +private func temporaryCodexHome(rollout: String) throws -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("InferenceMeterFiveHourTests-\(UUID().uuidString)", isDirectory: true) + let dayDirectory = home.appendingPathComponent(".codex/sessions/2026/10/01", isDirectory: true) + try FileManager.default.createDirectory(at: dayDirectory, withIntermediateDirectories: true) + try Data(rollout.utf8).write(to: dayDirectory.appendingPathComponent("rollout-test.jsonl")) + return home +} + +private func labelText( + claude: Usage = .unavailable(provider: .claude), + codex: Usage = .unavailable(provider: .codex), + compact: Bool +) -> String { + labelSegments(claude: claude, codex: codex, compact: compact).map(\.text).joined() +} + +private func rounded(_ percentage: Double?) -> String { + String(format: "%2d", Int((percentage ?? -1).rounded())) +} + +private final class FixtureBundleMarker {} + +private func fixtureData(named name: String) throws -> Data { + let bundle = Bundle(for: FixtureBundleMarker.self) + + if let url = bundle.url(forResource: name, withExtension: nil, subdirectory: "Fixtures") { + return try Data(contentsOf: url) + } + + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures") + .appendingPathComponent(name) + + return try Data(contentsOf: fixtureURL) +} diff --git a/InferenceMeterTests/MenuBarLabelTests.swift b/InferenceMeterTests/MenuBarLabelTests.swift index ac90fa7..9660363 100644 --- a/InferenceMeterTests/MenuBarLabelTests.swift +++ b/InferenceMeterTests/MenuBarLabelTests.swift @@ -129,42 +129,62 @@ func unavailableProvidersRenderDashesInsteadOfZeroValues() { compact: true ) - #expect(segmentText(fullSegments) == "✳ --·-- ⬡ 0·70") + #expect(segmentText(fullSegments) == "✳ -- ⬡ 0·70") #expect(fullSegments[1].color == .secondary) - #expect(fullSegments[3].color == .secondary) + #expect(fullSegments[4].color == Color(.systemGreen)) #expect(fullSegments[6].color == Color(.systemGreen)) - #expect(fullSegments[8].color == Color(.systemGreen)) #expect(segmentText(compactSegments) == "✳ -- ⬡ 0") #expect(compactSegments[1].color == .secondary) #expect(compactSegments[4].color == Color(.systemGreen)) } -@Test("Nil values render as dashes and single digits use a stable two-character slot") -func nilValuesRenderAsDashesAndSingleDigitsUseStableSlot() { +@Test("A reported five-hour window without a weekly value keeps the weekly dash") +func reportedFiveHourWindowWithoutWeeklyValueKeepsWeeklyDash() { let segments = labelSegments( claude: usage(provider: .claude, fiveHourPct: 9.4, weeklyPct: nil), - codex: usage(provider: .codex, fiveHourPct: nil, weeklyPct: 10.4), + codex: usage(provider: .codex, fiveHourPct: 99, weeklyPct: nil), compact: false ) - #expect(segmentText(segments) == "✳ 9·-- ⬡ --·10") + #expect(segmentText(segments) == "✳ 9·-- ⬡ 99·--") #expect(segments[1] == MenuBarLabelSegment(text: " 9", color: Color(.systemGreen))) #expect(segments[3] == MenuBarLabelSegment(text: "--", color: .secondary)) - #expect(segments[6] == MenuBarLabelSegment(text: "--", color: .secondary)) - #expect(segments[8] == MenuBarLabelSegment(text: "10", color: Color(.systemGreen))) + #expect(segments.last == MenuBarLabelSegment(text: "--", color: .secondary)) } -@Test("Codex renders an available five-hour value when weekly usage is absent") -func codexRendersFiveHourValueWhenWeeklyUsageIsAbsent() { - let segments = labelSegments( - claude: usage(provider: .claude, fiveHourPct: 45, weeklyPct: 70), - codex: usage(provider: .codex, fiveHourPct: 99, weeklyPct: nil), - compact: false - ) +@Test("An absent five-hour window leaves only the weekly value in both label modes") +func absentFiveHourWindowLeavesOnlyWeeklyValueInBothLabelModes() { + let claude = usage(provider: .claude, fiveHourPct: nil, weeklyPct: 70) + let codex = usage(provider: .codex, fiveHourPct: nil, weeklyPct: 10.4) - #expect(segmentText(segments) == "✳ 45·70 ⬡ 99·--") - #expect(segments.last == MenuBarLabelSegment(text: "--", color: .secondary)) + let fullSegments = labelSegments(claude: claude, codex: codex, compact: false) + let compactSegments = labelSegments(claude: claude, codex: codex, compact: true) + + #expect(segmentText(fullSegments) == "✳ 70 ⬡ 10") + #expect(fullSegments[1] == MenuBarLabelSegment(text: "70", color: Color(.systemOrange))) + #expect(fullSegments.last == MenuBarLabelSegment(text: "10", color: Color(.systemGreen))) + #expect(segmentText(compactSegments) == "✳ 70 ⬡ 10") +} + +@Test("An absent five-hour window stays out of the label in every state") +func absentFiveHourWindowStaysOutOfLabelInEveryState() { + let cases: [(UsageState, String)] = [ + (.stale, "✳ 70 ⬡ 40"), + (.refreshRequired, "✳ 70 ⬡ 40"), + (.unauthorized, "✳ ! ⬡ 40"), + (.unavailable, "✳ -- ⬡ 40") + ] + + for (state, expectedText) in cases { + let segments = labelSegments( + claude: usage(provider: .claude, fiveHourPct: nil, weeklyPct: 70, state: state), + codex: usage(provider: .codex, fiveHourPct: nil, weeklyPct: 40), + compact: false + ) + + #expect(segmentText(segments) == expectedText) + } } private func segmentText(_ segments: [MenuBarLabelSegment]) -> String { diff --git a/InferenceMeterTests/NotifierTests.swift b/InferenceMeterTests/NotifierTests.swift index ff78a18..a08abfe 100644 --- a/InferenceMeterTests/NotifierTests.swift +++ b/InferenceMeterTests/NotifierTests.swift @@ -137,6 +137,21 @@ func codexFiveHourValuesPostThresholdNotifications() async { ]) } +@MainActor +@Test("An absent five-hour window posts no five-hour notification for either provider") +func absentFiveHourWindowPostsNoFiveHourNotification() async { + let fixture = NotifierFixture() + + await fixture.notifier.evaluate( + state: fixture.state(claudeWeeklyPct: 81, codexWeeklyPct: 81) + ) + + #expect(fixture.poster.notifications.map(\.identifier) == [ + "inference-meter.threshold.claude.weekly.80", + "inference-meter.threshold.codex.weekly.80" + ]) +} + @MainActor @Test("Fable notification copy uses scoped model label") func fableNotificationCopyUsesScopedModelLabel() async { diff --git a/InferenceMeterTests/RefreshEngineTests.swift b/InferenceMeterTests/RefreshEngineTests.swift index 76b5eab..6521f6c 100644 --- a/InferenceMeterTests/RefreshEngineTests.swift +++ b/InferenceMeterTests/RefreshEngineTests.swift @@ -169,6 +169,56 @@ func claudeRefreshAttemptsStayThrottledWhenBackoffIsBypassed() async { #expect(await provider.refreshCallCount == 2) } +@MainActor +@Test("A five-hour window leaves and returns with successful refreshes", arguments: [Provider.claude, .codex]) +func fiveHourWindowLeavesAndReturnsWithSuccessfulRefreshes(provider: Provider) async { + let clock = TestRefreshClock() + let usageProvider = ScriptedUsageProvider( + provider: provider, + responses: [ + usage(provider: provider, fiveHourPct: 45, weeklyPct: 70, updatedAt: clock.now), + usage(provider: provider, fiveHourPct: nil, weeklyPct: 71, updatedAt: clock.now), + usage(provider: provider, fiveHourPct: 12, weeklyPct: 72, updatedAt: clock.now) + ] + ) + let appState = AppState() + let engine = RefreshEngine( + appState: appState, + providers: [usageProvider], + clock: clock, + configuration: testConfiguration() + ) + let refreshAfterThrottle = { + // Claude's endpoint attempts are limited to one every five minutes. + clock.advance(by: 300) + await engine.refresh(provider: provider, bypassingBackoff: true) + } + let labelText = { + labelSegments( + claude: provider == .claude ? appState.claude : .unavailable(provider: .claude), + codex: provider == .codex ? appState.codex : .unavailable(provider: .codex), + compact: false + ) + .map(\.text) + .joined() + } + let symbol = provider == .claude ? "✳" : "⬡" + + await refreshAfterThrottle() + #expect(appState.usage(for: provider).fiveHourPct == 45) + #expect(labelText().contains("\(symbol) 45·70")) + + await refreshAfterThrottle() + #expect(appState.usage(for: provider).fiveHourPct == nil) + #expect(appState.usage(for: provider).weeklyPct == 71) + #expect(labelText().contains("\(symbol) 71")) + #expect(!labelText().contains("\(symbol) 71·")) + + await refreshAfterThrottle() + #expect(appState.usage(for: provider).fiveHourPct == 12) + #expect(labelText().contains("\(symbol) 12·72")) +} + @MainActor @Test("Failed refresh keeps the last known usage values") func failedRefreshKeepsLastKnownUsageValues() async { diff --git a/README.md b/README.md index 141cd79..1f2f1de 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A private, native macOS menu bar meter for Claude Code and Codex usage limits. -InferenceMeter keeps Claude's and Codex's provider-reported five-hour and weekly windows visible without opening either CLI. Claude's scoped Fable limit is shown when available, and threshold notifications can warn at 80% and 95%. +InferenceMeter keeps Claude's and Codex's weekly windows visible without opening either CLI. A five-hour window appears when the provider reports it for your account and returns on its own if the provider reports it again. Claude's scoped Fable limit is shown when available, and threshold notifications can warn at 80% and 95%. > [!IMPORTANT] > InferenceMeter relies on provider-owned, undocumented usage data. Claude Code or Codex updates can change those formats without notice. The project is maintained on a best-effort basis and is not affiliated with, endorsed by, or supported by Anthropic or OpenAI. @@ -14,8 +14,8 @@ InferenceMeter keeps Claude's and Codex's provider-reported five-hour and weekly ## Features - Native SwiftUI menu bar app with no Dock icon -- Claude Code five-hour, weekly, and Fable usage -- Codex usage windows reported by the installed CLI, with recent-session fallback +- Claude Code weekly and Fable usage, plus the five-hour window when Claude reports it +- Codex weekly usage from the installed CLI, with recent-session fallback, plus the five-hour window when Codex reports it - Reset times and stale-data indicators - Optional 80% and 95% macOS notifications - Wake-from-sleep and Codex session-file refreshes