diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5b485915c..d83b28b43e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: timeout-minutes: 5 outputs: macos-tests: ${{ steps.macos-tests.outputs.macos-tests }} + macos-tests-reason: ${{ steps.macos-tests.outputs.macos-tests-reason }} + changed-path-count: ${{ steps.macos-tests.outputs.changed-path-count }} steps: - uses: actions/checkout@v6 with: @@ -49,6 +51,26 @@ jobs: sed 's/^/- /' "$changed_paths" ./Scripts/ci_macos_test_gate.sh "$changed_paths" + - name: Summarize macOS test gate + if: ${{ always() }} + shell: bash + env: + MACOS_TESTS: ${{ steps.macos-tests.outputs.macos-tests }} + MACOS_TESTS_REASON: ${{ steps.macos-tests.outputs.macos-tests-reason }} + CHANGED_PATH_COUNT: ${{ steps.macos-tests.outputs.changed-path-count }} + run: | + set -euo pipefail + reason="${MACOS_TESTS_REASON:-}" + reason="${reason//|/\\|}" + { + printf '### macOS test gate\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-}" + printf '| Reason | %s |\n' "$reason" + printf '| Changed path entries | `%s` |\n' "${CHANGED_PATH_COUNT:-}" + } >> "$GITHUB_STEP_SUMMARY" + lint: runs-on: ubuntu-latest timeout-minutes: 10 @@ -105,6 +127,26 @@ jobs: CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }} \ ./Scripts/test.sh + - name: Summarize macOS shard + if: ${{ always() }} + shell: bash + env: + SHARD_INDEX: ${{ matrix.shard-index }} + SHARD_COUNT: ${{ matrix.shard-count }} + RUNS_LINT_MACOS: ${{ matrix.shard-index == 0 }} + run: | + set -euo pipefail + xcode_version="$(/usr/bin/xcodebuild -version 2>/dev/null | tr '\n' ' ' || true)" + { + printf '### macOS Swift shard\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| Shard | `%s / %s` |\n' "$SHARD_INDEX" "$SHARD_COUNT" + printf '| Runner | `%s` |\n' "${RUNNER_NAME:-unknown}" + printf '| Xcode | `%s` |\n' "${xcode_version:-unknown}" + printf '| Runs lint-macos | `%s` |\n' "$RUNS_LINT_MACOS" + } >> "$GITHUB_STEP_SUMMARY" + lint-build-test: runs-on: ubuntu-latest timeout-minutes: 5 @@ -124,6 +166,30 @@ jobs: "${{ needs.changes.outputs.macos-tests }}" \ "${{ needs.swift-test-macos.result }}" + - name: Summarize aggregate CI gate + if: ${{ always() }} + shell: bash + env: + LINT_RESULT: ${{ needs.lint.result }} + CHANGES_RESULT: ${{ needs.changes.result }} + MACOS_TESTS: ${{ needs.changes.outputs.macos-tests }} + MACOS_TESTS_REASON: ${{ needs.changes.outputs.macos-tests-reason }} + MACOS_RESULT: ${{ needs.swift-test-macos.result }} + run: | + set -euo pipefail + reason="${MACOS_TESTS_REASON:-}" + reason="${reason//|/\\|}" + { + printf '### Aggregate CI gate\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| lint result | `%s` |\n' "$LINT_RESULT" + printf '| changes result | `%s` |\n' "$CHANGES_RESULT" + printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-}" + printf '| macOS gate reason | %s |\n' "$reason" + printf '| swift-test-macos result | `%s` |\n' "$MACOS_RESULT" + } >> "$GITHUB_STEP_SUMMARY" + build-linux-cli: timeout-minutes: 20 strategy: @@ -212,3 +278,22 @@ jobs: fi "$BIN" usage --provider codex --web 2>&1 | tee /tmp/codexbarcli-stderr.txt >/dev/null || true grep -q "macOS" /tmp/codexbarcli-stderr.txt + + - name: Summarize Linux CLI build + if: ${{ always() }} + shell: bash + env: + MATRIX_NAME: ${{ matrix.name }} + MATRIX_RUNS_ON: ${{ matrix.runs-on }} + SWIFT_CACHE_HIT: ${{ steps.swift-toolchain-cache.outputs.cache-hit }} + run: | + set -euo pipefail + { + printf '### Linux CLI build\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| Matrix | `%s` |\n' "$MATRIX_NAME" + printf '| Runner label | `%s` |\n' "$MATRIX_RUNS_ON" + printf '| Swift version | `%s` |\n' "$SWIFT_VERSION" + printf '| Swift toolchain cache hit | `%s` |\n' "${SWIFT_CACHE_HIT:-false}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e76f7a93..9e9d9e1f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 0.37.3 — Unreleased +### Fixed +- Memory pressure: finish isolating utility-queue source reads from main-actor state to prevent the remaining callback crash. Thanks @Zihao-Qi! +- Claude: stop installed-version checks from invoking a login shell and triggering unwanted Keychain prompts. Thanks @enieuwy! +- Localization: reject blank translated values and restore the affected Vietnamese provider prompts. Thanks @kiranmagic7! +- Usage totals: keep Today tied to the current local calendar day across cost, Admin API, and Poe surfaces instead of showing the latest historical bucket. Thanks @Zihao-Qi! +- Antigravity: align compact icons and automatic highest-usage selection with grouped Gemini and Claude/GPT 5-hour and weekly lanes while ignoring non-renderable cadences. Thanks @Yuxin-Qiao! + ## 0.37.2 — 2026-06-22 ### Added diff --git a/Scripts/check-app-locales.mjs b/Scripts/check-app-locales.mjs index e36b1df3a2..607018b80c 100644 --- a/Scripts/check-app-locales.mjs +++ b/Scripts/check-app-locales.mjs @@ -45,6 +45,10 @@ function formatKeyList(keys, limit = 12) { return remaining > 0 ? `${shown}, ... +${remaining} more` : shown; } +function blankKeys(catalog, referenceKeys) { + return referenceKeys.filter((key) => Object.hasOwn(catalog, key) && !catalog[key]?.trim()); +} + function swiftInterpolationTokens(value) { const tokens = []; for (let index = 0; index < value.length - 1; index += 1) { @@ -82,6 +86,10 @@ if (isTest) { formatKeyList(["alpha", "beta", "gamma", "delta"], 2), "alpha, beta, ... +2 more", "truncated key list"); + assertEqual( + blankKeys({ alpha: "", beta: " ", gamma: "ok" }, ["alpha", "beta", "gamma", "delta"]), + ["alpha", "beta"], + "blank keys"); console.log("app locale checker tests OK"); process.exit(0); } @@ -106,6 +114,7 @@ for (const directory of fs.readdirSync(resources).filter((name) => name.endsWith checkedCount++; const catalogKeys = Object.keys(catalog); + const emptyKeys = blankKeys(catalog, englishKeys); // 1. Missing keys const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key)); @@ -134,15 +143,17 @@ for (const directory of fs.readdirSync(resources).filter((name) => name.endsWith } } + if (emptyKeys.length > 0) { + console.error( + `\x1b[31m[${locale}] Error: Blank values for ${emptyKeys.length} keys: ${formatKeyList(emptyKeys)}.\x1b[0m`); + hasErrors = true; + } + // 2. Identical values count let identicalCount = 0; for (const key of englishKeys) { if (!catalog[key]?.trim()) { - if (strictLocales.includes(locale) && catalogKeys.includes(key)) { - console.error(`\x1b[31m[${locale}] Error: Blank value for strict locale key "${key}".\x1b[0m`); - hasErrors = true; - } continue; } diff --git a/Scripts/ci_macos_test_gate.sh b/Scripts/ci_macos_test_gate.sh index 246bcee070..94a14e4799 100755 --- a/Scripts/ci_macos_test_gate.sh +++ b/Scripts/ci_macos_test_gate.sh @@ -86,8 +86,16 @@ if [[ "$path_count" -eq 0 ]]; then require_macos_tests '' 'no changed paths were reported' fi +if [[ "$macos_tests" == true ]]; then + summary_reason="$macos_tests_reason" +else + summary_reason="docs/site-only changes covered by portable checks" +fi + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then printf 'macos-tests=%s\n' "$macos_tests" >> "$GITHUB_OUTPUT" + printf 'macos-tests-reason=%s\n' "$summary_reason" >> "$GITHUB_OUTPUT" + printf 'changed-path-count=%s\n' "$path_count" >> "$GITHUB_OUTPUT" fi if [[ "$macos_tests" == true ]]; then diff --git a/Scripts/test_ci_path_gate.sh b/Scripts/test_ci_path_gate.sh index 3cea56bb8a..3574475745 100755 --- a/Scripts/test_ci_path_gate.sh +++ b/Scripts/test_ci_path_gate.sh @@ -21,6 +21,26 @@ assert_gate() { printf '%s: expected macos-tests=%s, got %s\n' "$name" "$expected" "${actual:-}" >&2 exit 1 fi + + local reason + reason="$(sed -n 's/^macos-tests-reason=//p' "$output_file")" + if [[ -z "$reason" ]]; then + printf '%s: expected macos-tests-reason output\n' "$name" >&2 + exit 1 + fi + + local path_count + path_count="$(sed -n 's/^changed-path-count=//p' "$output_file")" + if ! [[ "$path_count" =~ ^[0-9]+$ ]]; then + printf '%s: expected numeric changed-path-count output, got %s\n' \ + "$name" "${path_count:-}" >&2 + exit 1 + fi + + if [[ "$expected" == false && "$reason" != "docs/site-only changes covered by portable checks" ]]; then + printf '%s: expected docs/site skip reason, got %s\n' "$name" "$reason" >&2 + exit 1 + fi } assert_gate false docs-only $'M\tdocs/providers.md' $'M\tREADME.md' diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift index fe62830d16..83adcf0584 100644 --- a/Sources/CodexBar/IconRemainingResolver.swift +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -3,8 +3,7 @@ import CodexBarCore enum IconRemainingResolver { private static let visibleZeroPercent = 0.0001 private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" - private static let antigravityGeminiQuotaBucketIDPrefix = "gemini-" - // Antigravity quota summaries currently expose exact 5-hour session and weekly buckets for the compact icon. + // Antigravity quota summaries expose exact 5-hour session and weekly buckets for the compact icon. private static let sessionWindowMinutes = 5 * 60 private static let weeklyWindowMinutes = 7 * 24 * 60 @@ -38,13 +37,6 @@ enum IconRemainingResolver { } ?? [] guard !quotaSummaryWindows.isEmpty else { return nil } - let geminiWindows = quotaSummaryWindows.filter(Self.isAntigravityGeminiQuotaSummaryWindow) - // The Antigravity menu-bar icon represents Gemini quotas. If any Gemini cadence is present, - // keep missing Gemini lanes empty instead of silently borrowing Claude + GPT quota. - if !geminiWindows.isEmpty { - return self.antigravityQuotaSummaryPair(in: geminiWindows.filter(\.usageKnown)) - ?? (primary: nil, secondary: nil) - } return self.antigravityQuotaSummaryPair(in: quotaSummaryWindows.filter(\.usageKnown)) } @@ -58,15 +50,6 @@ enum IconRemainingResolver { return (primary: session, secondary: weekly) } - private static func isAntigravityGeminiQuotaSummaryWindow(_ window: NamedRateWindow) -> Bool { - self.antigravityQuotaSummaryBucketID(for: window)?.hasPrefix(self.antigravityGeminiQuotaBucketIDPrefix) == true - } - - private static func antigravityQuotaSummaryBucketID(for window: NamedRateWindow) -> String? { - guard window.id.hasPrefix(self.antigravityQuotaSummaryWindowIDPrefix) else { return nil } - return String(window.id.dropFirst(self.antigravityQuotaSummaryWindowIDPrefix.count)) - } - /// Returns the highest-usage window for an exact Antigravity compact-icon cadence. private static func mostConstrainedWindow(in windows: [NamedRateWindow], windowMinutes: Int) -> RateWindow? { windows diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 762007bdc0..82656c7c15 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -76,7 +76,7 @@ extension UsageMenuCardView.Model { if input.provider == .poe, let usage = input.snapshot?.poeUsage { - return self.poeUsageNotes(usage) + return self.poeUsageNotes(usage, now: input.now) } if input.provider == .ollama, @@ -89,7 +89,7 @@ extension UsageMenuCardView.Model { } static func openAIAPIUsageNotes(_ usage: OpenAIAPIUsageSnapshot) -> [String] { - let today = usage.latestDay + let today = usage.currentDay let seven = usage.last7Days let thirty = usage.last30Days let historyLabel = usage.historyWindowLabel @@ -113,8 +113,12 @@ extension UsageMenuCardView.Model { return notes } - static func poeUsageNotes(_ usage: PoeUsageHistorySnapshot) -> [String] { - let today = usage.latestDay + static func poeUsageNotes( + _ usage: PoeUsageHistorySnapshot, + now: Date = Date(), + calendar: Calendar = .current) -> [String] + { + let today = usage.currentDay(now: now, calendar: calendar) let week = usage.last7Days let month = usage.last30Days let todayUSD = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" @@ -196,7 +200,7 @@ extension UsageMenuCardView.Model { let usage = input.snapshot?.poeUsage, !usage.daily.isEmpty { - return Self.poeInlineDashboard(usage) + return Self.poeInlineDashboard(usage, now: input.now) } if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), input.tokenCostUsageEnabled, @@ -229,8 +233,12 @@ extension UsageMenuCardView.Model { } } - static func poeInlineDashboard(_ usage: PoeUsageHistorySnapshot) -> InlineUsageDashboardModel { - let today = usage.latestDay + static func poeInlineDashboard( + _ usage: PoeUsageHistorySnapshot, + now: Date = Date(), + calendar: Calendar = .current) -> InlineUsageDashboardModel + { + let today = usage.currentDay(now: now, calendar: calendar) let week = usage.last7Days let month = usage.last30Days let points = usage.daily.suffix(30).map { @@ -318,7 +326,9 @@ extension UsageMenuCardView.Model { value: cost, accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))") } - let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } + let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily) + let usesLatestPrimary = provider == .bedrock || provider == .mistral + let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD var details: [String] = [] if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") @@ -337,8 +347,8 @@ extension UsageMenuCardView.Model { valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), kpis: [ .init( - title: provider == .bedrock || provider == .mistral ? L("Latest") : L("Today"), - value: latest?.costUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + title: usesLatestPrimary ? L("Latest") : L("Today"), + value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", emphasis: true), .init( title: historyTitle, @@ -378,7 +388,7 @@ extension UsageMenuCardView.Model { fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) -> InlineUsageDashboardModel { - let today = usage.latestDay + let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days let points = usage.daily.suffix(30).map { diff --git a/Sources/CodexBar/MemoryPressureMonitor.swift b/Sources/CodexBar/MemoryPressureMonitor.swift index 3b2e3fc8a2..9af67a3986 100644 --- a/Sources/CodexBar/MemoryPressureMonitor.swift +++ b/Sources/CodexBar/MemoryPressureMonitor.swift @@ -64,7 +64,7 @@ final class MemoryPressureMonitor { eventMask: [.warning, .critical], queue: .global(qos: .utility)) source.setEventHandler(handler: Self.makeEventHandler( - eventReader: { [weak source] in source?.data ?? [] }, + source: source, handle: { [weak self] isWarning, isCritical in self?.handleMemoryPressure(isWarning: isWarning, isCritical: isCritical) })) @@ -72,6 +72,16 @@ final class MemoryPressureMonitor { source.resume() } + nonisolated static func makeEventHandler( + source: DispatchSourceMemoryPressure, + handle: @escaping @MainActor @Sendable (_ isWarning: Bool, _ isCritical: Bool) -> Void) + -> @Sendable () -> Void + { + self.makeEventHandler( + eventReader: { [weak source] in source?.data ?? [] }, + handle: handle) + } + nonisolated static func makeEventHandler( eventReader: @escaping @Sendable () -> DispatchSource.MemoryPressureEvent, handle: @escaping @MainActor @Sendable (_ isWarning: Bool, _ isCritical: Bool) -> Void) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 75cc63d059..fb8a88ba24 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -293,7 +293,7 @@ struct MenuDescriptor { entries: inout [Entry], usage: OpenAIAPIUsageSnapshot) { - let today = usage.latestDay + let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days let historyLabel = usage.historyWindowLabel @@ -319,7 +319,7 @@ struct MenuDescriptor { entries: inout [Entry], usage: ClaudeAdminAPIUsageSnapshot) { - let today = usage.latestDay + let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days @@ -393,7 +393,7 @@ struct MenuDescriptor { entries: inout [Entry], usage: PoeUsageHistorySnapshot) { - let today = usage.latestDay + let today = usage.currentDay() let week = usage.last7Days let month = usage.last30Days let todayCostSuffix = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 47724b65a8..7438c3ec44 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -775,8 +775,8 @@ "Quota" = "Hạn mức"; "tokens" = "mã thông báo"; "requests" = "yêu cầu"; -"Latest" = ""; -"Monthly" = "Mới nhất"; +"Latest" = "Mới nhất"; +"Monthly" = "Hàng tháng"; "Sonnet" = "Sonnet"; "Overages" = "Quá tải"; "Activity" = "Hoạt động"; diff --git a/Sources/CodexBar/UsageStore+HighestUsage.swift b/Sources/CodexBar/UsageStore+HighestUsage.swift index 3b51034f1e..e3ae9f093f 100644 --- a/Sources/CodexBar/UsageStore+HighestUsage.swift +++ b/Sources/CodexBar/UsageStore+HighestUsage.swift @@ -30,10 +30,7 @@ extension UsageStore { private func menuBarMetricWindowForHighestUsage(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { let effectivePreference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) if provider == .antigravity, effectivePreference == .automatic { - let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) - return [windows.primary, windows.secondary] - .compactMap(\.self) - .max(by: { $0.usedPercent < $1.usedPercent }) + return Self.mostConstrainedAntigravityQuotaSummaryWindow(snapshot: snapshot) } return MenuBarMetricWindowResolver.rateWindow( preference: effectivePreference, @@ -56,9 +53,9 @@ extension UsageStore { return percents.allSatisfy { $0 >= 100 } } if provider == .antigravity, effectivePreference == .automatic { - let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) - let percents = [windows.primary?.usedPercent, windows.secondary?.usedPercent].compactMap(\.self) - return percents.allSatisfy { $0 >= 100 } + let windows = Self.antigravityRenderedQuotaSummaryWindows(snapshot: snapshot) + guard !windows.isEmpty else { return true } + return windows.allSatisfy { $0.usedPercent >= 100 } } if provider == .copilot, effectivePreference == .automatic, @@ -82,4 +79,26 @@ extension UsageStore { return true } + + private nonisolated static func mostConstrainedAntigravityQuotaSummaryWindow( + snapshot: UsageSnapshot) + -> RateWindow? + { + let windows = self.antigravityRenderedQuotaSummaryWindows(snapshot: snapshot) + guard !windows.isEmpty else { return nil } + + let usableWindows = windows.filter { $0.usedPercent < 100 } + if let maxUsable = usableWindows.max(by: { $0.usedPercent < $1.usedPercent }) { + return maxUsable + } + return windows.max(by: { $0.usedPercent < $1.usedPercent }) + } + + private nonisolated static func antigravityRenderedQuotaSummaryWindows( + snapshot: UsageSnapshot) + -> [RateWindow] + { + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + return [windows.primary, windows.secondary].compactMap(\.self) + } } diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 8958463ade..d6bf78b5b9 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -170,6 +170,19 @@ extension CodexBarCLI { return fallback ? .absolute : .countdown } + static func weeklyProgressWorkDaysFromDefaults() -> Int? { + let domains = [ + "com.steipete.codexbar", + "com.steipete.codexbar.debug", + ] + for domain in domains { + if let value = UserDefaults(suiteName: domain)?.object(forKey: "weeklyProgressWorkDays") as? Int { + return value + } + } + return UserDefaults.standard.object(forKey: "weeklyProgressWorkDays") as? Int + } + static func fetchProviderUsage( provider: UsageProvider, context: ProviderFetchContext) async -> ProviderFetchOutcome diff --git a/Sources/CodexBarCLI/CLIPayloads.swift b/Sources/CodexBarCLI/CLIPayloads.swift index 59d7dadd0a..5d530edbc6 100644 --- a/Sources/CodexBarCLI/CLIPayloads.swift +++ b/Sources/CodexBarCLI/CLIPayloads.swift @@ -16,6 +16,7 @@ struct ProviderPayload: Encodable { let antigravityPlanInfo: AntigravityPlanInfoSummary? let openaiDashboard: OpenAIDashboardSnapshot? let error: ProviderErrorPayload? + let pace: ProviderPacePayload? private enum CodingKeys: String, CodingKey { case provider @@ -28,6 +29,7 @@ struct ProviderPayload: Encodable { case antigravityPlanInfo case openaiDashboard case error + case pace } init( @@ -41,7 +43,8 @@ struct ProviderPayload: Encodable { credits: CreditsSnapshot?, antigravityPlanInfo: AntigravityPlanInfoSummary?, openaiDashboard: OpenAIDashboardSnapshot?, - error: ProviderErrorPayload?) + error: ProviderErrorPayload?, + pace: ProviderPacePayload? = nil) { self.provider = provider.rawValue self.account = account @@ -54,6 +57,7 @@ struct ProviderPayload: Encodable { self.antigravityPlanInfo = antigravityPlanInfo self.openaiDashboard = openaiDashboard self.error = error + self.pace = pace } init( @@ -67,7 +71,8 @@ struct ProviderPayload: Encodable { credits: CreditsSnapshot?, antigravityPlanInfo: AntigravityPlanInfoSummary?, openaiDashboard: OpenAIDashboardSnapshot?, - error: ProviderErrorPayload?) + error: ProviderErrorPayload?, + pace: ProviderPacePayload? = nil) { self.provider = providerID self.account = account @@ -80,9 +85,27 @@ struct ProviderPayload: Encodable { self.antigravityPlanInfo = antigravityPlanInfo self.openaiDashboard = openaiDashboard self.error = error + self.pace = pace } } +struct ProviderPacePayload: Encodable { + let primary: PacePayload? + let secondary: PacePayload? +} + +struct PacePayload: Encodable { + let stage: String + /// Rounded (used − expected); positive = deficit, negative = reserve. + let deltaPercent: Double + let expectedUsedPercent: Double + let willLastToReset: Bool + let etaSeconds: TimeInterval? + /// Always absent in CLI output; kept for schema parity. + let runOutProbability: Double? + let summary: String +} + struct ProviderStatusPayload: Encodable { let indicator: ProviderStatusIndicator let description: String? diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 020b71aee4..dc44a049fa 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -12,11 +12,11 @@ enum CLIRenderer { provider: UsageProvider, snapshot: UsageSnapshot, credits: CreditsSnapshot?, - context: RenderContext) -> String + context: RenderContext, + now: Date = Date()) -> String { let meta = ProviderDescriptorRegistry.descriptor(for: provider).metadata let labels = self.rateWindowLabels(provider: provider, metadata: meta, snapshot: snapshot) - let now = Date() var lines: [String] = [] lines.append(self.headerLine(context.header, useColor: context.useColor)) self.appendPrimaryLines( @@ -63,6 +63,22 @@ enum CLIRenderer { return lines.joined(separator: "\n") } + static func providerPacePayload( + provider: UsageProvider, + snapshot: UsageSnapshot, + weeklyWorkDays: Int? = nil, + now: Date = Date()) -> ProviderPacePayload? + { + let primary = snapshot.primary.flatMap { + self.pacePayload(provider: provider, window: $0, kind: .session, now: now) + } + let secondary = snapshot.secondary.flatMap { + self.pacePayload(provider: provider, window: $0, kind: .weekly, weeklyWorkDays: weeklyWorkDays, now: now) + } + guard primary != nil || secondary != nil else { return nil } + return ProviderPacePayload(primary: primary, secondary: secondary) + } + static func rateLine(title: String, window: RateWindow, useColor: Bool) -> String { let text = UsageFormatter.usageLine( remaining: window.remainingPercent, @@ -87,7 +103,7 @@ enum CLIRenderer { provider: provider, title: labels.primary, window: primary, - includePace: false, + paceKind: .session, context: context, now: now, lines: &lines) @@ -115,7 +131,7 @@ enum CLIRenderer { provider: provider, title: labels.secondary, window: weekly, - includePace: true, + paceKind: .weekly, context: context, now: now, lines: &lines) @@ -301,14 +317,20 @@ enum CLIRenderer { provider: UsageProvider, title: String, window: RateWindow, - includePace: Bool, + paceKind: PaceKind?, context: RenderContext, now: Date, lines: inout [String]) { lines.append(self.rateLine(title: title, window: window, useColor: context.useColor)) - if includePace, - let pace = self.paceLine(provider: provider, window: window, useColor: context.useColor, now: now) + if let paceKind, + let pace = self.paceLine( + provider: provider, + window: window, + kind: paceKind, + weeklyWorkDays: context.weeklyWorkDays, + useColor: context.useColor, + now: now) { lines.append(pace) } @@ -424,29 +446,116 @@ enum CLIRenderer { return self.ansi(self.accentColor, bar) } - private static func paceLine( + /// .session mirrors the GUI's session pace (5h window, real session windows only); .weekly reads + /// weeklyProgressWorkDays from the GUI's UserDefaults (same key) and passes it to UsagePace.weekly, + /// so the baseline matches the menu bar when the setting is configured. Codex historical refinement + /// is not applied (fixed allowlist only), so it can still differ from the menu for Codex accounts. + private enum PaceKind { + case session + case weekly + + var defaultWindowMinutes: Int { + switch self { + case .session: 300 + case .weekly: 10080 + } + } + + func supports(provider: UsageProvider) -> Bool { + switch self { + case .session: + provider == .codex || provider == .claude || provider == .ollama + case .weekly: + provider == .codex || provider == .claude || provider == .opencode || provider == .ollama + } + } + } + + private static func computePace( provider: UsageProvider, window: RateWindow, - useColor: Bool, - now: Date) -> String? + kind: PaceKind, + weeklyWorkDays: Int? = nil, + now: Date) -> UsagePace? { - guard provider == .codex || provider == .claude || provider == .opencode || provider == .ollama else { - return nil - } + guard kind.supports(provider: provider) else { return nil } + // Only pace a real session window here; Claude w/o 5-hour data falls a 7-day window into primary. + if case .session = kind, let minutes = window.windowMinutes, minutes > 300 { return nil } if provider == .ollama, window.windowMinutes == nil { return nil } guard window.remainingPercent > 0 else { return nil } - guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil } + // workDays applies only to the weekly (10 080-min) window; UsagePace.weekly ignores it for other durations. + let workDays = kind == .weekly ? weeklyWorkDays : nil + guard let pace = UsagePace.weekly( + window: window, + now: now, + defaultWindowMinutes: kind.defaultWindowMinutes, + workDays: workDays) else { return nil } guard pace.expectedUsedPercent >= Self.paceMinimumExpectedPercent else { return nil } + return pace + } + private static func paceSummary(for pace: UsagePace, kind: PaceKind, now: Date) -> String { let expected = Int(pace.expectedUsedPercent.rounded()) var parts: [String] = [] parts.append(Self.paceLeftLabel(for: pace)) parts.append("Expected \(expected)% used") - if let rightLabel = Self.paceRightLabel(for: pace, now: now) { + if let rightLabel = Self.paceRightLabel(for: pace, kind: kind, now: now) { parts.append(rightLabel) } + return parts.joined(separator: " | ") + } + + private static func paceLine( + provider: UsageProvider, + window: RateWindow, + kind: PaceKind, + weeklyWorkDays: Int? = nil, + useColor: Bool, + now: Date) -> String? + { + guard let pace = self.computePace( + provider: provider, + window: window, + kind: kind, + weeklyWorkDays: weeklyWorkDays, + now: now) else { return nil } let label = self.label("Pace", useColor: useColor) - return "\(label): \(parts.joined(separator: " | "))" + return "\(label): \(self.paceSummary(for: pace, kind: kind, now: now))" + } + + private static func pacePayload( + provider: UsageProvider, + window: RateWindow, + kind: PaceKind, + weeklyWorkDays: Int? = nil, + now: Date) -> PacePayload? + { + guard let pace = self.computePace( + provider: provider, + window: window, + kind: kind, + weeklyWorkDays: weeklyWorkDays, + now: now) else { return nil } + return PacePayload( + stage: Self.stageString(pace.stage), + deltaPercent: pace.deltaPercent.rounded(), + expectedUsedPercent: pace.expectedUsedPercent.rounded(), + willLastToReset: pace.willLastToReset, + etaSeconds: pace.etaSeconds.map { $0.rounded() }, + runOutProbability: pace.runOutProbability, + summary: self.paceSummary(for: pace, kind: kind, now: now)) + } + + private static func stageString(_ stage: UsagePace.Stage) -> String { + switch stage { + case .farAhead: "farAhead" + case .ahead: "ahead" + case .slightlyAhead: "slightlyAhead" + case .onTrack: "onTrack" + case .slightlyBehind: "slightlyBehind" + case .behind: "behind" + case .farBehind: "farBehind" + } } private static func paceLeftLabel(for pace: UsagePace) -> String { @@ -461,12 +570,16 @@ enum CLIRenderer { } } - private static func paceRightLabel(for pace: UsagePace, now: Date) -> String? { + private static func paceRightLabel(for pace: UsagePace, kind: PaceKind, now: Date) -> String? { if pace.willLastToReset { return "Lasts until reset" } guard let etaSeconds = pace.etaSeconds else { return nil } let etaText = Self.paceDurationText(seconds: etaSeconds, now: now) - if etaText == "now" { return "Runs out now" } - return "Runs out in \(etaText)" + switch kind { + case .session: + return etaText == "now" ? "Projected empty now" : "Projected empty in \(etaText)" + case .weekly: + return etaText == "now" ? "Runs out now" : "Runs out in \(etaText)" + } } private static func paceDurationText(seconds: TimeInterval, now: Date) -> String { @@ -518,6 +631,7 @@ struct RenderContext { let status: ProviderStatusPayload? let useColor: Bool let resetStyle: ResetTimeDisplayStyle + let weeklyWorkDays: Int? let notes: [String] init( @@ -525,12 +639,14 @@ struct RenderContext { status: ProviderStatusPayload?, useColor: Bool, resetStyle: ResetTimeDisplayStyle, + weeklyWorkDays: Int? = nil, notes: [String] = []) { self.header = header self.status = status self.useColor = useColor self.resetStyle = resetStyle + self.weeklyWorkDays = weeklyWorkDays self.notes = notes } } diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 651f7b2d39..c9ae89b097 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -722,6 +722,7 @@ extension CodexBarCLI { verbose: false, useColor: false, resetStyle: Self.resetTimeDisplayStyleFromDefaults(), + weeklyWorkDays: Self.weeklyProgressWorkDaysFromDefaults(), jsonOnly: true, includeAllCodexAccounts: true, fetcher: UsageFetcher(), diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 3b769f036c..e331e09ff8 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -13,6 +13,7 @@ struct UsageCommandContext { let verbose: Bool let useColor: Bool let resetStyle: ResetTimeDisplayStyle + let weeklyWorkDays: Int? let jsonOnly: Bool let includeAllCodexAccounts: Bool let fetcher: UsageFetcher @@ -66,6 +67,7 @@ extension CodexBarCLI { let noColor = values.flags.contains("noColor") let useColor = Self.shouldUseColor(noColor: noColor, format: format) let resetStyle = Self.resetTimeDisplayStyleFromDefaults() + let weeklyWorkDays = Self.weeklyProgressWorkDaysFromDefaults() let providerList = provider.asList let tokenSelection: TokenAccountCLISelection @@ -131,6 +133,7 @@ extension CodexBarCLI { verbose: verbose, useColor: useColor, resetStyle: resetStyle, + weeklyWorkDays: weeklyWorkDays, jsonOnly: output.jsonOnly, includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex], fetcher: fetcher, @@ -241,6 +244,35 @@ extension CodexBarCLI { return output } + // swiftlint:disable:next function_parameter_count + private static func makeUsagePayload( + provider: UsageProvider, + accountLabel: String?, + cacheAccountKey: String?, + version: String?, + source: String, + status: ProviderStatusPayload?, + usage: UsageSnapshot, + credits: CreditsSnapshot?, + antigravityPlanInfo: AntigravityPlanInfoSummary?, + dashboard: OpenAIDashboardSnapshot?, + weeklyWorkDays: Int?) -> ProviderPayload + { + ProviderPayload( + provider: provider, + account: accountLabel, + cacheAccountKey: cacheAccountKey, + version: version, + source: source, + status: status, + usage: usage, + credits: credits, + antigravityPlanInfo: antigravityPlanInfo, + openaiDashboard: dashboard, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: provider, snapshot: usage, weeklyWorkDays: weeklyWorkDays)) + } + private static func fetchUsageOutput( provider: UsageProvider, account: ProviderTokenAccount?, @@ -305,9 +337,7 @@ extension CodexBarCLI { providerManualTokenUpdater: tokenContext.manualTokenUpdater(), persistsCLISessions: Self.persistsCLISessions(provider: provider, command: command), persistentCLISessionIdleWindow: command.persistentCLISessionIdleWindow) - let outcome = await Self.fetchProviderUsage( - provider: provider, - context: fetchContext) + let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) if command.verbose, !command.jsonOnly { Self.printFetchAttempts(provider: provider, attempts: outcome.attempts) } @@ -359,15 +389,16 @@ extension CodexBarCLI { status: status, useColor: command.useColor, resetStyle: command.resetStyle, + weeklyWorkDays: command.weeklyWorkDays, notes: notes)) if let dashboard, provider == .codex, effectiveSourceMode.usesWeb { text += "\n" + Self.renderOpenAIWebDashboardText(dashboard) } output.sections.append(text) case .json: - output.payload.append(ProviderPayload( + output.payload.append(Self.makeUsagePayload( provider: provider, - account: account?.label ?? codexVisibleAccount?.menuDisplayName, + accountLabel: account?.label ?? codexVisibleAccount?.menuDisplayName, cacheAccountKey: cacheAccountKey, version: version, source: source, @@ -375,8 +406,8 @@ extension CodexBarCLI { usage: usage, credits: result.credits, antigravityPlanInfo: antigravityPlanInfo, - openaiDashboard: dashboard, - error: nil)) + dashboard: dashboard, + weeklyWorkDays: command.weeklyWorkDays)) } case let .failure(error): output.exitCode = Self.mapError(error) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e019ff608e..c4d59ca60e 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -116,7 +116,11 @@ public struct CostUsageFetcher: Sendable { environment: environment, since: since, until: until) - return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) + return Self.tokenSnapshot( + from: daily, + now: now, + historyDays: clampedHistoryDays, + useCurrentLocalDayForSession: false) } var options = overrideScannerOptions ?? CostUsageScanner.Options() @@ -276,23 +280,27 @@ public struct CostUsageFetcher: Sendable { static func tokenSnapshot( from daily: CostUsageDailyReport, now: Date, - historyDays: Int = 30) -> CostUsageTokenSnapshot + historyDays: Int = 30, + useCurrentLocalDayForSession: Bool = true) -> CostUsageTokenSnapshot { - // Pick the most recent day; break ties by cost/tokens to keep a stable "session" row. - let currentDay = daily.data.compactMap { entry -> (entry: CostUsageDailyReport.Entry, date: Date)? in - guard let date = CostUsageDateParser.parse(entry.date) else { return nil } - return (entry, date) + let sessionEntry = useCurrentLocalDayForSession + ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now) + : CostUsageTokenSnapshot.latestEntry(in: daily.data) + let hasHistoricalRows = !daily.data.isEmpty + let sessionTokens: Int? = if let sessionEntry { + sessionEntry.totalTokens + } else if hasHistoricalRows { + 0 + } else { + nil + } + let sessionCostUSD: Double? = if let sessionEntry { + sessionEntry.costUSD + } else if hasHistoricalRows { + 0 + } else { + nil } - .max { lhs, rhs in - if lhs.date != rhs.date { return lhs.date < rhs.date } - let lCost = lhs.entry.costUSD ?? -1 - let rCost = rhs.entry.costUSD ?? -1 - if lCost != rCost { return lCost < rCost } - let lTokens = lhs.entry.totalTokens ?? -1 - let rTokens = rhs.entry.totalTokens ?? -1 - if lTokens != rTokens { return lTokens < rTokens } - return lhs.entry.date < rhs.entry.date - }?.entry // Prefer summary totals when present; fall back to summing daily entries. let totalFromSummary = daily.summary?.totalCostUSD let totalFromEntries = daily.data.compactMap(\.costUSD).reduce(0, +) @@ -302,8 +310,8 @@ public struct CostUsageFetcher: Sendable { let last30DaysTokens = totalTokensFromSummary ?? (totalTokensFromEntries > 0 ? totalTokensFromEntries : nil) return CostUsageTokenSnapshot( - sessionTokens: currentDay?.totalTokens, - sessionCostUSD: currentDay?.costUSD, + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, last30DaysTokens: last30DaysTokens, last30DaysCostUSD: last30DaysCostUSD, historyDays: historyDays, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 30dcbb7012..d2a6a4d2c7 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -40,6 +40,41 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.daily = daily self.updatedAt = updatedAt } + + public func currentDayEntry(calendar: Calendar = .current) -> CostUsageDailyReport.Entry? { + Self.entry(in: self.daily, forLocalDayContaining: self.updatedAt, calendar: calendar) + } + + public static func latestEntry(in entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Entry? { + entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, date: Date)? in + guard let date = CostUsageDateParser.parse(entry.date) else { return nil } + return (entry, date) + } + .max { lhs, rhs in + if lhs.date != rhs.date { return lhs.date < rhs.date } + let lCost = lhs.entry.costUSD ?? -1 + let rCost = rhs.entry.costUSD ?? -1 + if lCost != rCost { return lCost < rCost } + let lTokens = lhs.entry.totalTokens ?? -1 + let rTokens = rhs.entry.totalTokens ?? -1 + if lTokens != rTokens { return lTokens < rTokens } + return lhs.entry.date < rhs.entry.date + }?.entry + } + + public static func entry( + in entries: [CostUsageDailyReport.Entry], + forLocalDayContaining date: Date, + calendar: Calendar = .current) -> CostUsageDailyReport.Entry? + { + let dayKey = CostUsageLocalDay.key(from: date, calendar: calendar) + return entries.first { entry in + let rawDate = entry.date.trimmingCharacters(in: .whitespacesAndNewlines) + if rawDate == dayKey { return true } + guard let parsed = CostUsageDateParser.parse(rawDate) else { return false } + return CostUsageLocalDay.key(from: parsed, calendar: calendar) == dayKey + } + } } public struct CostUsageDailyReport: Sendable, Decodable { @@ -806,7 +841,29 @@ enum CostUsageDateParser { formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = timeZone formatter.dateFormat = format + formatter.isLenient = false threadDict[cacheKey] = formatter return formatter } } + +enum CostUsageBucketInterval { + static func contains( + _ date: Date, + startTime: Date, + endTime: Date) -> Bool + { + guard startTime < endTime else { return false } + return startTime <= date && date < endTime + } +} + +enum CostUsageLocalDay { + static func key(from date: Date, calendar: Calendar = .current) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + let year = components.year ?? 0 + let month = components.month ?? 0 + let day = components.day ?? 0 + return String(format: "%04d-%02d-%02d", year, month, day) + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 1a4168b90f..bf808ca723 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -299,11 +299,25 @@ public struct AntigravityStatusSnapshot: Sendable { private static func displayTitle(forQuotaGroup group: AntigravityQuotaSummaryGroup) -> String { let title = group.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedTitle = title.lowercased() + if lowercasedTitle.contains("gemini") { + return "Gemini" + } + if lowercasedTitle.contains("claude") || lowercasedTitle.contains("gpt") { + return "Claude/GPT" + } return title.isEmpty ? "Quota" : title } private static func displayTitle(forQuotaBucket bucket: AntigravityQuotaSummaryBucket) -> String { - bucket.displayName + switch self.quotaBucketKind(for: bucket) { + case .session: + "5-hour" + case .weekly: + "weekly" + case .other: + bucket.displayName + } } private static func windowMinutes(forQuotaBucket bucket: AntigravityQuotaSummaryBucket) -> Int? { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift index f9b82ab0c8..96d30a20d8 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift @@ -129,10 +129,30 @@ public struct ClaudeAdminAPIUsageSnapshot: Codable, Equatable, Sendable { self.summary(days: 7) } + public var currentDay: Summary { + self.summary(forLocalDayContaining: self.updatedAt) + } + public var latestDay: Summary { self.summary(days: 1) } + public func summary(forLocalDayContaining date: Date, calendar _: Calendar = .current) -> Summary { + let selected = self.daily.filter { bucket in + CostUsageBucketInterval.contains( + date, + startTime: bucket.startTime, + endTime: bucket.endTime) + } + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cacheCreationInputTokens: selected.reduce(0) { $0 + $1.cacheCreationInputTokens }, + cacheReadInputTokens: selected.reduce(0) { $0 + $1.cacheReadInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + public func summary(days: Int) -> Summary { let selected = self.daily.suffix(max(1, days)) return Summary( diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 9f741ea994..5b4030eca8 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -126,7 +126,7 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { } public var last30Days: Summary { - self.summary(days: self.historyDays) + self.historyDays == 1 ? self.currentDay : self.summary(days: self.historyDays) } public var historyWindowLabel: String { @@ -141,10 +141,30 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { self.summary(days: 7) } + public var currentDay: Summary { + self.summary(forLocalDayContaining: self.updatedAt) + } + public var latestDay: Summary { self.summary(days: 1) } + public func summary(forLocalDayContaining date: Date, calendar _: Calendar = .current) -> Summary { + let selected = self.daily.filter { bucket in + CostUsageBucketInterval.contains( + date, + startTime: bucket.startTime, + endTime: bucket.endTime) + } + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + requests: selected.reduce(0) { $0 + $1.requests }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cachedInputTokens: selected.reduce(0) { $0 + $1.cachedInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + public func summary(days: Int) -> Summary { let selected = self.daily.suffix(max(1, days)) return Summary( @@ -238,12 +258,12 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) } - let latest = self.latestDay + let today = self.currentDay let total = self.last30Days return CostUsageTokenSnapshot( - sessionTokens: latest.totalTokens, - sessionCostUSD: latest.costUSD, - sessionRequests: latest.requests, + sessionTokens: today.totalTokens, + sessionCostUSD: today.costUSD, + sessionRequests: today.requests, last30DaysTokens: total.totalTokens, last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, diff --git a/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift b/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift index 63cceb5e58..8c5c6d30da 100644 --- a/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift +++ b/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift @@ -71,6 +71,14 @@ public struct PoeUsageHistorySnapshot: Codable, Equatable, Sendable { self.summary(days: 1) } + public func currentDay(now: Date = Date(), calendar: Calendar = .current) -> Summary { + let selected = self.entries.filter { calendar.isDate($0.createdAt, inSameDayAs: now) } + let points = selected.reduce(0) { $0 + max(0, $1.points) } + let costValues = selected.compactMap(\.costUSD).map { max(0, $0) } + let cost: Double? = costValues.isEmpty ? nil : costValues.reduce(0, +) + return Summary(points: points, requests: selected.count, costUSD: cost) + } + public var last7Days: Summary { self.summary(days: 7) } diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift index 071b0606c6..8364324c97 100644 --- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift +++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift @@ -16,7 +16,13 @@ public enum ProviderVersionDetector { send: "", options: TTYCommandRunner.Options( timeout: 5.0, - extraArgs: ["--allowed-tools", "", "--version"], + // `--version` alone makes Claude Code print and exit before it + // initializes its credential subsystem. Passing `--allowed-tools` + // (even empty) makes it treat the invocation as a real session and + // read the OAuth token from the macOS keychain ("Claude Code-credentials"), + // which spawns `/usr/bin/security` and triggers a keychain prompt on + // every probe when no ~/.claude/.credentials.json / env token exists. + extraArgs: ["--version"], initialDelay: 0.0, useClaudeProbeWorkingDirectory: true)).text let trimmed = TextParsing.stripANSICodes(out).trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift b/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift new file mode 100644 index 0000000000..fb4a530a23 --- /dev/null +++ b/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AdminAPIUsageLocalDaySelectionTests { + @Test + func `OpenAI current day includes UTC bucket containing positive timezone morning`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney") + let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC") + let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-16", + startTime: staleUTCStart, + endTime: staleUTCStart.addingTimeInterval(86400), + costUSD: 9, + requests: 9, + inputTokens: 900, + cachedInputTokens: 90, + outputTokens: 90, + totalTokens: 990, + lineItems: [], + models: []), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: overlappingUTCStart, + endTime: overlappingUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + requests: 3, + inputTokens: 200, + cachedInputTokens: 20, + outputTokens: 30, + totalTokens: 250, + lineItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 2.5) + #expect(today.requests == 3) + #expect(today.totalTokens == 250) + } + + @Test + func `OpenAI current day does not sum adjacent UTC buckets after positive timezone UTC rollover`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 16, timeZoneIdentifier: "Australia/Sydney") + let previousUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let currentUTCStart = try Self.date(year: 2026, month: 5, day: 18, hour: 0, timeZoneIdentifier: "UTC") + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: previousUTCStart, + endTime: previousUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + requests: 3, + inputTokens: 200, + cachedInputTokens: 20, + outputTokens: 30, + totalTokens: 250, + lineItems: [], + models: []), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-18", + startTime: currentUTCStart, + endTime: currentUTCStart.addingTimeInterval(86400), + costUSD: 4.5, + requests: 5, + inputTokens: 400, + cachedInputTokens: 40, + outputTokens: 50, + totalTokens: 490, + lineItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 4.5) + #expect(today.requests == 5) + #expect(today.totalTokens == 490) + } + + @Test + func `Claude Admin current day includes UTC bucket containing positive timezone morning`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney") + let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC") + let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-05-16", + startTime: staleUTCStart, + endTime: staleUTCStart.addingTimeInterval(86400), + costUSD: 9, + inputTokens: 900, + cacheCreationInputTokens: 90, + cacheReadInputTokens: 45, + outputTokens: 90, + totalTokens: 1125, + costItems: [], + models: []), + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: overlappingUTCStart, + endTime: overlappingUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + inputTokens: 200, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + outputTokens: 30, + totalTokens: 260, + costItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 2.5) + #expect(today.inputTokens == 200) + #expect(today.totalTokens == 260) + } + + @Test + func `Claude Admin current day does not sum adjacent UTC buckets after negative timezone UTC rollover`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "America/Los_Angeles") + let now = try Self.date(year: 2026, month: 6, day: 22, hour: 20, timeZoneIdentifier: "America/Los_Angeles") + let previousUTCStart = try Self.date(year: 2026, month: 6, day: 22, hour: 0, timeZoneIdentifier: "UTC") + let currentUTCStart = try Self.date(year: 2026, month: 6, day: 23, hour: 0, timeZoneIdentifier: "UTC") + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-06-22", + startTime: previousUTCStart, + endTime: previousUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + inputTokens: 200, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + outputTokens: 30, + totalTokens: 260, + costItems: [], + models: []), + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-06-23", + startTime: currentUTCStart, + endTime: currentUTCStart.addingTimeInterval(86400), + costUSD: 4.5, + inputTokens: 400, + cacheCreationInputTokens: 40, + cacheReadInputTokens: 20, + outputTokens: 50, + totalTokens: 510, + costItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 4.5) + #expect(today.inputTokens == 400) + #expect(today.totalTokens == 510) + } + + private static func calendar(timeZoneIdentifier: String) throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier)) + return calendar + } + + private static func date( + year: Int, + month: Int, + day: Int, + hour: Int, + timeZoneIdentifier: String) throws -> Date + { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(identifier: timeZoneIdentifier) + components.year = year + components.month = month + components.day = day + components.hour = hour + return try #require(components.date) + } +} diff --git a/Tests/CodexBarTests/AntigravityQuotaSummaryTests.swift b/Tests/CodexBarTests/AntigravityQuotaSummaryTests.swift index 48824ad58b..75ba999e34 100644 --- a/Tests/CodexBarTests/AntigravityQuotaSummaryTests.swift +++ b/Tests/CodexBarTests/AntigravityQuotaSummaryTests.swift @@ -37,10 +37,10 @@ struct AntigravityQuotaSummaryTests { "antigravity-quota-summary-3p-weekly", ]) #expect(windows.map(\.title) == [ - "Gemini Models Five Hour Limit", - "Gemini Models Weekly Limit", - "Claude and GPT models Five Hour Limit", - "Claude and GPT models Weekly Limit", + "Gemini 5-hour", + "Gemini weekly", + "Claude/GPT 5-hour", + "Claude/GPT weekly", ]) #expect(windows.map(\.window.windowMinutes) == [300, 10080, 300, 10080]) #expect(windows.map { $0.window.remainingPercent.rounded() } == [91, 82, 73, 64]) diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index bb4483fb23..f0e4b2a1b9 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBarCLI +// swiftlint:disable:next type_body_length struct CLISnapshotTests { @Test func `renders Factory token rate billing with time window labels`() { @@ -417,6 +418,49 @@ struct CLISnapshotTests { #expect(output.contains("Pace:")) } + @Test + func `configured work days affect weekly text and JSON pace`() throws { + var calendar = Calendar.current + calendar.timeZone = .current + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = resetsAt.addingTimeInterval(-72 * 60 * 60) + let snap = UsageSnapshot( + primary: nil, + secondary: .init( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: 5), + now: now) + #expect(output.contains("Pace: On pace | Expected 60% used | Lasts until reset")) + + let pace = try #require(CLIRenderer.providerPacePayload( + provider: .codex, + snapshot: snap, + weeklyWorkDays: 5, + now: now)?.secondary) + #expect(pace.expectedUsedPercent == 60) + #expect(pace.summary == "On pace | Expected 60% used | Lasts until reset") + } + @Test func `renders Ollama weekly pace line when weekly window has reset`() { let now = Date() @@ -475,6 +519,178 @@ struct CLISnapshotTests { #expect(!output.contains("Pace:")) } + @Test + func `renders session pace line when session window has reset`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(output.contains("Session: 80% left")) + // 2h remaining of a 5h window => 3h elapsed => 60% expected; even rate easily lasts to reset. + #expect(output.contains("Pace: 40% in reserve | Expected 60% used | Lasts until reset")) + } + + @Test + func `renders Claude session pace using five hour default window`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .claude, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Claude Code 2.0.69 (claude)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + // windowMinutes is nil, so the 5-hour (300 minute) session default must drive the pace. + #expect(output.contains("Pace: 40% in reserve | Expected 60% used")) + } + + @Test + func `renders session pace deficit with run out estimate`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + // 1h elapsed of a 5h window => 20% expected vs 50% used => burning ahead of pace. + // Session mirrors the GUI's "Projected empty" wording (weekly uses "Runs out"). + #expect(output.contains("Pace: 30% in deficit | Expected 20% used | Projected empty in")) + #expect(!output.contains("Runs out")) + } + + @Test + func `renders session pace on track and lasts until reset`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // Exactly halfway through a 5h window with 50% used => On pace (delta 0); the even rate + // means the quota lasts precisely to the reset. + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2.5 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(output.contains("Pace: On pace | Expected 50% used | Lasts until reset")) + } + + @Test + func `hides session pace for unsupported provider`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .zai, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "z.ai 0.0.0 (zai)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(!output.contains("Pace:")) + } + + @Test + func `hides session pace for non-session primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // Claude with no 5-hour data falls a 7-day window back into `primary`; it must not be + // paced as a "Session" (that would print "Projected empty …" over a weekly window). + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .claude, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Claude Code 2.0.69 (claude)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(!output.contains("Pace:")) + #expect(CLIRenderer.providerPacePayload(provider: .claude, snapshot: snap, now: now) == nil) + } + @Test func `renders JSON payload`() throws { let snap = UsageSnapshot( @@ -515,6 +731,165 @@ struct CLISnapshotTests { #expect(json.contains("1700000000")) } + @Test + func `json pace rounds derived numbers to match usage precision`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // 13000s elapsed of an 18000s (300m) window => 72.22% expected; used 79 => +6.78 deficit; + // projected empty in ~3455.7s. Derived fields must be emitted as whole numbers (no float noise). + let snap = UsageSnapshot( + primary: .init( + usedPercent: 79, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "codex-cli", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .codex, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let primary = try #require((root["pace"] as? [String: Any])?["primary"] as? [String: Any]) + + #expect(primary["expectedUsedPercent"] as? Double == 72) + #expect(primary["deltaPercent"] as? Double == 7) + #expect(primary["etaSeconds"] as? Double == 3456) + // actualUsedPercent is not emitted; consumers read usage.primary.usedPercent. + #expect(primary["actualUsedPercent"] == nil) + } + + @Test + func `json payload includes session and weekly pace with distinct wording`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snap = UsageSnapshot( + // 1h elapsed of a 5h window => 20% expected vs 50% used => deficit, runs out in 1h. + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + // 5d elapsed of a 7d window => ~71% expected vs 90% used => deficit, runs out before reset. + secondary: .init( + usedPercent: 90, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: "1.2.3", + source: "codex-cli", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .codex, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let pace = try #require(root["pace"] as? [String: Any]) + + let primary = try #require(pace["primary"] as? [String: Any]) + #expect(primary["stage"] as? String == "farAhead") + #expect(primary["expectedUsedPercent"] as? Double == 20) + #expect(primary["deltaPercent"] as? Double == 30) + #expect(primary["willLastToReset"] as? Bool == false) + #expect(primary["etaSeconds"] as? Double == 3600) + #expect((primary["summary"] as? String)? + .contains("30% in deficit | Expected 20% used | Projected empty in") == true) + // actualUsedPercent is redundant with usage.usedPercent and is not emitted; + // runOutProbability is never set by the CLI, so both keys are omitted. + #expect(primary["actualUsedPercent"] == nil) + #expect(primary["runOutProbability"] == nil) + + let secondary = try #require(pace["secondary"] as? [String: Any]) + #expect(secondary["stage"] as? String == "farAhead") + #expect((secondary["summary"] as? String)?.contains("Runs out in") == true) + #expect((secondary["summary"] as? String)?.contains("Projected empty") == false) + } + + @Test + func `json omits pace when not applicable`() throws { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + // z.ai is not a session/weekly pace provider, so no pace should be emitted. + let payload = ProviderPayload( + provider: .zai, + account: nil, + version: nil, + source: "zai", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .zai, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("\"pace\"")) + } + + @Test + func `json includes only session pace when weekly window missing`() throws { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "codex-cli", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .codex, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let pace = try #require(root["pace"] as? [String: Any]) + #expect(pace["primary"] is [String: Any]) + #expect(pace["secondary"] == nil) + } + @Test func `encodes JSON with secondary null when missing`() throws { let snap = UsageSnapshot( diff --git a/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift b/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift new file mode 100644 index 0000000000..0c86ea287a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ClaudeAdminAPIInlineDashboardModelTests { + @Test + func `claude admin api usage gets inline dashboard`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), + costUSD: 1.25, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [ + ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), + ], + models: [ + ClaudeAdminAPIUsageSnapshot.ModelBreakdown( + name: "claude-sonnet-4-20250514", + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") + #expect(model.inlineUsageDashboard?.detailLines + .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) + #expect(model.planText == "Admin API") + } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } +} diff --git a/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift index 6a63827e99..51ccf8b7b1 100644 --- a/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift @@ -181,6 +181,33 @@ struct ClaudeAdminAPIUsageTests { #expect(usage.identity?.loginMethod == "Admin API") } + @Test + func `current day summary is zero when Claude admin history is stale`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) + let apiUsage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), + costUSD: 8.5, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [], + models: []), + ], + updatedAt: now) + + #expect(apiUsage.currentDay.costUSD == 0) + #expect(apiUsage.currentDay.totalTokens == 0) + #expect(apiUsage.latestDay.costUSD == 8.5) + #expect(apiUsage.latestDay.totalTokens == 1950) + } + @Test func `fetch strategy reports admin api source label`() async throws { let strategy = ClaudeAdminAPIFetchStrategy(usageFetcher: { apiKey in @@ -193,4 +220,8 @@ struct ClaudeAdminAPIUsageTests { #expect(result.sourceLabel == "admin-api") #expect(result.usage.identity?.loginMethod == "Admin API") } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 169eead114..0d7f76db1d 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -101,9 +101,9 @@ struct CodexBarTests { let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) #expect(windows.primary?.windowMinutes == 300) - #expect(windows.primary?.remainingPercent == 3) + #expect(windows.primary?.remainingPercent == 2) #expect(windows.secondary?.windowMinutes == 10080) - #expect(windows.secondary?.remainingPercent == 16) + #expect(windows.secondary?.remainingPercent == 1) } @Test @@ -139,7 +139,7 @@ struct CodexBarTests { } @Test - func `antigravity quota summary icon prefers gemini ids over display titles`() { + func `antigravity quota summary icon uses most constrained quota summary lanes`() { let snapshot = UsageSnapshot( primary: nil, secondary: nil, @@ -166,12 +166,12 @@ struct CodexBarTests { let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) - #expect(windows.primary?.remainingPercent == 60) - #expect(windows.secondary?.remainingPercent == 70) + #expect(windows.primary?.remainingPercent == 2) + #expect(windows.secondary?.remainingPercent == 1) } @Test - func `antigravity quota summary icon does not borrow missing gemini weekly from claude gpt`() { + func `antigravity quota summary icon can pair gemini session with claude gpt weekly`() { let snapshot = UsageSnapshot( primary: nil, secondary: nil, @@ -191,11 +191,11 @@ struct CodexBarTests { let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) #expect(windows.primary?.remainingPercent == 60) - #expect(windows.secondary == nil) + #expect(windows.secondary?.remainingPercent == 1) } @Test - func `antigravity quota summary icon treats unknown gemini rows as present`() { + func `antigravity quota summary icon ignores unknown rows while ranking known lanes`() { let snapshot = UsageSnapshot( primary: nil, secondary: nil, @@ -216,7 +216,42 @@ struct CodexBarTests { let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) #expect(windows.primary == nil) - #expect(windows.secondary == nil) + #expect(windows.secondary?.remainingPercent == 1) + } + + @Test + func `antigravity used icon percent matches constrained claude gpt lane`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow(usedPercent: 95, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow(usedPercent: 40, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let percents = IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: .antigravity, + showUsed: true) + + #expect(percents.primary == 95) + #expect(percents.secondary == 40) } @Test diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 33eb9df2c1..acf4f65ea0 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -381,7 +381,7 @@ struct CostUsageDecodingTests { } @Test - func `token snapshot selects most recent day`() throws { + func `token snapshot selects current local day`() throws { let json = """ { "type": "daily", @@ -404,7 +404,7 @@ struct CostUsageDecodingTests { """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - let now = Date(timeIntervalSince1970: 1_766_275_200) // 2025-12-21 + let now = try Self.localNoon(year: 2025, month: 12, day: 21) let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) #expect(snapshot.sessionTokens == 10) #expect(snapshot.sessionCostUSD == 4.56) @@ -434,7 +434,10 @@ struct CostUsageDecodingTests { """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: Date()) + let snapshot = CostUsageFetcher.tokenSnapshot( + from: report, + now: Date(), + useCurrentLocalDayForSession: false) #expect(snapshot.sessionTokens == 30) #expect(snapshot.sessionCostUSD == 23.45) @@ -493,4 +496,8 @@ struct CostUsageDecodingTests { let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: Date()) #expect(snapshot.last30DaysCostUSD == nil) } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift new file mode 100644 index 0000000000..d587151c1e --- /dev/null +++ b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageTokenSnapshotDaySelectionTests { + @Test + func `token snapshot reports zero today when latest history row is stale`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-15", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) + + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.last30DaysCostUSD == 1.5) + #expect(snapshot.last30DaysTokens == 300) + #expect(snapshot.currentDayEntry() == nil) + } + + @Test + func `token snapshot uses current local day instead of newest historical row`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-17", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-05-18", + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + costUSD: 0.15, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) + + #expect(snapshot.sessionCostUSD == 0.15) + #expect(snapshot.sessionTokens == 30) + #expect(snapshot.last30DaysCostUSD == 1.65) + #expect(snapshot.last30DaysTokens == 330) + } + + @Test + func `token snapshot can preserve latest bucket semantics`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-15", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot( + from: report, + now: now, + useCurrentLocalDayForSession: false) + + #expect(snapshot.sessionCostUSD == 1.5) + #expect(snapshot.sessionTokens == 300) + } + + @Test + func `latest entry ignores invalid calendar dates`() { + let latest = CostUsageTokenSnapshot.latestEntry(in: [ + CostUsageDailyReport.Entry( + date: "2026-06-31", + inputTokens: nil, + outputTokens: nil, + totalTokens: 999, + costUSD: 9.99, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-06-30", + inputTokens: nil, + outputTokens: nil, + totalTokens: 100, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil), + ]) + + #expect(latest?.date == "2026-06-30") + } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + var components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + components.hour = 12 + return try #require(components.date) + } +} diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index 2584e5dfed..ab0385c909 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -4,6 +4,55 @@ import Testing @testable import CodexBar struct InlineCostHistoryDashboardLabelTests { + @Test + func `local cost history Today KPI uses current day session value`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 0, + sessionCostUSD: 0, + last30DaysTokens: 275, + last30DaysCostUSD: 0.25, + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.first?.title == "Today") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: $0.25") + } + @Test func `local cost history KPI titles preserve one day and dynamic windows`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift index 1f028030ea..1d908e6942 100644 --- a/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift +++ b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift @@ -57,6 +57,40 @@ struct MemoryPressureCacheTrimTests { #expect(snapshot.handledOnMainThread) } + @Test + func `memory pressure source event handler can read source data from utility queue`() async { + let source = DispatchSource.makeMemoryPressureSource( + eventMask: [.warning, .critical], + queue: .global(qos: .utility)) + source.setEventHandler {} + source.resume() + defer { source.cancel() } + + let probe = MemoryPressureEventHandlerProbe() + let handler = MemoryPressureMonitor.makeEventHandler( + source: source, + handle: { isWarning, isCritical in + probe.record( + isWarning: isWarning, + isCritical: isCritical, + handledOnMainThread: Thread.isMainThread) + }) + + DispatchQueue.global(qos: .utility).async { + probe.recordInvocationThread(isMainThread: Thread.isMainThread) + handler() + } + + let completed = await Task.detached { + probe.wait(timeout: .now() + 2) + }.value + #expect(completed) + + let snapshot = probe.snapshot() + #expect(snapshot.invokedOnMainThread == false) + #expect(snapshot.handledOnMainThread) + } + @Test func `status controller trims rebuildable menu caches on memory pressure`() { let controller = self.makeController() diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 18dfc1443a..752c803b72 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -60,66 +60,6 @@ struct OverviewMenuCardVisibilityTests { } struct ProviderInlineDashboardModelTests { - @Test - func `claude admin api usage gets inline dashboard`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let usage = ClaudeAdminAPIUsageSnapshot( - daily: [ - ClaudeAdminAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 1.25, - inputTokens: 1000, - cacheCreationInputTokens: 400, - cacheReadInputTokens: 300, - outputTokens: 250, - totalTokens: 1950, - costItems: [ - ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), - ], - models: [ - ClaudeAdminAPIUsageSnapshot.ModelBreakdown( - name: "claude-sonnet-4-20250514", - inputTokens: 1000, - cacheCreationInputTokens: 400, - cacheReadInputTokens: 300, - outputTokens: 250, - totalTokens: 1950), - ]), - ], - updatedAt: now) - - let model = UsageMenuCardView.Model.make(.init( - provider: .claude, - metadata: metadata, - snapshot: usage.toUsageSnapshot(), - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.isEmpty) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$1.25") - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") - #expect(model.inlineUsageDashboard?.detailLines - .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) - #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) - #expect(model.planText == "Admin API") - } - @Test func `openrouter period usage gets inline dashboard`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift index d45b96429e..e7954bf1f1 100644 --- a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift +++ b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift @@ -22,13 +22,15 @@ struct MenuDescriptorOpenAIAPITests { fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let firstDay = try Self.localNoon(year: 2023, month: 11, day: 13) + let secondDay = try Self.localNoon(year: 2023, month: 11, day: 14) let usage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-13", - startTime: now.addingTimeInterval(-86400), - endTime: now, + startTime: firstDay, + endTime: firstDay.addingTimeInterval(86400), costUSD: 5, requests: 8, inputTokens: 100, @@ -47,8 +49,8 @@ struct MenuDescriptorOpenAIAPITests { ]), OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: secondDay, + endTime: secondDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -83,10 +85,14 @@ struct MenuDescriptorOpenAIAPITests { return text } - #expect(lines.contains("Today: $12.50 · 1.5K tokens")) + #expect(lines.contains("Today: $0.00 · 0 tokens")) #expect(lines.contains("7d: $17.50 · 48 requests")) #expect(lines.contains("30d: $17.50 · 48 requests")) #expect(lines.contains("Top model: gpt-5.2-codex")) #expect(!lines.contains("No usage yet")) } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/MenuDescriptorPoeTests.swift b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift index 1f5dc5b212..1771944115 100644 --- a/Tests/CodexBarTests/MenuDescriptorPoeTests.swift +++ b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift @@ -72,7 +72,7 @@ struct MenuDescriptorPoeTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let now = Date(timeIntervalSince1970: 1_717_171_717) + let now = Date() let history = PoeUsageHistorySnapshot( entries: [ .init( diff --git a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift index b13eabe477..cb97d2e468 100644 --- a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift @@ -6,14 +6,15 @@ import Testing struct OpenAIAPIMenuCardModelTests { @Test func `admin usage model shows summaries and spend without fake quota bars`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) let metadata = try #require(ProviderDefaults.metadata[.openai]) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -57,13 +58,13 @@ struct OpenAIAPIMenuCardModelTests { #expect(model.metrics.isEmpty) #expect(model.openAIAPIUsage != nil) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") #expect(model.inlineUsageDashboard?.kpis.last?.title == "Requests") #expect(model.inlineUsageDashboard?.kpis.last?.value == "40") #expect(model.inlineUsageDashboard?.points.count == 1) #expect(model.inlineUsageDashboard?.detailLines.contains("30d requests: 40 requests") == true) #expect(model.providerCost == nil) - #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) + #expect(model.usageNotes.contains { $0.contains("Today: $0.00") }) #expect(model.usageNotes.contains("Top model: gpt-5.2")) #expect(model.creditsText == nil) #expect(model.planText == "Admin API") @@ -119,14 +120,15 @@ struct OpenAIAPIMenuCardModelTests { @Test func `admin usage model can show cost card summary`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) let metadata = try #require(ProviderDefaults.metadata[.openai]) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -159,8 +161,12 @@ struct OpenAIAPIMenuCardModelTests { now: now)) #expect(ProviderDescriptorRegistry.descriptor(for: .openai).tokenCost.supportsTokenCost) - #expect(model.tokenUsage?.sessionLine == "Today: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.sessionLine == "Today: $0.00 · 0 tokens") #expect(model.tokenUsage?.monthLine == "Last 30 days: $12.50 · 1.5K tokens") #expect(model.tokenUsage?.hintLine == "Reported by OpenAI Admin API organization usage.") } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift index a9cd8f50ee..fbf298bdf4 100644 --- a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -385,14 +385,16 @@ struct OpenAIAPIUsageFetcherTests { } @Test - func `maps project scoped admin usage to cost token snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) + func `maps project scoped admin usage to cost token snapshot`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let firstDay = try Self.localNoon(year: 2023, month: 11, day: 13) + let secondDay = try Self.localNoon(year: 2023, month: 11, day: 14) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-13", - startTime: now.addingTimeInterval(-86400), - endTime: now, + startTime: firstDay, + endTime: firstDay.addingTimeInterval(86400), costUSD: 2.25, requests: 3, inputTokens: 300, @@ -411,8 +413,8 @@ struct OpenAIAPIUsageFetcherTests { ]), OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: secondDay, + endTime: secondDay.addingTimeInterval(86400), costUSD: 8.5, requests: 42, inputTokens: 1000, @@ -442,9 +444,11 @@ struct OpenAIAPIUsageFetcherTests { #expect(usage.identity?.accountOrganization == "Project: proj_abc") #expect(snapshot.historyDays == 7) #expect(snapshot.currencyCode == "USD") - #expect(snapshot.sessionCostUSD == 8.5) - #expect(snapshot.sessionTokens == 1250) - #expect(snapshot.sessionRequests == 42) + #expect(apiUsage.currentDay.costUSD == 0) + #expect(apiUsage.currentDay.totalTokens == 0) + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.sessionRequests == 0) #expect(snapshot.last30DaysCostUSD == 10.75) #expect(snapshot.last30DaysTokens == 1750) #expect(snapshot.last30DaysRequests == 45) @@ -461,6 +465,10 @@ struct OpenAIAPIUsageFetcherTests { else { return nil } return components.queryItems?.first(where: { $0.name == name })?.value } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } private actor OpenAIAdminUsagePaginationScript: ProviderHTTPTransport { diff --git a/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift b/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift new file mode 100644 index 0000000000..33ae1c108a --- /dev/null +++ b/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct PoeCurrentDayPresentationTests { + @Test + func `Poe notes and dashboard do not label stale usage as Today`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/London")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T12:00:00Z")) + let yesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T12:00:00Z")) + let usage = PoeUsageHistorySnapshot( + entries: [ + .init( + id: "stale", + createdAt: yesterday, + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: 0.10), + ], + daily: [ + .init(day: "2026-06-22", points: 100, requests: 1, costUSD: 0.10), + ], + updatedAt: now) + + let notes = UsageMenuCardView.Model.poeUsageNotes(usage, now: now, calendar: calendar) + let dashboard = UsageMenuCardView.Model.poeInlineDashboard(usage, now: now, calendar: calendar) + + #expect(notes.first == "Today: 0 points · 0 requests") + #expect(dashboard.kpis.first?.title == "Today") + #expect(dashboard.kpis.first?.value == "0 points") + #expect(!dashboard.detailLines.contains(where: { $0.hasPrefix("Today USD:") })) + } +} diff --git a/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift index c24fc82f5b..b7a0f70d2d 100644 --- a/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift +++ b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift @@ -148,6 +148,76 @@ struct PoeUsageHistorySnapshotTests { #expect(snapshot.last30Days == snapshot.summary(days: 30)) } + @Test + func `current day does not reuse a stale latest bucket`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/London")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T12:00:00Z")) + let yesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T12:00:00Z")) + let snapshot = PoeUsageHistorySnapshot( + entries: [ + self.makeEntry( + id: "stale", + createdAt: yesterday, + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: 0.10), + ], + daily: [ + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-06-22", + points: 100, + requests: 1, + costUSD: 0.10), + ], + updatedAt: now) + + #expect(snapshot.latestDay.points == 100) + #expect(snapshot.currentDay(now: now, calendar: calendar).points == 0) + #expect(snapshot.currentDay(now: now, calendar: calendar).requests == 0) + #expect(snapshot.currentDay(now: now, calendar: calendar).costUSD == nil) + } + + @Test + func `current day filters raw entries across a UTC bucket boundary`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T01:00:00Z")) + let localToday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T20:00:00Z")) + let localYesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T06:00:00Z")) + let snapshot = PoeUsageHistorySnapshot( + entries: [ + self.makeEntry( + id: "today", + createdAt: localToday, + model: "GPT-4o", + usageType: "chat", + points: 80, + costUSD: 0.08), + self.makeEntry( + id: "yesterday", + createdAt: localYesterday, + model: "Claude", + usageType: "chat", + points: 20, + costUSD: 0.02), + ], + daily: [ + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-06-22", + points: 100, + requests: 2, + costUSD: 0.10), + ], + updatedAt: now) + + let current = snapshot.currentDay(now: now, calendar: calendar) + #expect(current.points == 80) + #expect(current.requests == 1) + #expect(current.costUSD == 0.08) + } + // MARK: - topModels / topModel @Test diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index a2451ff737..d16324a15d 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -138,7 +138,7 @@ struct StatusItemAnimationSignatureTests { #expect(signature.contains("provider=antigravity")) #expect(signature.contains("style=combined")) - #expect(signature.contains("primary=99.000")) + #expect(signature.contains("primary=98.000")) #expect(signature.contains("weekly=1.000")) } diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 3c781ca022..f8f688d093 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -242,11 +242,13 @@ struct UsageStoreHighestUsageTests { #expect(highest?.provider == .codex) #expect(highest?.usedPercent == 70) } +} +extension UsageStoreHighestUsageTests { @Test - func `automatic metric ranks antigravity by rendered gemini quota summary lanes`() { + func `automatic metric ranks antigravity by rendered quota summary lanes across groups`() { let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-rendered-gemini"), + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-all-summary"), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.refreshFrequency = .manual @@ -271,17 +273,26 @@ struct UsageStoreHighestUsageTests { secondary: nil, updatedAt: Date()), provider: .codex) + let antigravity = self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 10, + geminiWeeklyUsed: 20, + otherSessionUsed: 95, + otherWeeklyUsed: 90) + let unknownCadence = NamedRateWindow( + id: "antigravity-quota-summary-future-daily", + title: "Future daily lane", + window: RateWindow( + usedPercent: 99, + windowMinutes: 24 * 60, + resetsAt: nil, + resetDescription: nil)) store._setSnapshotForTesting( - self.antigravityQuotaSummarySnapshot( - geminiSessionUsed: 10, - geminiWeeklyUsed: 20, - otherSessionUsed: 95, - otherWeeklyUsed: 90), + antigravity.with(extraRateWindows: (antigravity.extraRateWindows ?? []) + [unknownCadence]), provider: .antigravity) var highest = store.providerWithHighestUsage() - #expect(highest?.provider == .codex) - #expect(highest?.usedPercent == 80) + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 95) store._setSnapshotForTesting( self.antigravityQuotaSummarySnapshot( @@ -306,9 +317,9 @@ struct UsageStoreHighestUsageTests { } @Test - func `automatic metric keeps antigravity when one rendered lane has quota`() { + func `automatic metric skips exhausted antigravity quota summary lanes when another remains usable`() { let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-all-100"), + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-summary-usable"), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.refreshFrequency = .manual @@ -340,8 +351,8 @@ struct UsageStoreHighestUsageTests { store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) let highest = store.providerWithHighestUsage() - #expect(highest?.provider == .antigravity) - #expect(highest?.usedPercent == 100) + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) } @Test diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index f6e6a2fc27..819a09961c 100644 --- a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -9,23 +9,23 @@ /* Begin PBXBuildFile section */ 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */; }; 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */; }; + 6AE8A91F50B5CE058EC3F7C3 /* BurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */; }; 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E430B27E4F28973A5E77EA3F /* WidgetKit.framework */; }; + 795FB218DC9B1C0909B4D202 /* CombinedBurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */; }; 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */ = {isa = PBXBuildFile; productRef = 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */; }; 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F15F392AF9D502F0503F8F /* SwiftUI.framework */; }; 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */; }; - B2C3D4E5F6071829A3B4C5D6 /* BurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6071829A3B4C5D7 /* BurnDownWidgetViews.swift */; }; - C3D4E5F607182930B4C5D6E7 /* CombinedBurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F607182930B4C5D6E8 /* CombinedBurnDownWidgetViews.swift */; }; - D4E5F60718293041C5D6E7F8 /* BurnDownWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F60718293041C5D6E7F9 /* BurnDownWidgetProvider.swift */; }; + D1B3B06F03A2F05251AC0B42 /* BurnDownWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 02F15F392AF9D502F0503F8F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedBurnDownWidgetViews.swift; sourceTree = ""; }; 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; - B2C3D4E5F6071829A3B4C5D7 /* BurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetViews.swift; sourceTree = ""; }; - C3D4E5F607182930B4C5D6E8 /* CombinedBurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedBurnDownWidgetViews.swift; sourceTree = ""; }; - D4E5F60718293041C5D6E7F9 /* BurnDownWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetProvider.swift; sourceTree = ""; }; + A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetViews.swift; sourceTree = ""; }; + B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetProvider.swift; sourceTree = ""; }; E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; EFBE36CB6481E7133E2A5CF3 /* CodexBar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CodexBar; path = ..; sourceTree = SOURCE_ROOT; }; @@ -66,12 +66,12 @@ B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */ = { isa = PBXGroup; children = ( + B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */, + A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */, 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */, 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */, 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */, - B2C3D4E5F6071829A3B4C5D7 /* BurnDownWidgetViews.swift */, - C3D4E5F607182930B4C5D6E8 /* CombinedBurnDownWidgetViews.swift */, - D4E5F60718293041C5D6E7F9 /* BurnDownWidgetProvider.swift */, + 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */, ); name = CodexBarWidget; path = ../Sources/CodexBarWidget; @@ -154,12 +154,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D1B3B06F03A2F05251AC0B42 /* BurnDownWidgetProvider.swift in Sources */, + 6AE8A91F50B5CE058EC3F7C3 /* BurnDownWidgetViews.swift in Sources */, 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */, 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */, 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */, - B2C3D4E5F6071829A3B4C5D6 /* BurnDownWidgetViews.swift in Sources */, - C3D4E5F607182930B4C5D6E7 /* CombinedBurnDownWidgetViews.swift in Sources */, - D4E5F60718293041C5D6E7F8 /* BurnDownWidgetProvider.swift in Sources */, + 795FB218DC9B1C0909B4D202 /* CombinedBurnDownWidgetViews.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/docs/cli.md b/docs/cli.md index 7c4c5e9e0f..28809223d6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -153,6 +153,7 @@ codexbar cache clear --all --format json --pretty ``` == Codex 0.6.0 (codex-cli) == Session: 72% left [========----] +Pace: 12% in deficit | Expected 16% used | Projected empty in 2h 30m Resets today at 2:15 PM Weekly: 41% left [====--------] Pace: 6% in reserve | Expected 47% used | Lasts until reset @@ -161,6 +162,7 @@ Credits: 112.4 left == Claude Code 2.0.58 (web) == Session: 88% left [==========--] +Pace: On pace | Expected 13% used | Lasts until reset Resets tomorrow at 1:00 AM Weekly: 63% left [=======-----] Pace: On pace | Expected 37% used | Runs out in 4d @@ -199,6 +201,10 @@ Note: Using CLI fallback "accountOrganization": null, "loginMethod": "plus" }, + "pace": { + "primary": { "stage": "ahead", "deltaPercent": 12, "expectedUsedPercent": 16, "willLastToReset": false, "etaSeconds": 9000, "summary": "12% in deficit | Expected 16% used | Projected empty in 2h 30m" }, + "secondary": { "stage": "slightlyBehind", "deltaPercent": -6, "expectedUsedPercent": 47, "willLastToReset": true, "summary": "6% in reserve | Expected 47% used | Lasts until reset" } + }, "credits": { "remaining": 112.4, "updatedAt": "2025-12-04T18:10:21Z" }, "antigravityPlanInfo": null, "openaiDashboard": {