{
+ await operation()
}
- guard response.response.url?.scheme?.lowercased() == "https",
- response.response.url?.host?.lowercased() == self.billingURL.host?.lowercased()
+ let race = BoundedTaskJoin(sourceTask: sourceTask)
+ switch await race.value(joinGrace: timeout) {
+ case let .value(result):
+ return result
+ case .timedOut, .failure:
+ return nil
+ }
+ }
+
+ /// Best-effort fetch of the Pay-as-you-go tab. Never throws: subscription quota windows are
+ /// the primary, historically-supported contract of this fetcher, and an account without PAYG
+ /// credit (or a console change that breaks this parser) must not regress that core behavior.
+ private static func fetchPayAsYouGo(
+ cookieHeader: String,
+ transport: any ProviderHTTPTransport,
+ timeout: TimeInterval) async -> SakanaPayAsYouGoSnapshot?
+ {
+ var request = URLRequest(url: self.payAsYouGoURL)
+ request.httpMethod = "GET"
+ request.timeoutInterval = timeout
+ request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
+ request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+
+ guard let response = try? await transport.response(for: request),
+ response.statusCode == 200,
+ response.response.url?.scheme?.lowercased() == "https",
+ response.response.url?.host?.lowercased() == self.billingURL.host?.lowercased(),
+ let html = String(data: response.data, encoding: .utf8), !html.isEmpty
else {
- throw SakanaUsageError.loginRequired
+ return nil
}
- guard response.statusCode == 200 else {
- throw SakanaUsageError.apiError(response.statusCode)
+ return self.parsePayAsYouGoHTML(html)
+ }
+
+ static func parsePayAsYouGoHTML(_ html: String) -> SakanaPayAsYouGoSnapshot? {
+ guard let balanceText = self.capture(
+ pattern: #"]*>\s*Credit balance\s*
[\s\S]{0,900}?]*tabular-nums[^"]*"[^>]*>"# +
+ #"\$?([0-9][0-9,]*(?:\.[0-9]+)?)
"#,
+ in: html),
+ let creditBalance = self.parseAmount(balanceText)
+ else {
+ return nil
}
- guard let html = String(data: response.data, encoding: .utf8), !html.isEmpty else {
- throw SakanaUsageError.parseFailed("Billing page response was empty.")
+
+ let usageTotalText = self.capture(
+ pattern: #"]*>\s*Usage\s*
\s*]*>\s*Total(?:)?:\s*"# +
+ #"(?:)?\$?([0-9][0-9,]*(?:\.[0-9]+)?)\s*"#,
+ in: html)
+ let periodUsageTotal = usageTotalText.flatMap(self.parseAmount)
+
+ let periodLabel = self.capture(
+ pattern: #"aria-label="Usage date range"[^>]*>([\s\S]*?)"#,
+ in: html).map(self.stripHTMLComments)
+
+ return SakanaPayAsYouGoSnapshot(
+ creditBalance: creditBalance,
+ periodUsageTotal: periodUsageTotal,
+ periodLabel: periodLabel)
+ }
+
+ private static func parseAmount(_ text: String) -> Double? {
+ guard let value = Double(text.replacingOccurrences(of: ",", with: "")), value.isFinite else {
+ return nil
}
- return try self.parseBillingHTML(html, now: now)
+ return value
+ }
+
+ /// Strips React's `` hydration boundary comments (inserted between separately
+ /// interpolated JSX text nodes) and collapses the remaining whitespace.
+ private static func stripHTMLComments(_ text: String) -> String {
+ let stripped = text.replacingOccurrences(
+ of: #""#,
+ with: "",
+ options: .regularExpression)
+ return stripped
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
}
static func parseBillingHTML(
diff --git a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift
index 4527c992da..dddacef7bf 100644
--- a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift
+++ b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift
@@ -276,7 +276,7 @@ public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable {
private func credentials(from query: [String: Any]) throws -> ZedCredentials? {
var result: AnyObject?
- let status = SecItemCopyMatching(query as CFDictionary, &result)
+ let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
break
diff --git a/Sources/CodexBarCore/RemoteSessionFetcher.swift b/Sources/CodexBarCore/RemoteSessionFetcher.swift
new file mode 100644
index 0000000000..b14e8cbf80
--- /dev/null
+++ b/Sources/CodexBarCore/RemoteSessionFetcher.swift
@@ -0,0 +1,191 @@
+import Foundation
+
+public struct RemoteSessionHostResult: Equatable, Sendable, Identifiable {
+ public let host: String
+ public let sessions: [AgentSession]
+ public let error: String?
+
+ public var id: String {
+ self.host
+ }
+
+ public var isReachable: Bool {
+ self.error == nil
+ }
+
+ public init(host: String, sessions: [AgentSession], error: String?) {
+ self.host = host
+ self.sessions = sessions
+ self.error = error
+ }
+}
+
+public enum TailscaleStatusParser {
+ public static func hosts(from data: Data, excludingLocalHost localHost: String? = nil) -> [String] {
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
+ let peers: [[String: Any]] = if let dictionary = root["Peer"] as? [String: [String: Any]] {
+ Array(dictionary.values)
+ } else if let array = root["Peer"] as? [[String: Any]] {
+ array
+ } else {
+ []
+ }
+ let selfStatus = root["Self"] as? [String: Any]
+ let localLabels = Set([
+ localHost,
+ selfStatus?["DNSName"] as? String,
+ selfStatus?["HostName"] as? String,
+ ].compactMap(self.firstDNSLabel).map { $0.lowercased() })
+
+ var seen = Set()
+ return peers.compactMap { peer in
+ guard peer["Online"] as? Bool == true,
+ let operatingSystem = peer["OS"] as? String,
+ operatingSystem == "macOS" || operatingSystem == "linux",
+ let label = self.firstDNSLabel(peer["DNSName"] as? String)
+ else { return nil }
+ let normalized = label.lowercased()
+ guard !localLabels.contains(normalized), seen.insert(normalized).inserted else { return nil }
+ return label
+ }.sorted()
+ }
+
+ private static func firstDNSLabel(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "."))
+ guard let label = trimmed.split(separator: ".").first, !label.isEmpty else { return nil }
+ return String(label)
+ }
+}
+
+public struct RemoteSessionFetcher: Sendable {
+ public static let bundledCLIFallback = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"
+
+ public init() {}
+
+ public func discoveredHosts(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ localHost: String = ProcessInfo.processInfo.hostName) async -> [String]
+ {
+ guard let tailscale = self.tailscaleBinary(environment: environment),
+ let result = try? await SubprocessRunner.run(
+ binary: tailscale,
+ arguments: ["status", "--json"],
+ environment: environment,
+ timeout: 5,
+ label: "Tailscale session host discovery"),
+ let data = result.stdout.data(using: .utf8)
+ else { return [] }
+ return TailscaleStatusParser.hosts(from: data, excludingLocalHost: localHost)
+ }
+
+ public func fetch(
+ hosts: [String],
+ environment: [String: String] = ProcessInfo.processInfo.environment) async -> [RemoteSessionHostResult]
+ {
+ let normalizedHosts = Self.sanitizedHosts(hosts)
+ return await withTaskGroup(
+ of: RemoteSessionHostResult.self,
+ returning: [RemoteSessionHostResult].self)
+ { group in
+ for host in normalizedHosts {
+ group.addTask {
+ await self.fetch(host: host, environment: environment)
+ }
+ }
+ var results: [RemoteSessionHostResult] = []
+ for await result in group {
+ results.append(result)
+ }
+ return results
+ .sorted { lhs, rhs in lhs.host.localizedCaseInsensitiveCompare(rhs.host) == .orderedAscending }
+ }
+ }
+
+ public func focus(
+ sessionID: String,
+ host: String,
+ environment: [String: String] = ProcessInfo.processInfo.environment) async
+ {
+ guard let host = Self.sanitizedHosts([host]).first else { return }
+ guard let ssh = self.findExecutable("ssh", environment: environment) ??
+ (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) })
+ else { return }
+ let command = "codexbar sessions focus \(Self.shellQuote(sessionID)) || " +
+ "\(Self.shellQuote(Self.bundledCLIFallback)) sessions focus \(Self.shellQuote(sessionID))"
+ _ = try? await SubprocessRunner.run(
+ binary: ssh,
+ arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=3", host, "sh", "-lc", Self.shellQuote(command)],
+ environment: environment,
+ timeout: 5,
+ acceptsNonZeroExit: true,
+ label: "focus remote agent session")
+ }
+
+ private func fetch(host: String, environment: [String: String]) async -> RemoteSessionHostResult {
+ guard let ssh = self.findExecutable("ssh", environment: environment) ??
+ (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) })
+ else {
+ return RemoteSessionHostResult(host: host, sessions: [], error: "ssh not found")
+ }
+ let command = "codexbar sessions --json || " +
+ "\(Self.shellQuote(Self.bundledCLIFallback)) sessions --json"
+ do {
+ let result = try await SubprocessRunner.run(
+ binary: ssh,
+ arguments: [
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=3",
+ host,
+ "sh", "-lc", Self.shellQuote(command),
+ ],
+ environment: environment,
+ timeout: 5,
+ label: "fetch remote agent sessions")
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ var sessions = try decoder.decode([AgentSession].self, from: Data(result.stdout.utf8))
+ for index in sessions.indices {
+ sessions[index].host = host
+ }
+ return RemoteSessionHostResult(host: host, sessions: sessions, error: nil)
+ } catch {
+ return RemoteSessionHostResult(host: host, sessions: [], error: error.localizedDescription)
+ }
+ }
+
+ private func tailscaleBinary(environment: [String: String]) -> String? {
+ self.findExecutable("tailscale", environment: environment) ?? {
+ let bundled = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
+ return FileManager.default.isExecutableFile(atPath: bundled) ? bundled : nil
+ }()
+ }
+
+ private func findExecutable(_ name: String, environment: [String: String]) -> String? {
+ let path = environment["PATH"] ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
+ return path.split(separator: ":")
+ .map { String($0) + "/" + name }
+ .first { FileManager.default.isExecutableFile(atPath: $0) }
+ }
+
+ public static func sanitizedHosts(_ hosts: [String]) -> [String] {
+ var seen = Set()
+ return hosts.compactMap { rawHost in
+ let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines)
+ let hasUnsafeScalar = host.unicodeScalars.contains { scalar in
+ CharacterSet.controlCharacters.contains(scalar) ||
+ CharacterSet.whitespacesAndNewlines.contains(scalar)
+ }
+ guard !host.isEmpty,
+ !host.hasPrefix("-"),
+ !hasUnsafeScalar,
+ seen.insert(host.lowercased()).inserted
+ else { return nil }
+ return host
+ }
+ }
+
+ private static func shellQuote(_ value: String) -> String {
+ "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+}
diff --git a/Sources/CodexBarCore/SessionWindowFocuser.swift b/Sources/CodexBarCore/SessionWindowFocuser.swift
new file mode 100644
index 0000000000..c832e7341d
--- /dev/null
+++ b/Sources/CodexBarCore/SessionWindowFocuser.swift
@@ -0,0 +1,107 @@
+#if os(macOS)
+import AppKit
+import ApplicationServices
+import Foundation
+
+public enum SessionFocusResult: Equatable, Sendable {
+ case focused
+ case activatedApplicationOnly
+ case failed
+}
+
+@MainActor
+public enum SessionWindowFocuser {
+ private static let knownBundleIdentifiers: Set = [
+ "com.mitchellh.ghostty",
+ "com.googlecode.iterm2",
+ "com.apple.Terminal",
+ "dev.warp.Warp-Stable",
+ "com.github.wez.wezterm",
+ "net.kovidgoyal.kitty",
+ "org.alacritty",
+ "com.microsoft.VSCode",
+ "com.todesktop.230313mzl4w4u92",
+ "dev.zed.Zed",
+ "com.anthropic.claudefordesktop",
+ ]
+
+ @discardableResult
+ public static func focus(_ session: AgentSession, promptForAccessibility: Bool = true) -> SessionFocusResult {
+ guard let application = self.application(for: session) else { return .failed }
+ guard application.activate() else { return .failed }
+
+ let trusted = AXIsProcessTrustedWithOptions(
+ ["AXTrustedCheckOptionPrompt": promptForAccessibility] as CFDictionary)
+ guard trusted else { return .activatedApplicationOnly }
+
+ let appElement = AXUIElementCreateApplication(application.processIdentifier)
+ var windowsValue: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success,
+ let windows = windowsValue as? [AXUIElement],
+ let window = self.preferredWindow(windows, session: session) ?? windows.first
+ else { return .activatedApplicationOnly }
+ AXUIElementPerformAction(window, kAXRaiseAction as CFString)
+ return .focused
+ }
+
+ private static func application(for session: AgentSession) -> NSRunningApplication? {
+ if let pid = session.pid {
+ var currentPID = pid
+ var fallback: NSRunningApplication?
+ var visited = Set()
+ while currentPID > 0, visited.insert(currentPID).inserted {
+ if let application = NSRunningApplication(processIdentifier: currentPID) {
+ fallback = fallback ?? application
+ if let bundleIdentifier = application.bundleIdentifier,
+ self.knownBundleIdentifiers.contains(bundleIdentifier)
+ {
+ return application
+ }
+ }
+ guard let parent = self.parentPID(of: currentPID), parent != currentPID else { break }
+ currentPID = parent
+ }
+ if let fallback {
+ return fallback
+ }
+ }
+
+ let bundleIdentifier: String? = switch (session.provider, session.source) {
+ case (.claude, .desktopApp): "com.anthropic.claudefordesktop"
+ case (.codex, .desktopApp): "com.openai.codex"
+ default: nil
+ }
+ guard let bundleIdentifier else { return nil }
+ return NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first
+ }
+
+ private static func preferredWindow(_ windows: [AXUIElement], session: AgentSession) -> AXUIElement? {
+ let candidates = [session.projectName, session.cwd.map { URL(fileURLWithPath: $0).lastPathComponent }]
+ .compactMap { $0?.lowercased() }
+ .filter { !$0.isEmpty }
+ guard !candidates.isEmpty else { return nil }
+ return windows.first { window in
+ var titleValue: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleValue) == .success,
+ let title = titleValue as? String
+ else { return false }
+ let lowercasedTitle = title.lowercased()
+ return candidates.contains { lowercasedTitle.contains($0) }
+ }
+ }
+
+ private static func parentPID(of pid: Int32) -> Int32? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/ps")
+ process.arguments = ["-o", "ppid=", "-p", String(pid)]
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ guard (try? process.run()) != nil else { return nil }
+ process.waitUntilExit()
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ guard let output = String(data: data, encoding: .utf8) else { return nil }
+ return Int32(output.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/UsageChartScale.swift b/Sources/CodexBarCore/UsageChartScale.swift
new file mode 100644
index 0000000000..81c7eec6b7
--- /dev/null
+++ b/Sources/CodexBarCore/UsageChartScale.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+public struct UsageChartScale: Equatable, Sendable {
+ public let maximum: Double
+
+ public init(values: [Double]) {
+ self.maximum = values
+ .filter { $0.isFinite && $0 > 0 }
+ .max() ?? 0
+ }
+
+ public func fraction(for value: Double) -> Double {
+ guard self.maximum > 0, value.isFinite, value > 0 else { return 0 }
+ return min(value / self.maximum, 1)
+ }
+}
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index 6d071f6360..ef6a0ee3b3 100644
--- a/Sources/CodexBarCore/UsageFetcher.swift
+++ b/Sources/CodexBarCore/UsageFetcher.swift
@@ -184,7 +184,9 @@ public struct UsageSnapshot: Codable, Sendable {
public let deepseekUsage: DeepSeekUsageSummary?
public let mimoUsage: MiMoUsageSnapshot?
public let openRouterUsage: OpenRouterUsageSnapshot?
+ public let sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot?
public let crossModelUsage: CrossModelUsageSnapshot?
+ public let clawRouterUsage: ClawRouterUsageSnapshot?
public let openAIAPIUsage: OpenAIAPIUsageSnapshot?
public let codexResetCredits: CodexRateLimitResetCreditsSnapshot?
public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot?
@@ -214,7 +216,9 @@ public struct UsageSnapshot: Codable, Sendable {
case ampUsage
case mimoUsage
case openRouterUsage
+ case sakanaPayAsYouGo
case crossModelUsage
+ case clawRouterUsage
case openAIAPIUsage
case codexResetCredits
case claudeAdminAPIUsage
@@ -244,7 +248,9 @@ public struct UsageSnapshot: Codable, Sendable {
deepseekUsage: DeepSeekUsageSummary? = nil,
mimoUsage: MiMoUsageSnapshot? = nil,
openRouterUsage: OpenRouterUsageSnapshot? = nil,
+ sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? = nil,
crossModelUsage: CrossModelUsageSnapshot? = nil,
+ clawRouterUsage: ClawRouterUsageSnapshot? = nil,
openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil,
codexResetCredits: CodexRateLimitResetCreditsSnapshot? = nil,
claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil,
@@ -273,7 +279,9 @@ public struct UsageSnapshot: Codable, Sendable {
self.deepseekUsage = deepseekUsage
self.mimoUsage = mimoUsage
self.openRouterUsage = openRouterUsage
+ self.sakanaPayAsYouGo = sakanaPayAsYouGo
self.crossModelUsage = crossModelUsage
+ self.clawRouterUsage = clawRouterUsage
self.openAIAPIUsage = openAIAPIUsage
self.codexResetCredits = codexResetCredits
self.claudeAdminAPIUsage = claudeAdminAPIUsage
@@ -319,7 +327,11 @@ public struct UsageSnapshot: Codable, Sendable {
self.deepseekUsage = nil // Not persisted, fetched fresh each time
self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage)
self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage)
+ self.sakanaPayAsYouGo = try container.decodeIfPresent(
+ SakanaPayAsYouGoSnapshot.self,
+ forKey: .sakanaPayAsYouGo)
self.crossModelUsage = try container.decodeIfPresent(CrossModelUsageSnapshot.self, forKey: .crossModelUsage)
+ self.clawRouterUsage = try container.decodeIfPresent(ClawRouterUsageSnapshot.self, forKey: .clawRouterUsage)
self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage)
self.codexResetCredits = try container.decodeIfPresent(
CodexRateLimitResetCreditsSnapshot.self,
@@ -372,7 +384,9 @@ public struct UsageSnapshot: Codable, Sendable {
try container.encodeIfPresent(self.ampUsage, forKey: .ampUsage)
try container.encodeIfPresent(self.mimoUsage, forKey: .mimoUsage)
try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage)
+ try container.encodeIfPresent(self.sakanaPayAsYouGo, forKey: .sakanaPayAsYouGo)
try container.encodeIfPresent(self.crossModelUsage, forKey: .crossModelUsage)
+ try container.encodeIfPresent(self.clawRouterUsage, forKey: .clawRouterUsage)
try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage)
try container.encodeIfPresent(self.codexResetCredits, forKey: .codexResetCredits)
try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage)
@@ -551,7 +565,9 @@ public struct UsageSnapshot: Codable, Sendable {
deepseekUsage: self.deepseekUsage,
mimoUsage: self.mimoUsage,
openRouterUsage: self.openRouterUsage,
+ sakanaPayAsYouGo: self.sakanaPayAsYouGo,
crossModelUsage: self.crossModelUsage,
+ clawRouterUsage: self.clawRouterUsage,
openAIAPIUsage: self.openAIAPIUsage,
codexResetCredits: codexResetCredits.resolving(self.codexResetCredits),
claudeAdminAPIUsage: self.claudeAdminAPIUsage,
diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift
index e5ce49d4ba..4b469d67e2 100644
--- a/Sources/CodexBarCore/UsageFormatter.swift
+++ b/Sources/CodexBarCore/UsageFormatter.swift
@@ -82,9 +82,10 @@ public enum UsageFormatter {
public static func percentText(_ percent: Double, suffix: String) -> String {
let clamped = min(100, max(0, percent))
- let text = self.localized("%.0f%% %@", clamped, suffix)
- guard clamped > 0, clamped < 1 else { return text }
- return text.replacingOccurrences(of: "0%", with: "<1%")
+ if clamped > 0, clamped < 1 {
+ return self.localized("<1%% %@", suffix)
+ }
+ return self.localized("%.0f%% %@", clamped, suffix)
}
public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String {
@@ -112,6 +113,7 @@ public enum UsageFormatter {
if days > 0 {
if hours > 0 { return "in \(days)d \(hours)h" }
+ if minutes > 0 { return "in \(days)d \(minutes)m" }
return "in \(days)d"
}
if hours > 0 {
@@ -243,6 +245,16 @@ public enum UsageFormatter {
value.formatted(.currency(code: currencyCode).locale(Locale(identifier: "en_US")))
}
+ public static func compactCurrencyString(_ value: Double, currencyCode: String) -> String {
+ if value != 0, abs(value) < 1 {
+ return self.currencyString(value, currencyCode: currencyCode)
+ }
+ return value.formatted(
+ .currency(code: currencyCode)
+ .precision(.fractionLength(0))
+ .locale(Locale(identifier: "en_US")))
+ }
+
public static func tokenCountString(_ value: Int) -> String {
let absValue = abs(value)
let sign = value < 0 ? "-" : ""
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift
index 40c2d8c6f4..2515354d9d 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift
@@ -3,6 +3,7 @@ import Foundation
enum CostUsageCacheIO {
private static let compatibleCodexProducerKeys: Set = [
"codex:cu:p3c27f997569eb3c5",
+ "codex:cu:pc54070a94f6419ea",
]
private static func artifactVersion(for provider: UsageProvider) -> Int {
@@ -110,6 +111,7 @@ struct CostUsageCache: Codable {
var scanUntilKey: String?
var codexPricingKey: String?
var codexPriorityMetadataKey: String?
+ var codexProjectMetadataVersion: Int?
var codexPriorityTurnKeys: [String: String]?
var codexPriorityTurnIDsByDay: [String: [String]]?
@@ -136,6 +138,9 @@ struct CostUsageFileUsage: Codable {
var lastCodexTurnID: String?
var sessionId: String?
var forkedFromId: String?
+ var projectPath: String?
+ var canonicalProjectPath: String?
+ var codexCostCacheComplete: Bool?
var codexCostNanos: [String: [String: Int64]]?
var codexPrioritySurchargeNanos: [String: [String: Int64]]?
var codexStandardCostNanos: [String: [String: Int64]]?
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift
index 98e8779b4d..d5f78283df 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift
@@ -8,19 +8,21 @@ import Darwin
#endif
extension CostUsageScanner {
- static func codexRowsByDayModel(
- cache: CostUsageCache,
- range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]]
- {
- var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:]
- for usage in cache.files.values {
- for row in usage.codexRows ?? [] {
- guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey)
- else { continue }
- rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row)
- }
+ private final class CodexModelsDevCatalogResolver {
+ private var catalog: ModelsDevCatalog?
+ private let cacheRoot: URL?
+
+ init(catalog: ModelsDevCatalog?, cacheRoot: URL?) {
+ self.catalog = catalog
+ self.cacheRoot = cacheRoot
+ }
+
+ func load(_ loader: (URL?) -> ModelsDevCatalog?) -> ModelsDevCatalog {
+ if let catalog { return catalog }
+ let loaded = loader(self.cacheRoot) ?? ModelsDevCatalog(providers: [:])
+ self.catalog = loaded
+ return loaded
}
- return rowsByDayModel
}
static func codexRowsByDayModel(
@@ -78,6 +80,12 @@ extension CostUsageScanner {
self.codexIntByDayModel(cache: cache, range: range) { $0.codexPriorityTokens }
}
+ static func codexReportDayKeys(cache: CostUsageCache, range: CostUsageDayRange) -> [String] {
+ cache.days.keys.sorted().filter {
+ CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey)
+ }
+ }
+
static func codexNanosByDayModel(
cache: CostUsageCache,
range: CostUsageDayRange,
@@ -274,6 +282,9 @@ extension CostUsageScanner {
lastCodexTurnID: String? = nil,
sessionId: String? = nil,
forkedFromId: String? = nil,
+ projectPath: String? = nil,
+ canonicalProjectPath: String? = nil,
+ codexCostCacheComplete: Bool? = true,
codexCostNanos: [String: [String: Int64]]? = nil,
codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil,
codexStandardCostNanos: [String: [String: Int64]]? = nil,
@@ -297,6 +308,9 @@ extension CostUsageScanner {
lastCodexTurnID: lastCodexTurnID,
sessionId: sessionId,
forkedFromId: forkedFromId,
+ projectPath: projectPath,
+ canonicalProjectPath: canonicalProjectPath,
+ codexCostCacheComplete: codexCostCacheComplete,
codexCostNanos: codexCostNanos,
codexPrioritySurchargeNanos: codexPrioritySurchargeNanos,
codexStandardCostNanos: codexStandardCostNanos,
@@ -310,14 +324,17 @@ extension CostUsageScanner {
static func needsCodexCostCache(_ usage: CostUsageFileUsage) -> Bool {
!(usage.codexRows?.isEmpty ?? true)
- && (usage.codexCostNanos == nil || self.needsCodexModeSplitCache(usage))
+ && (usage.codexCostCacheComplete != true || self.needsCodexModeSplitCache(usage))
}
static func needsCodexCostCache(_ usage: CostUsageFileUsage, range: CostUsageDayRange) -> Bool {
+ guard usage.codexCostCacheComplete != true || self.needsCodexModeSplitCache(usage) else {
+ return false
+ }
guard let rows = usage.codexRows, !rows.isEmpty else { return false }
return rows.contains {
CostUsageDayRange.isInRange(dayKey: $0.day, since: range.sinceKey, until: range.untilKey)
- } && (usage.codexCostNanos == nil || Self.needsCodexModeSplitCache(usage))
+ }
}
static func needsCodexModeSplitCache(_ usage: CostUsageFileUsage) -> Bool {
@@ -326,21 +343,36 @@ extension CostUsageScanner {
let hasStandardTokens = !(usage.codexStandardTokens?.isEmpty ?? true)
let hasPriorityTokens = !(usage.codexPriorityTokens?.isEmpty ?? true)
- guard hasStandardCost || hasPriorityCost else { return true }
+ // Token maps are also the completion marker for models with no known pricing.
guard hasStandardTokens || hasPriorityTokens else { return true }
- return hasStandardCost != hasStandardTokens || hasPriorityCost != hasPriorityTokens
+ return (hasStandardCost && !hasStandardTokens) || (hasPriorityCost && !hasPriorityTokens)
}
static func codexFileUsageWithCostCache(
_ usage: CostUsageFileUsage,
context: CodexFileScanContext) -> CostUsageFileUsage
+ {
+ self.codexFileUsageWithCostCache(
+ usage,
+ range: context.range,
+ priorityTurns: context.resources.priorityTurns,
+ modelsDevCatalog: context.resources.modelsDevCatalog,
+ modelsDevCacheRoot: context.resources.modelsDevCacheRoot)
+ }
+
+ static func codexFileUsageWithCostCache(
+ _ usage: CostUsageFileUsage,
+ range: CostUsageDayRange,
+ priorityTurns: [String: CodexPriorityTurnMetadata],
+ modelsDevCatalog: ModelsDevCatalog?,
+ modelsDevCacheRoot: URL?) -> CostUsageFileUsage
{
guard let rows = usage.codexRows, !rows.isEmpty else { return usage }
var migratedRows: [CodexUsageRow] = []
for row in rows where CostUsageDayRange.isInRange(
dayKey: row.day,
- since: context.range.scanSinceKey,
- until: context.range.scanUntilKey)
+ since: range.scanSinceKey,
+ until: range.scanUntilKey)
{
migratedRows.append(row)
}
@@ -348,26 +380,26 @@ extension CostUsageScanner {
let splitMaps = Self.codexModeSplitMaps(
rows: migratedRows,
- range: context.range,
- priorityTurns: context.resources.priorityTurns,
- modelsDevCatalog: context.resources.modelsDevCatalog,
- modelsDevCacheRoot: context.resources.modelsDevCacheRoot)
+ range: range,
+ priorityTurns: priorityTurns,
+ modelsDevCatalog: modelsDevCatalog,
+ modelsDevCacheRoot: modelsDevCacheRoot)
var updated = usage
updated.codexCostNanos = Self.mergeMissingCostMaps(
usage.codexCostNanos,
Self.codexCostNanos(
rows: migratedRows,
- range: context.range,
- modelsDevCatalog: context.resources.modelsDevCatalog,
- modelsDevCacheRoot: context.resources.modelsDevCacheRoot))
+ range: range,
+ modelsDevCatalog: modelsDevCatalog,
+ modelsDevCacheRoot: modelsDevCacheRoot))
updated.codexPrioritySurchargeNanos = Self.mergeMissingCostMaps(
usage.codexPrioritySurchargeNanos,
Self.codexPrioritySurchargeNanos(
rows: migratedRows,
- range: context.range,
- priorityTurns: context.resources.priorityTurns,
- modelsDevCatalog: context.resources.modelsDevCatalog,
- modelsDevCacheRoot: context.resources.modelsDevCacheRoot))
+ range: range,
+ priorityTurns: priorityTurns,
+ modelsDevCatalog: modelsDevCatalog,
+ modelsDevCacheRoot: modelsDevCacheRoot))
updated.codexStandardCostNanos = Self.mergeMissingCostMaps(
usage.codexStandardCostNanos,
splitMaps.standardCostNanos)
@@ -380,6 +412,7 @@ extension CostUsageScanner {
updated.codexPriorityTokens = Self.mergeMissingIntMaps(
usage.codexPriorityTokens,
splitMaps.priorityTokens)
+ updated.codexCostCacheComplete = true
updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows)
updated.codexRows = rows
return updated
@@ -663,6 +696,8 @@ extension CostUsageScanner {
lastCodexTurnID: usage.lastCodexTurnID,
sessionId: usage.sessionId,
forkedFromId: usage.forkedFromId,
+ projectPath: usage.projectPath,
+ canonicalProjectPath: usage.canonicalProjectPath,
codexCostNanos: Self.mergeCostMaps(
Self.costMapOutsideScanWindow(usage.codexCostNanos, range: context.range),
Self.codexCostNanos(
@@ -942,6 +977,10 @@ extension CostUsageScanner {
return false
}
let sessionId = delta.sessionId ?? cached.sessionId
+ let projectPath = delta.projectPath ?? cached.projectPath
+ let canonicalProjectPath = delta.projectPath.map {
+ context.resources.projectPathResolver.canonicalProjectPath(for: $0)
+ } ?? cached.canonicalProjectPath ?? context.resources.projectPathResolver.canonicalProjectPath(for: projectPath)
let sessionAlreadyContributed = sessionId.map { state.contributingSessionIds.contains($0) } ?? false
let cachedRows = cached.codexRows ?? []
let retainedCachedRows: [CodexUsageRow]
@@ -1004,6 +1043,8 @@ extension CostUsageScanner {
lastCodexTurnID: delta.lastCodexTurnID,
sessionId: sessionId,
forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId,
+ projectPath: projectPath,
+ canonicalProjectPath: canonicalProjectPath,
codexCostNanos: Self.codexMergedCostMap(
migratedCached.codexCostNanos,
deltaRows: uniqueRows,
@@ -1056,6 +1097,11 @@ extension CostUsageScanner {
inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:),
checkCancellation: context.checkCancellation)
let sessionId = parsed.sessionId ?? input.cached?.sessionId
+ let projectPath = parsed.projectPath ?? input.cached?.projectPath
+ let canonicalProjectPath = parsed.projectPath.map {
+ context.resources.projectPathResolver.canonicalProjectPath(for: $0)
+ } ?? input.cached?.canonicalProjectPath ?? context.resources.projectPathResolver
+ .canonicalProjectPath(for: projectPath)
let uniqueRows = Self.uniqueCodexRows(
rows: parsed.rows,
sessionId: sessionId,
@@ -1091,6 +1137,8 @@ extension CostUsageScanner {
lastCodexTurnID: parsed.lastCodexTurnID,
sessionId: sessionId,
forkedFromId: parsed.forkedFromId,
+ projectPath: projectPath,
+ canonicalProjectPath: canonicalProjectPath,
codexCostNanos: Self.mergeCostMaps(
context.dropDeferredCodexRows
? nil
@@ -1244,33 +1292,39 @@ extension CostUsageScanner {
range: CostUsageDayRange,
modelsDevCatalog: ModelsDevCatalog? = nil,
modelsDevCacheRoot: URL? = nil,
- priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> CostUsageDailyReport
+ priorityTurns: [String: CodexPriorityTurnMetadata] = [:],
+ modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = {
+ CostUsagePricing.modelsDevCatalog(cacheRoot: $0)
+ }) -> CostUsageDailyReport
{
- var entries: [CostUsageDailyReport.Entry] = []
- var totalInput = 0
- var totalCacheRead = 0
- var totalOutput = 0
- var totalTokens = 0
- var totalCost: Double = 0
- var costSeen = false
-
- let dayKeys = cache.days.keys.sorted().filter {
- CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey)
- }
- let costNanosByDayModel = self.codexCostNanosByDayModel(cache: cache, range: range)
- let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel(cache: cache, range: range)
- let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: cache, range: range)
- let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: cache, range: range)
- let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: cache, range: range)
- let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: cache, range: range)
-
- let hasCodexRows = cache.files.values.contains {
- !($0.codexRows?.isEmpty ?? true)
+ let catalogResolver = CodexModelsDevCatalogResolver(
+ catalog: modelsDevCatalog,
+ cacheRoot: modelsDevCacheRoot)
+ var reportCache = cache
+ for (path, usage) in cache.files where self.needsCodexCostCache(usage, range: range) {
+ reportCache.files[path] = self.codexFileUsageWithCostCache(
+ usage,
+ range: range,
+ priorityTurns: priorityTurns,
+ modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader),
+ modelsDevCacheRoot: modelsDevCacheRoot)
}
- let rowsByDayModel = hasCodexRows ? self.codexRowsByDayModel(cache: cache, range: range) : [:]
+ var entries: [CostUsageDailyReport.Entry] = []
+ var (totalInput, totalCacheRead, totalOutput, totalTokens) = (0, 0, 0, 0)
+ var (totalCost, costSeen) = (0.0, false)
+
+ let dayKeys = self.codexReportDayKeys(cache: reportCache, range: range)
+ let costNanosByDayModel = self.codexCostNanosByDayModel(cache: reportCache, range: range)
+ let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel(
+ cache: reportCache,
+ range: range)
+ let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: reportCache, range: range)
+ let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: reportCache, range: range)
+ let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: reportCache, range: range)
+ let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: reportCache, range: range)
for day in dayKeys {
- guard let models = cache.days[day] else { continue }
+ guard let models = reportCache.days[day] else { continue }
let modelNames = models.keys.sorted()
var dayInput = 0
@@ -1291,20 +1345,17 @@ extension CostUsageScanner {
dayCacheRead += cached
dayOutput += output
- let rows = rowsByDayModel[day]?[model]
- let rowCostBreakdown = rows.map {
- self.codexRowCostBreakdown(
- rows: $0,
- priorityTurns: priorityTurns,
- modelsDevCatalog: modelsDevCatalog,
- modelsDevCacheRoot: modelsDevCacheRoot)
- }
let cachedBaseCost = costNanosByDayModel[day]?[model].map { Double($0) / Self.costScale }
- let rowTotalCost = cachedBaseCost == nil ? rowCostBreakdown?.totalCostUSD : nil
- let standardCost = standardCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale }
- ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardCostUSD : nil)
- let priorityCost = priorityCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale }
- ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityCostUSD : nil)
+ let cachedStandardCost = standardCostNanosByDayModel[day]?[model].map {
+ Double($0) / Self.costScale
+ }
+ let cachedPriorityCost = priorityCostNanosByDayModel[day]?[model].map {
+ Double($0) / Self.costScale
+ }
+ let cachedStandardTokens = standardTokensByDayModel[day]?[model]
+ let cachedPriorityTokens = priorityTokensByDayModel[day]?[model]
+ let standardCost = cachedStandardCost
+ let priorityCost = cachedPriorityCost
let splitTotalCost: Double? = if standardCost != nil || priorityCost != nil {
(standardCost ?? 0) + (priorityCost ?? 0)
} else {
@@ -1312,36 +1363,20 @@ extension CostUsageScanner {
}
var cost = splitTotalCost
?? cachedBaseCost
- ?? rowTotalCost
?? CostUsagePricing.codexCostUSD(
model: model,
inputTokens: input,
cachedInputTokens: cached,
outputTokens: output,
- modelsDevCatalog: modelsDevCatalog,
+ modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader),
modelsDevCacheRoot: modelsDevCacheRoot)
if splitTotalCost == nil,
let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model],
cachedBaseCost != nil
{
cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale)
- } else if splitTotalCost == nil,
- rowTotalCost == nil,
- !priorityTurns.isEmpty,
- let rows,
- let surcharge = self.codexPrioritySurchargeUSD(
- rows: rows,
- priorityTurns: priorityTurns,
- modelsDevCatalog: modelsDevCatalog,
- modelsDevCacheRoot: modelsDevCacheRoot)
- {
- cost = (cost ?? 0) + surcharge
}
- let standardModeTokens = standardTokensByDayModel[day]?[model]
- ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardTokens : nil)
- let priorityModeTokens = priorityTokensByDayModel[day]?[model]
- ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityTokens : nil)
- let hasModeSplit = priorityCost != nil || priorityModeTokens != nil
+ let hasModeSplit = priorityCost != nil || cachedPriorityTokens != nil
breakdown.append(
CostUsageDailyReport.ModelBreakdown(
modelName: model,
@@ -1349,8 +1384,8 @@ extension CostUsageScanner {
totalTokens: totalTokens,
standardCostUSD: hasModeSplit ? standardCost : nil,
priorityCostUSD: hasModeSplit ? priorityCost : nil,
- standardTokens: hasModeSplit ? standardModeTokens : nil,
- priorityTokens: hasModeSplit ? priorityModeTokens : nil))
+ standardTokens: hasModeSplit ? cachedStandardTokens : nil,
+ priorityTokens: hasModeSplit ? cachedPriorityTokens : nil))
if let cost {
dayCost += cost
dayCostSeen = true
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift
index 83730d3b16..2f61dabada 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift
@@ -24,12 +24,17 @@ extension CostUsageScanner {
var unresolved = false
}
- private static func defaultClaudeProjectsRoots(options: Options) -> [URL] {
+ static func defaultClaudeProjectsRoots(
+ options: Options,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
+ fileManager: FileManager = .default) -> [URL]
+ {
if let override = options.claudeProjectsRoots { return override }
var roots: [URL] = []
- if let env = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"]?
+ if let env = environment["CLAUDE_CONFIG_DIR"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!env.isEmpty
{
@@ -44,12 +49,27 @@ extension CostUsageScanner {
}
}
} else {
- let home = FileManager.default.homeDirectoryForCurrentUser
- roots.append(home.appendingPathComponent(".config/claude/projects", isDirectory: true))
- roots.append(home.appendingPathComponent(".claude/projects", isDirectory: true))
+ roots.append(homeDirectory.appendingPathComponent(".config/claude/projects", isDirectory: true))
+ roots.append(homeDirectory.appendingPathComponent(".claude/projects", isDirectory: true))
+ roots.append(contentsOf: ClaudeDesktopProjectsLocator.roots(
+ homeDirectory: homeDirectory,
+ fileManager: fileManager))
}
- return roots
+ return self.deduplicatedClaudeProjectRoots(roots)
+ }
+
+ private static func deduplicatedClaudeProjectRoots(_ roots: [URL]) -> [URL] {
+ var seen: Set = []
+ var out: [URL] = []
+ for root in roots {
+ let standardized = root.standardizedFileURL
+ let path = standardized.path
+ guard !seen.contains(path) else { continue }
+ seen.insert(path)
+ out.append(standardized)
+ }
+ return out
}
static func parseClaudeFile(
@@ -647,7 +667,6 @@ extension CostUsageScanner {
|| cache.lastScanUnixMs == 0
|| nowMs - cache.lastScanUnixMs > refreshMs
- let roots = self.defaultClaudeProjectsRoots(options: options)
let providerFilter = options.claudeLogProviderFilter
var touched: Set = []
@@ -667,6 +686,7 @@ extension CostUsageScanner {
modelsDevCacheRoot: options.cacheRoot,
checkCancellation: checkCancellation)
+ let roots = self.defaultClaudeProjectsRoots(options: options)
for root in roots {
try Self.scanClaudeRoot(
root: root,
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift
new file mode 100644
index 0000000000..b6b1efee0f
--- /dev/null
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift
@@ -0,0 +1,176 @@
+import Foundation
+
+extension CostUsageScanner {
+ static func buildCodexProjectBreakdownsFromCache(
+ cache: CostUsageCache,
+ range: CostUsageDayRange,
+ modelsDevCatalog: ModelsDevCatalog? = nil,
+ modelsDevCacheRoot: URL? = nil,
+ priorityTurns: [String: CodexPriorityTurnMetadata] = [:],
+ modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = {
+ CostUsagePricing.modelsDevCatalog(cacheRoot: $0)
+ }) -> [CostUsageProjectBreakdown]
+ {
+ // Project rollups build one report per cached session file. Resolve pricing once so every
+ // row does not fall back through ModelsDevCache.load and repeat filesystem metadata reads.
+ let resolvedModelsDevCatalog = modelsDevCatalog
+ ?? modelsDevCatalogLoader(modelsDevCacheRoot)
+ ?? ModelsDevCatalog(providers: [:])
+ let projectPathResolver = CodexCanonicalProjectPathResolver()
+ var accumulatorsByProjectPath: [String: CodexProjectBreakdownAccumulator] = [:]
+ for (filePath, usage) in cache.files {
+ guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else {
+ continue
+ }
+ var fileCache = CostUsageCache()
+ fileCache.files[filePath] = usage
+ fileCache.days = usage.days
+ let report = Self.buildCodexReportFromCache(
+ cache: fileCache,
+ range: range,
+ modelsDevCatalog: resolvedModelsDevCatalog,
+ priorityTurns: priorityTurns)
+ guard !report.data.isEmpty else { continue }
+ let projectKey = usage.canonicalProjectPath
+ ?? projectPathResolver.canonicalProjectPath(for: usage.projectPath)
+ ?? ""
+ let sourceKey = usage.projectPath ?? ""
+ var accumulator = accumulatorsByProjectPath[projectKey] ?? CodexProjectBreakdownAccumulator()
+ accumulator.add(report: report, sourcePath: sourceKey)
+ accumulatorsByProjectPath[projectKey] = accumulator
+ }
+
+ return accumulatorsByProjectPath.map { projectPath, accumulator in
+ let merged = CostUsageDailyReport.merged(accumulator.reports)
+ let resolvedPath = projectPath.isEmpty ? nil : projectPath
+ return CostUsageProjectBreakdown(
+ name: Self.codexProjectName(path: resolvedPath),
+ path: resolvedPath,
+ totalTokens: merged.summary?.totalTokens,
+ totalCostUSD: merged.summary?.totalCostUSD,
+ daily: merged.data,
+ modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data),
+ sources: Self.codexProjectSourceBreakdowns(from: accumulator.reportsBySourcePath))
+ }
+ .sorted { lhs, rhs in
+ let lhsCost = lhs.totalCostUSD ?? -1
+ let rhsCost = rhs.totalCostUSD ?? -1
+ if lhsCost != rhsCost { return lhsCost > rhsCost }
+ let lhsTokens = lhs.totalTokens ?? -1
+ let rhsTokens = rhs.totalTokens ?? -1
+ if lhsTokens != rhsTokens { return lhsTokens > rhsTokens }
+ return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
+ }
+ }
+
+ private static func codexProjectName(path: String?) -> String {
+ guard let path, !path.isEmpty else { return CostUsageProjectBreakdown.unknownProjectName }
+ let name = URL(fileURLWithPath: path, isDirectory: true).lastPathComponent
+ return name.isEmpty ? path : name
+ }
+
+ private struct CodexProjectBreakdownAccumulator {
+ var reports: [CostUsageDailyReport] = []
+ var reportsBySourcePath: [String: [CostUsageDailyReport]] = [:]
+
+ mutating func add(report: CostUsageDailyReport, sourcePath: String) {
+ self.reports.append(report)
+ self.reportsBySourcePath[sourcePath, default: []].append(report)
+ }
+ }
+
+ private static func codexProjectSourceBreakdowns(
+ from reportsBySourcePath: [String: [CostUsageDailyReport]]) -> [CostUsageProjectSourceBreakdown]
+ {
+ reportsBySourcePath.map { sourcePath, reports in
+ let merged = CostUsageDailyReport.merged(reports)
+ let resolvedPath = sourcePath.isEmpty ? nil : sourcePath
+ return CostUsageProjectSourceBreakdown(
+ name: Self.codexProjectName(path: resolvedPath),
+ path: resolvedPath,
+ totalTokens: merged.summary?.totalTokens,
+ totalCostUSD: merged.summary?.totalCostUSD,
+ daily: merged.data,
+ modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data))
+ }
+ .sorted { lhs, rhs in
+ let lhsCost = lhs.totalCostUSD ?? -1
+ let rhsCost = rhs.totalCostUSD ?? -1
+ if lhsCost != rhsCost { return lhsCost > rhsCost }
+ let lhsTokens = lhs.totalTokens ?? -1
+ let rhsTokens = rhs.totalTokens ?? -1
+ if lhsTokens != rhsTokens { return lhsTokens > rhsTokens }
+ return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
+ }
+ }
+
+ private struct ProjectBreakdownAccumulator {
+ var totalTokens = 0
+ var sawTotalTokens = false
+ var costUSD: Double = 0
+ var sawCost = false
+ var standardCostUSD: Double = 0
+ var sawStandardCost = false
+ var priorityCostUSD: Double = 0
+ var sawPriorityCost = false
+ var standardTokens = 0
+ var sawStandardTokens = false
+ var priorityTokens = 0
+ var sawPriorityTokens = false
+
+ mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) {
+ if let totalTokens = breakdown.totalTokens {
+ self.totalTokens += totalTokens
+ self.sawTotalTokens = true
+ }
+ if let costUSD = breakdown.costUSD {
+ self.costUSD += costUSD
+ self.sawCost = true
+ }
+ if let standardCostUSD = breakdown.standardCostUSD {
+ self.standardCostUSD += standardCostUSD
+ self.sawStandardCost = true
+ }
+ if let priorityCostUSD = breakdown.priorityCostUSD {
+ self.priorityCostUSD += priorityCostUSD
+ self.sawPriorityCost = true
+ }
+ if let standardTokens = breakdown.standardTokens {
+ self.standardTokens += standardTokens
+ self.sawStandardTokens = true
+ }
+ if let priorityTokens = breakdown.priorityTokens {
+ self.priorityTokens += priorityTokens
+ self.sawPriorityTokens = true
+ }
+ }
+
+ func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown {
+ CostUsageDailyReport.ModelBreakdown(
+ modelName: modelName,
+ costUSD: self.sawCost ? self.costUSD : nil,
+ totalTokens: self.sawTotalTokens ? self.totalTokens : nil,
+ standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil,
+ priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil,
+ standardTokens: self.sawStandardTokens ? self.standardTokens : nil,
+ priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil)
+ }
+ }
+
+ private static func codexProjectModelBreakdowns(
+ from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]?
+ {
+ var accumulators: [String: ProjectBreakdownAccumulator] = [:]
+ for entry in entries {
+ for breakdown in entry.modelBreakdowns ?? [] {
+ var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator()
+ accumulator.add(breakdown)
+ accumulators[breakdown.modelName] = accumulator
+ }
+ }
+ guard !accumulators.isEmpty else { return nil }
+ return Self.sortedModelBreakdowns(accumulators.map { modelName, accumulator in
+ accumulator.build(modelName: modelName)
+ })
+ }
+}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index a4f6c40931..27cdb544be 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -3,10 +3,12 @@ import CryptoKit
#else
import Crypto
#endif
+import Dispatch
import Foundation
// swiftlint:disable type_body_length file_length
enum CostUsageScanner {
+ static let codexProjectMetadataVersion = 1
typealias CancellationCheck = () throws -> Void
static let log = CodexBarLog.logger(LogCategories.tokenCost)
@@ -57,6 +59,7 @@ enum CostUsageScanner {
let lastCodexTurnID: String?
let sessionId: String?
let forkedFromId: String?
+ let projectPath: String?
let rows: [CodexUsageRow]
}
@@ -176,6 +179,7 @@ enum CostUsageScanner {
struct CodexScanResources {
let fileIndex: CodexSessionFileIndex
let inheritedResolver: CodexInheritedTotalsResolver
+ let projectPathResolver: CodexCanonicalProjectPathResolver
let modelsDevCatalog: ModelsDevCatalog?
let modelsDevCacheRoot: URL?
let priorityTurns: [String: CodexPriorityTurnMetadata]
@@ -191,6 +195,94 @@ enum CostUsageScanner {
let checkCancellation: CancellationCheck?
}
+ final class CodexCanonicalProjectPathResolver {
+ private var cache: [String: String] = [:]
+ private let homeCodexWorktreesPrefix: String
+
+ init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) {
+ self.homeCodexWorktreesPrefix = homeDirectory
+ .appendingPathComponent(".codex/worktrees", isDirectory: true)
+ .standardizedFileURL
+ .path
+ }
+
+ func canonicalProjectPath(for projectPath: String?) -> String? {
+ guard let projectPath else { return nil }
+ if let cached = self.cache[projectPath] {
+ return cached
+ }
+ let resolved = self.resolveCanonicalProjectPath(projectPath) ?? projectPath
+ self.cache[projectPath] = resolved
+ return resolved
+ }
+
+ private func resolveCanonicalProjectPath(_ projectPath: String) -> String? {
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: projectPath, isDirectory: &isDirectory),
+ isDirectory.boolValue
+ else { return nil }
+ guard let output = self.gitWorktreeList(projectPath: projectPath) else { return nil }
+ let worktrees = output
+ .split(separator: "\n")
+ .compactMap { line -> String? in
+ guard line.hasPrefix("worktree ") else { return nil }
+ let rawPath = line.dropFirst("worktree ".count)
+ return Self.standardizedAbsolutePath(String(rawPath))
+ }
+ guard !worktrees.isEmpty else { return nil }
+ return worktrees.first { !self.isEphemeralWorktreePath($0) }
+ }
+
+ private func gitWorktreeList(projectPath: String) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = ["git", "-C", projectPath, "worktree", "list", "--porcelain"]
+
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+ let outputCapture = ProcessPipeCapture(pipe: outputPipe)
+ let errorCapture = ProcessPipeCapture(pipe: errorPipe)
+
+ let semaphore = DispatchSemaphore(value: 0)
+ process.terminationHandler = { _ in semaphore.signal() }
+ do {
+ try process.run()
+ } catch {
+ return nil
+ }
+ outputCapture.start()
+ errorCapture.start()
+
+ if semaphore.wait(timeout: .now() + .seconds(1)) == .timedOut {
+ process.terminate()
+ outputCapture.stop()
+ errorCapture.stop()
+ return nil
+ }
+ let data = outputCapture.finishSynchronously(timeout: 0.1)
+ errorCapture.stop()
+ guard process.terminationStatus == 0 else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ private func isEphemeralWorktreePath(_ path: String) -> Bool {
+ path == self.homeCodexWorktreesPrefix
+ || path.hasPrefix(self.homeCodexWorktreesPrefix + "/")
+ || path.hasSuffix("/.codex/worktrees")
+ || path.contains("/.codex/worktrees/")
+ || path == "/private/tmp"
+ || path.hasPrefix("/private/tmp/")
+ }
+
+ private static func standardizedAbsolutePath(_ path: String) -> String? {
+ let expanded = (path as NSString).expandingTildeInPath
+ guard expanded.hasPrefix("/") else { return nil }
+ return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path
+ }
+ }
+
struct CodexRefreshPlan {
let refreshMs: Int64
let roots: [URL]
@@ -198,6 +290,7 @@ enum CostUsageScanner {
let rootsChanged: Bool
let windowExpanded: Bool
let needsCostCacheMigration: Bool
+ let needsProjectMetadataMigration: Bool
let modelsDevCatalog: ModelsDevCatalog?
let codexPricingKey: String
let codexPriorityMetadataKey: String
@@ -464,7 +557,7 @@ enum CostUsageScanner {
.copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp,
.ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana,
.abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun,
- .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .crossmodel:
+ .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .crossmodel, .clawrouter:
return emptyReport
}
}
@@ -955,6 +1048,7 @@ enum CostUsageScanner {
let sessionId: String?
let forkedFromId: String?
let forkTimestamp: String?
+ let projectPath: String?
}
private struct CodexTokenCountRecord {
@@ -993,6 +1087,7 @@ enum CostUsageScanner {
private static let codexJSONFieldTurnId = Array("turn_id".utf8)
private static let codexJSONFieldTurnIdCamel = Array("turnId".utf8)
private static let codexJSONFieldType = Array("type".utf8)
+ private static let codexJSONFieldCwd = Array("cwd".utf8)
private static func codexForkParentId(from payload: [String: Any]?) -> String? {
guard let payload else { return nil }
@@ -1065,6 +1160,24 @@ enum CostUsageScanner {
return nil
}
+ static func normalizedCodexProjectPath(_ rawPath: String?) -> String? {
+ guard let rawPath = rawPath?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !rawPath.isEmpty
+ else { return nil }
+ let expanded = (rawPath as NSString).expandingTildeInPath
+ guard expanded.hasPrefix("/") else { return nil }
+ return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path
+ }
+
+ private static func codexProjectPath(
+ from bytes: UnsafeBufferPointer,
+ payloadRange: Range?) -> String?
+ {
+ guard let payloadRange else { return nil }
+ return Self.normalizedCodexProjectPath(
+ Self.extractJSONByteStringField(Self.codexJSONFieldCwd, from: bytes, in: payloadRange, atDepth: 1))
+ }
+
private static func codexTotals(
from bytes: UnsafeBufferPointer,
in objectRange: Range?) -> CostUsageCodexTotals?
@@ -1122,7 +1235,8 @@ enum CostUsageScanner {
Self.codexJSONFieldTimestamp,
from: rawBuffer,
in: objectRange,
- atDepth: 1)))
+ atDepth: 1),
+ projectPath: Self.codexProjectPath(from: rawBuffer, payloadRange: payloadRange)))
case "turn_context":
guard let payloadRange = Self.extractJSONByteObjectField(
@@ -1280,7 +1394,8 @@ enum CostUsageScanner {
?? obj["id"] as? String,
forkedFromId: Self.codexForkParentId(from: payload),
forkTimestamp: payload?["timestamp"] as? String
- ?? obj["timestamp"] as? String)
+ ?? obj["timestamp"] as? String,
+ projectPath: Self.normalizedCodexProjectPath(payload?["cwd"] as? String))
}
}
@@ -1507,6 +1622,7 @@ enum CostUsageScanner {
lastCodexTurnID: initialCodexTurnID,
sessionId: nil,
forkedFromId: nil,
+ projectPath: nil,
rows: [])
}
@@ -1528,6 +1644,7 @@ enum CostUsageScanner {
var previousTotals = initialTotals
var sessionId: String?
var forkedFromId: String?
+ var projectPath: String?
var inheritedTotals: CostUsageCodexTotals?
var remainingInheritedTotals: CostUsageCodexTotals?
var forkBaselineResolved = false
@@ -1577,6 +1694,9 @@ enum CostUsageScanner {
if forkedFromId == nil {
forkedFromId = metadata.forkedFromId
}
+ if projectPath == nil {
+ projectPath = metadata.projectPath
+ }
if let forkedFromId {
try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "")
}
@@ -1798,6 +1918,7 @@ enum CostUsageScanner {
{
sessionId = metadata.sessionId
forkedFromId = metadata.forkedFromId
+ projectPath = metadata.projectPath
if let forkedFromId = metadata.forkedFromId,
inheritedTotals == nil
{
@@ -1866,6 +1987,9 @@ enum CostUsageScanner {
if forkedFromId == nil {
forkedFromId = Self.codexForkParentId(from: payload)
}
+ if projectPath == nil {
+ projectPath = Self.normalizedCodexProjectPath(payload?["cwd"] as? String)
+ }
if let forkedFromId {
let forkedAt = payload?["timestamp"] as? String
?? obj["timestamp"] as? String
@@ -2137,6 +2261,7 @@ enum CostUsageScanner {
lastCodexTurnID: currentTurnID,
sessionId: sessionId,
forkedFromId: forkedFromId,
+ projectPath: projectPath,
rows: rows)
}
@@ -2188,6 +2313,7 @@ enum CostUsageScanner {
let rootsChanged = cache.roots != rootsFingerprint
let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache)
let needsCostCacheMigration = cache.files.values.contains { Self.needsCodexCostCache($0, range: range) }
+ let needsProjectMetadataMigration = cache.codexProjectMetadataVersion != Self.codexProjectMetadataVersion
let modelsDevLoad = ModelsDevCache.load(now: now, cacheRoot: options.cacheRoot)
let modelsDevCatalog = modelsDevLoad.artifact?.catalog
let codexPricingKey = Self.codexPricingKey(modelsDevArtifact: modelsDevLoad.artifact)
@@ -2206,6 +2332,7 @@ enum CostUsageScanner {
|| windowExpanded
|| rootsChanged
|| needsCostCacheMigration
+ || needsProjectMetadataMigration
|| needsTurnIDCacheMigration
|| pricingChanged
|| priorityMetadataChanged
@@ -2236,6 +2363,7 @@ enum CostUsageScanner {
|| windowExpanded
|| rootsChanged
|| needsCostCacheMigration
+ || needsProjectMetadataMigration
|| needsTurnIDCacheMigration
|| pricingChanged
|| priorityMetadataChanged
@@ -2251,6 +2379,7 @@ enum CostUsageScanner {
rootsChanged: rootsChanged,
windowExpanded: windowExpanded,
needsCostCacheMigration: needsCostCacheMigration,
+ needsProjectMetadataMigration: needsProjectMetadataMigration,
modelsDevCatalog: modelsDevCatalog,
codexPricingKey: codexPricingKey,
codexPriorityMetadataKey: codexPriorityMetadataKey,
@@ -2342,23 +2471,20 @@ enum CostUsageScanner {
let resources = CodexScanResources(
fileIndex: fileIndex,
inheritedResolver: inheritedResolver,
+ projectPathResolver: CodexCanonicalProjectPathResolver(),
modelsDevCatalog: plan.modelsDevCatalog,
modelsDevCacheRoot: options.cacheRoot,
priorityTurns: plan.priorityTurns)
+ let scanContext = Self.codexFileScanContext(
+ range: range,
+ options: options,
+ plan: plan,
+ resources: resources,
+ checkCancellation: checkCancellation)
for fileURL in files {
try Self.scanCodexFile(
fileURL: fileURL,
- context: CodexFileScanContext(
- range: range,
- forceFullScan: options
- .forceRescan || plan.windowExpanded || plan.pricingChanged || plan.priorityMetadataChanged,
- dropDeferredCodexRows: options.forceRescan || plan.pricingChanged || plan
- .priorityMetadataChanged
- || plan.needsTurnIDCacheMigration,
- requiresTurnIDCache: plan.needsTurnIDCacheMigration,
- changedPriorityTurnIDs: plan.changedPriorityTurnIDs,
- resources: resources,
- checkCancellation: checkCancellation),
+ context: scanContext,
cache: &cache,
state: &scanState)
}
@@ -2370,6 +2496,7 @@ enum CostUsageScanner {
isForceRescan: options.forceRescan)
let shouldDropAllUnscannedFiles = options.forceRescan || plan.rootsChanged || cache.files.isEmpty
+ || plan.needsProjectMetadataMigration
for key in cache.files.keys where !filePathsInScan.contains(key) {
guard let old = cache.files[key] else { continue }
let shouldDrop = shouldDropAllUnscannedFiles ||
@@ -2393,7 +2520,7 @@ enum CostUsageScanner {
}
let shouldRetainWiderWindow = !options.forceRescan && !plan.pricingChanged && !plan
- .priorityMetadataChanged && !plan.needsTurnIDCacheMigration
+ .priorityMetadataChanged && !plan.needsTurnIDCacheMigration && !plan.needsProjectMetadataMigration
let retainedSinceKey = shouldRetainWiderWindow
? [cachedSinceKey, range.scanSinceKey].compactMap(\.self).min() ?? range.scanSinceKey
: range.scanSinceKey
@@ -2406,6 +2533,7 @@ enum CostUsageScanner {
cache.scanUntilKey = retainedUntilKey
cache.codexPricingKey = plan.codexPricingKey
cache.codexPriorityMetadataKey = plan.codexPriorityMetadataKey
+ cache.codexProjectMetadataVersion = Self.codexProjectMetadataVersion
if plan.hasPriorityMetadata {
cache.codexPriorityTurnKeys = Self.mergePriorityTurnKeys(
existing: shouldRetainWiderWindow ? cache.codexPriorityTurnKeys : nil,
@@ -2432,6 +2560,25 @@ enum CostUsageScanner {
modelsDevCacheRoot: options.cacheRoot,
priorityTurns: plan.priorityTurns)
}
+
+ private static func codexFileScanContext(
+ range: CostUsageDayRange,
+ options: Options,
+ plan: CodexRefreshPlan,
+ resources: CodexScanResources,
+ checkCancellation: CancellationCheck?) -> CodexFileScanContext
+ {
+ CodexFileScanContext(
+ range: range,
+ forceFullScan: options.forceRescan || plan.windowExpanded || plan.pricingChanged
+ || plan.priorityMetadataChanged || plan.needsProjectMetadataMigration,
+ dropDeferredCodexRows: options.forceRescan || plan.pricingChanged || plan.priorityMetadataChanged
+ || plan.needsTurnIDCacheMigration,
+ requiresTurnIDCache: plan.needsTurnIDCacheMigration,
+ changedPriorityTurnIDs: plan.changedPriorityTurnIDs,
+ resources: resources,
+ checkCancellation: checkCancellation)
+ }
}
// swiftlint:enable type_body_length
diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift
index 80f1d3065d..5f68d59ce8 100644
--- a/Sources/CodexBarCore/WidgetSnapshot.swift
+++ b/Sources/CodexBarCore/WidgetSnapshot.swift
@@ -5,11 +5,13 @@ public struct WidgetSnapshot: Codable, Sendable {
public let id: String
public let title: String
public let percentLeft: Double?
+ public let window: RateWindow?
- public init(id: String, title: String, percentLeft: Double?) {
+ public init(id: String, title: String, percentLeft: Double?, window: RateWindow? = nil) {
self.id = id
self.title = title
self.percentLeft = percentLeft
+ self.window = window
}
}
@@ -24,6 +26,7 @@ public struct WidgetSnapshot: Codable, Sendable {
public let codeReviewRemainingPercent: Double?
public let tokenUsage: TokenUsageSummary?
public let dailyUsage: [DailyUsagePoint]
+ public let providerCost: ProviderCostSnapshot?
public init(
provider: UsageProvider,
@@ -35,7 +38,8 @@ public struct WidgetSnapshot: Codable, Sendable {
creditsRemaining: Double?,
codeReviewRemainingPercent: Double?,
tokenUsage: TokenUsageSummary?,
- dailyUsage: [DailyUsagePoint])
+ dailyUsage: [DailyUsagePoint],
+ providerCost: ProviderCostSnapshot? = nil)
{
self.provider = provider
self.updatedAt = updatedAt
@@ -47,6 +51,7 @@ public struct WidgetSnapshot: Codable, Sendable {
self.codeReviewRemainingPercent = codeReviewRemainingPercent
self.tokenUsage = tokenUsage
self.dailyUsage = dailyUsage
+ self.providerCost = providerCost
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 98b828b550..df74b23d45 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -12,10 +12,13 @@ enum ProviderChoice: String, AppEnum {
case antigravity
case zai
case copilot
+ case devin
case minimax
case kilo
case opencode
case opencodego
+ case mistral
+ case kimi
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
@@ -28,10 +31,13 @@ enum ProviderChoice: String, AppEnum {
.antigravity: DisplayRepresentation(title: "Antigravity"),
.zai: DisplayRepresentation(title: "z.ai"),
.copilot: DisplayRepresentation(title: "Copilot"),
+ .devin: DisplayRepresentation(title: "Devin"),
.minimax: DisplayRepresentation(title: "MiniMax"),
.kilo: DisplayRepresentation(title: "Kilo"),
.opencode: DisplayRepresentation(title: "OpenCode"),
.opencodego: DisplayRepresentation(title: "OpenCode Go"),
+ .mistral: DisplayRepresentation(title: "Mistral"),
+ .kimi: DisplayRepresentation(title: "Kimi"),
]
var provider: UsageProvider {
@@ -44,10 +50,13 @@ enum ProviderChoice: String, AppEnum {
case .antigravity: .antigravity
case .zai: .zai
case .copilot: .copilot
+ case .devin: .devin
case .minimax: .minimax
case .kilo: .kilo
case .opencode: .opencode
case .opencodego: .opencodego
+ case .mistral: .mistral
+ case .kimi: .kimi
}
}
@@ -68,7 +77,7 @@ enum ProviderChoice: String, AppEnum {
case .zai: self = .zai
case .factory: return nil // Factory not yet supported in widgets
case .copilot: self = .copilot
- case .devin: return nil // Devin not yet supported in widgets
+ case .devin: self = .devin
case .minimax: self = .minimax
case .manus: return nil // Manus not yet supported in widgets
case .vertexai: return nil // Vertex AI not yet supported in widgets
@@ -76,7 +85,7 @@ enum ProviderChoice: String, AppEnum {
case .kiro: return nil // Kiro not yet supported in widgets
case .augment: return nil // Augment not yet supported in widgets
case .jetbrains: return nil // JetBrains not yet supported in widgets
- case .kimi: return nil // Kimi not yet supported in widgets
+ case .kimi: self = .kimi
case .kimik2: return nil // Kimi K2 not yet supported in widgets
case .moonshot: return nil // Moonshot not yet supported in widgets
case .amp: return nil // Amp not yet supported in widgets
@@ -85,6 +94,7 @@ enum ProviderChoice: String, AppEnum {
case .synthetic: return nil // Synthetic not yet supported in widgets
case .openrouter: return nil // OpenRouter not yet supported in widgets
case .crossmodel: return nil // CrossModel not yet supported in widgets
+ case .clawrouter: return nil // ClawRouter not yet supported in widgets
case .elevenlabs: return nil // ElevenLabs not yet supported in widgets
case .warp: return nil // Warp not yet supported in widgets
case .windsurf: return nil // Windsurf not yet supported in widgets
@@ -93,7 +103,7 @@ enum ProviderChoice: String, AppEnum {
case .doubao: return nil // Doubao not yet supported in widgets
case .sakana: return nil // Sakana AI not yet supported in widgets
case .abacus: return nil // Abacus AI not yet supported in widgets
- case .mistral: return nil // Mistral not yet supported in widgets
+ case .mistral: self = .mistral
case .deepseek: return nil // DeepSeek not yet supported in widgets
case .codebuff: return nil // Codebuff not yet supported in widgets
case .crof: return nil // Crof not yet supported in widgets
@@ -218,8 +228,9 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider {
{
let provider = configuration.provider.provider
let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
- let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot)
- let refresh = Date().addingTimeInterval(30 * 60)
+ let now = Date()
+ let entry = CodexBarWidgetEntry(date: now, provider: provider, snapshot: snapshot)
+ let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: provider, now: now)
return Timeline(entries: [entry], policy: .after(refresh))
}
}
@@ -241,7 +252,10 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
let entry = self.makeEntry()
- let refresh = Date().addingTimeInterval(30 * 60)
+ let refresh = BurnDownRefreshSchedule.nextRefresh(
+ snapshot: entry.snapshot,
+ provider: entry.provider,
+ now: entry.date)
completion(Timeline(entries: [entry], policy: .after(refresh)))
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index dcae1d7bbb..471b7f4367 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -164,7 +164,7 @@ private struct CompactMetricView: View {
let metric: CompactMetric
var body: some View {
- let display = self.display
+ let display = CompactMetricFormatter.display(for: self.entry, metric: self.metric)
VStack(alignment: .leading, spacing: 8) {
HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
VStack(alignment: .leading, spacing: 2) {
@@ -183,26 +183,40 @@ private struct CompactMetricView: View {
}
.padding(12)
}
+}
+
+struct CompactMetricDisplay: Equatable {
+ let value: String
+ let label: String
+ let detail: String?
+}
- private var display: (value: String, label: String, detail: String?) {
- switch self.metric {
+enum CompactMetricFormatter {
+ static func display(for entry: WidgetSnapshot.ProviderEntry, metric: CompactMetric) -> CompactMetricDisplay {
+ switch metric {
case .credits:
- let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—"
- return (value, "Credits left", nil)
+ if let cost = WidgetBalanceFormatter.extraUsageCost(for: entry) {
+ return CompactMetricDisplay(
+ value: WidgetFormat.currency(cost.used, code: cost.currencyCode),
+ label: "Extra usage balance",
+ detail: nil)
+ }
+ let value = entry.creditsRemaining.map(WidgetFormat.credits) ?? "—"
+ return CompactMetricDisplay(value: value, label: "Credits left", detail: nil)
case .todayCost:
- let value = self.entry.tokenUsage.map { token in
+ let value = entry.tokenUsage.map { token in
token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—"
} ?? "—"
- let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount)
- let label = self.entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost"
- return (value, label, detail)
+ let detail = entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount)
+ let label = entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost"
+ return CompactMetricDisplay(value: value, label: label, detail: detail)
case .last30DaysCost:
- let value = self.entry.tokenUsage.map { token in
+ let value = entry.tokenUsage.map { token in
token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—"
} ?? "—"
- let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount)
- let label = self.entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost"
- return (value, label, detail)
+ let detail = entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount)
+ let label = entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost"
+ return CompactMetricDisplay(value: value, label: label, detail: detail)
}
}
}
@@ -300,6 +314,7 @@ private struct ProviderSwitchChip: View {
case .synthetic: "Synthetic"
case .openrouter: "OpenRouter"
case .crossmodel: "CrossModel"
+ case .clawrouter: "ClawRouter"
case .elevenlabs: "ElevenLabs"
case .warp: "Warp"
case .windsurf: "Windsurf"
@@ -357,6 +372,9 @@ private struct SwitcherSmallUsageView: View {
tokens: token.sessionTokens,
currencyCode: token.currencyCode))
}
+ if let balance = extraUsageBalanceLine(for: entry) {
+ balance
+ }
}
}
}
@@ -386,6 +404,9 @@ private struct SwitcherMediumUsageView: View {
tokens: token.sessionTokens,
currencyCode: token.currencyCode))
}
+ if let balance = extraUsageBalanceLine(for: entry) {
+ balance
+ }
}
}
}
@@ -426,7 +447,13 @@ private struct SwitcherLargeUsageView: View {
currencyCode: token.currencyCode))
}
}
- UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider))
+ if let balance = extraUsageBalanceLine(for: entry) {
+ balance
+ }
+ UsageHistoryChart(
+ points: self.entry.dailyUsage,
+ color: WidgetColors.color(for: self.entry.provider),
+ currencyCode: self.entry.tokenUsage?.currencyCode)
.frame(height: 50)
}
}
@@ -461,6 +488,9 @@ private struct SmallUsageView: View {
tokens: token.sessionTokens,
currencyCode: token.currencyCode))
}
+ if let balance = extraUsageBalanceLine(for: entry) {
+ balance
+ }
}
.padding(12)
}
@@ -492,6 +522,9 @@ private struct MediumUsageView: View {
tokens: token.sessionTokens,
currencyCode: token.currencyCode))
}
+ if let balance = extraUsageBalanceLine(for: entry) {
+ balance
+ }
}
.padding(12)
}
@@ -534,7 +567,13 @@ private struct LargeUsageView: View {
currencyCode: token.currencyCode))
}
}
- UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider))
+ if let balance = extraUsageBalanceLine(for: entry) {
+ balance
+ }
+ UsageHistoryChart(
+ points: self.entry.dailyUsage,
+ color: WidgetColors.color(for: self.entry.provider),
+ currencyCode: self.entry.tokenUsage?.currencyCode)
.frame(height: 50)
}
.padding(12)
@@ -552,11 +591,13 @@ struct WidgetUsageRow: Identifiable, Equatable {
}
static func smallWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? {
- self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2)
+ if entry.provider == .kimi { return 3 }
+ return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2)
}
static func mediumWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? {
- self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3)
+ if entry.provider == .kimi { return 3 }
+ return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3)
}
private static func antigravityQuotaSummaryRowLimit(
@@ -573,12 +614,36 @@ struct WidgetUsageRow: Identifiable, Equatable {
return limit
}
- static func rows(for entry: WidgetSnapshot.ProviderEntry, limit: Int? = nil) -> [WidgetUsageRow] {
+ static func rows(
+ for entry: WidgetSnapshot.ProviderEntry,
+ limit: Int? = nil,
+ now: Date = Date()) -> [WidgetUsageRow]
+ {
let rows: [WidgetUsageRow]
if let usageRows = entry.usageRows {
- rows = usageRows.map { row in
- WidgetUsageRow(id: row.id, title: row.title, percentLeft: row.percentLeft)
+ let resolvedSnapshots = usageRows.map { row in
+ guard row.window == nil,
+ let window = self.legacyCodexRateWindow(for: row.id, entry: entry)
+ else {
+ return row
+ }
+ return WidgetSnapshot.WidgetUsageRowSnapshot(
+ id: row.id,
+ title: row.title,
+ percentLeft: row.percentLeft,
+ window: window)
}
+ let sourceRows = resolvedSnapshots.map { row in
+ WidgetUsageRow(
+ id: row.id,
+ title: row.title,
+ percentLeft: row.window?.remainingPercent ?? row.percentLeft)
+ }
+ rows = self.applyingCodexWeeklyCap(
+ sourceRows,
+ snapshots: resolvedSnapshots,
+ provider: entry.provider,
+ now: now)
} else {
let metadata = ProviderDefaults.metadata[entry.provider]
var defaultRows = [
@@ -631,6 +696,45 @@ struct WidgetUsageRow: Identifiable, Equatable {
return Array(rows.prefix(max(0, limit)))
}
+ private static func applyingCodexWeeklyCap(
+ _ rows: [WidgetUsageRow],
+ snapshots: [WidgetSnapshot.WidgetUsageRowSnapshot],
+ provider: UsageProvider,
+ now: Date) -> [WidgetUsageRow]
+ {
+ guard provider == .codex,
+ let weekly = snapshots.first(where: { $0.id == "weekly" })?.window,
+ weekly.remainingPercent <= 0,
+ weekly.resetsAt.map({ $0 > now }) ?? true
+ else {
+ return rows
+ }
+ return rows.map { row in
+ guard row.id == "session" else { return row }
+ return WidgetUsageRow(id: row.id, title: row.title, percentLeft: 0)
+ }
+ }
+
+ private static func legacyCodexRateWindow(
+ for rowID: String,
+ entry: WidgetSnapshot.ProviderEntry) -> RateWindow?
+ {
+ guard entry.provider == .codex else { return nil }
+ let candidates = [(entry.primary, "session"), (entry.secondary, "weekly")]
+ for (window, fallbackID) in candidates {
+ guard let window else { continue }
+ let classifiedID = switch window.windowMinutes {
+ case 300: "session"
+ case 10080: "weekly"
+ default: fallbackID
+ }
+ if classifiedID == rowID {
+ return window
+ }
+ }
+ return nil
+ }
+
static func compactTokenUsage(
for entry: WidgetSnapshot.ProviderEntry) -> WidgetSnapshot.TokenUsageSummary?
{
@@ -691,7 +795,10 @@ private struct HistoryView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
- UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider))
+ UsageHistoryChart(
+ points: self.entry.dailyUsage,
+ color: WidgetColors.color(for: self.entry.provider),
+ currencyCode: self.entry.tokenUsage?.currencyCode)
.frame(height: self.isLarge ? 90 : 60)
if let token = entry.tokenUsage {
ValueLine(
@@ -776,27 +883,50 @@ private struct ValueLine: View {
private struct UsageHistoryChart: View {
let points: [WidgetSnapshot.DailyUsagePoint]
let color: Color
+ let currencyCode: String?
var body: some View {
+ let isCostMode = UsageHistoryChartMode.isCostMode(self.points)
let values = self.points.map { point -> Double in
- if let cost = point.costUSD { return cost }
+ if isCostMode { return point.costUSD ?? 0 }
return Double(point.totalTokens ?? 0)
}
- let maxValue = values.max() ?? 0
- HStack(alignment: .bottom, spacing: 2) {
- ForEach(values.indices, id: \.self) { index in
- let value = values[index]
- let height = maxValue > 0 ? CGFloat(value / maxValue) : 0
- RoundedRectangle(cornerRadius: 2)
- .fill(self.color.opacity(0.85))
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: height, anchor: .bottom)
- .animation(.easeOut(duration: 0.2), value: height)
+ let scale = UsageChartScale(values: values)
+ VStack(alignment: .trailing, spacing: 2) {
+ if isCostMode,
+ let currencyCode = self.currencyCode,
+ scale.maximum > 0
+ {
+ Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ .lineLimit(1)
+ .allowsTightening(true)
+ }
+ GeometryReader { geometry in
+ HStack(alignment: .bottom, spacing: 2) {
+ ForEach(values.indices, id: \.self) { index in
+ let fraction = scale.fraction(for: values[index])
+ RoundedRectangle(cornerRadius: 2)
+ .fill(self.color.opacity(0.85))
+ .frame(maxWidth: .infinity)
+ .frame(height: max(fraction > 0 ? 2 : 0, CGFloat(fraction) * geometry.size.height))
+ .animation(.easeOut(duration: 0.2), value: fraction)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
}
}
}
+enum UsageHistoryChartMode {
+ static func isCostMode(_ points: [WidgetSnapshot.DailyUsagePoint]) -> Bool {
+ !points.isEmpty && points.allSatisfy { $0.costUSD != nil }
+ }
+}
+
enum WidgetColors {
// swiftlint:disable:next cyclomatic_complexity
static func color(for provider: UsageProvider) -> Color {
@@ -861,6 +991,8 @@ enum WidgetColors {
Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
case .crossmodel:
Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) // CrossModel purple
+ case .clawrouter:
+ Color(red: 89 / 255, green: 110 / 255, blue: 246 / 255)
case .elevenlabs:
Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255)
case .warp:
@@ -906,7 +1038,7 @@ enum WidgetColors {
case .deepgram:
Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255)
case .poe:
- Color(red: 0.15, green: 0.68, blue: 0.38)
+ Color(red: 93 / 255, green: 92 / 255, blue: 222 / 255) // Poe purple
case .chutes:
Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255)
case .zed:
@@ -915,6 +1047,33 @@ enum WidgetColors {
}
}
+struct WidgetBalanceLine: Equatable {
+ let title: String
+ let value: String
+}
+
+enum WidgetBalanceFormatter {
+ static func extraUsageCost(for entry: WidgetSnapshot.ProviderEntry) -> ProviderCostSnapshot? {
+ guard entry.provider == .devin,
+ let cost = entry.providerCost,
+ cost.period == "Extra usage balance"
+ else { return nil }
+ return cost
+ }
+
+ static func extraUsageBalance(for entry: WidgetSnapshot.ProviderEntry) -> WidgetBalanceLine? {
+ guard let cost = self.extraUsageCost(for: entry) else { return nil }
+ return WidgetBalanceLine(
+ title: "Extra usage",
+ value: "Balance: \(WidgetFormat.currency(cost.used, code: cost.currencyCode))")
+ }
+}
+
+private func extraUsageBalanceLine(for entry: WidgetSnapshot.ProviderEntry) -> ValueLine? {
+ guard let line = WidgetBalanceFormatter.extraUsageBalance(for: entry) else { return nil }
+ return ValueLine(title: line.title, value: line.value)
+}
+
enum WidgetFormat {
static func percent(_ value: Double?) -> String {
guard let value else { return "—" }
diff --git a/Tests/CodexBarTests/AgentSessionJSONTests.swift b/Tests/CodexBarTests/AgentSessionJSONTests.swift
new file mode 100644
index 0000000000..4afdf1b605
--- /dev/null
+++ b/Tests/CodexBarTests/AgentSessionJSONTests.swift
@@ -0,0 +1,33 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct AgentSessionJSONTests {
+ @Test
+ func `sessions json round trip preserves stable schema`() throws {
+ let session = AgentSession(
+ id: "fixture-session",
+ provider: .codex,
+ source: .ide,
+ state: .active,
+ pid: 42,
+ cwd: "/tmp/project",
+ projectName: "project",
+ startedAt: Date(timeIntervalSince1970: 100),
+ lastActivityAt: Date(timeIntervalSince1970: 200),
+ transcriptPath: "/tmp/rollout.jsonl",
+ host: "local-mac")
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode([session])
+ let object = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]])
+ let keys = try #require(object.first).keys
+ #expect(Set(keys) == [
+ "id", "provider", "source", "state", "pid", "cwd", "projectName", "startedAt",
+ "lastActivityAt", "transcriptPath", "host",
+ ])
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ #expect(try decoder.decode([AgentSession].self, from: data) == [session])
+ }
+}
diff --git a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift
new file mode 100644
index 0000000000..24a7dc20a0
--- /dev/null
+++ b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift
@@ -0,0 +1,90 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+@MainActor
+struct AgentSessionMenuDescriptorTests {
+ @Test
+ func `session section counts groups and renders unreachable hosts`() {
+ let now = Date(timeIntervalSince1970: 1000)
+ let local = Self.session(id: "local", host: "local-mac", activity: now.addingTimeInterval(-60))
+ let remote = Self.session(id: "remote", host: "clawmac", activity: now.addingTimeInterval(-720))
+ let section = MenuDescriptor.agentSessionsSection(
+ localSessions: [local],
+ remoteHosts: [
+ RemoteSessionHostResult(host: "clawmac", sessions: [remote], error: nil),
+ RemoteSessionHostResult(host: "offline", sessions: [], error: "Connection timed out"),
+ ],
+ now: now)
+
+ guard case let .text(header, .headline) = section.entries[0] else {
+ Issue.record("Expected session headline")
+ return
+ }
+ #expect(header == "Agent Sessions (2)")
+ guard case let .action(localTitle, .focusAgentSession(_, remoteHost)) = section.entries[1] else {
+ Issue.record("Expected local session action")
+ return
+ }
+ #expect(localTitle.contains("alpha — codex · cli · 1m"))
+ #expect(remoteHost == nil)
+ guard case let .text(remoteGroup, .secondary) = section.entries[2] else {
+ Issue.record("Expected remote group")
+ return
+ }
+ #expect(remoteGroup == "clawmac — 1")
+ guard case let .unavailable(title, tooltip) = section.entries[4] else {
+ Issue.record("Expected unreachable host")
+ return
+ }
+ #expect(title == "offline — unreachable")
+ #expect(tooltip == "Connection timed out")
+ }
+
+ @Test
+ func `reachable empty remote host keeps zero count section actionable`() {
+ let section = MenuDescriptor.agentSessionsSection(
+ localSessions: [],
+ remoteHosts: [RemoteSessionHostResult(host: "clawmac", sessions: [], error: nil)])
+
+ #expect(section.entries.contains { entry in
+ guard case let .unavailable(title, _) = entry else { return false }
+ return title == "No agent sessions found"
+ })
+ }
+
+ @Test
+ func `remote refresh gate retries changed settings and rejects stale result`() throws {
+ var gate = AgentSessionRemoteRefreshGate()
+ let initialGenerationCandidate = gate.begin()
+ let initialGeneration = try #require(initialGenerationCandidate)
+ gate.settingsDidChange()
+ #expect(gate.begin() == nil)
+
+ let staleOutcome = gate.finish(generation: initialGeneration)
+ #expect(!staleOutcome.shouldPublish)
+ #expect(staleOutcome.shouldRetry)
+
+ let currentGenerationCandidate = gate.begin()
+ let currentGeneration = try #require(currentGenerationCandidate)
+ let currentOutcome = gate.finish(generation: currentGeneration)
+ #expect(currentOutcome.shouldPublish)
+ #expect(!currentOutcome.shouldRetry)
+ }
+
+ private static func session(id: String, host: String, activity: Date) -> AgentSession {
+ AgentSession(
+ id: id,
+ provider: .codex,
+ source: .cli,
+ state: .active,
+ pid: 42,
+ cwd: "/Users/test/alpha",
+ projectName: "alpha",
+ startedAt: nil,
+ lastActivityAt: activity,
+ transcriptPath: nil,
+ host: host)
+ }
+}
diff --git a/Tests/CodexBarTests/AgentSessionParserTests.swift b/Tests/CodexBarTests/AgentSessionParserTests.swift
new file mode 100644
index 0000000000..9f14f648e2
--- /dev/null
+++ b/Tests/CodexBarTests/AgentSessionParserTests.swift
@@ -0,0 +1,74 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct AgentSessionParserTests {
+ @Test
+ func `ps parser deduplicates desktop wrapper and excludes app server and helpers`() throws {
+ let output = try Self.fixtureString("agent-sessions-ps", extension: "txt")
+ let records = AgentPSOutputParser.parse(output)
+ let agents = AgentPSOutputParser.agentProcesses(from: records)
+
+ #expect(records.count == 9)
+ #expect(agents.map(\ .pid) == [102, 201])
+ #expect(AgentPSOutputParser.provider(for: agents[0]) == .claude)
+ #expect(AgentPSOutputParser.source(for: agents[0]) == .desktopApp)
+ #expect(AgentPSOutputParser.provider(for: agents[1]) == .codex)
+ #expect(agents[1].command.hasSuffix("strange argv here"))
+ #expect(AgentPSOutputParser.hasCodexAppServer(in: records))
+ }
+
+ @Test
+ func `lsof parser maps batched cwd records`() throws {
+ let output = try Self.fixtureString("agent-sessions-lsof", extension: "txt")
+ let paths = LSOFCWDOutputParser.parse(output)
+
+ #expect(paths[102] == "/Users/test/Projects/alpha")
+ #expect(paths[201] == "/Users/test/Projects/project with spaces")
+ }
+
+ @Test
+ func `same cwd processes remain uncorrelated when ownership is ambiguous`() {
+ let olderStart = Date(timeIntervalSince1970: 100)
+ let newerStart = Date(timeIntervalSince1970: 200)
+ let older = AgentProcessRecord(pid: 10, ppid: 1, startedAt: olderStart, command: "claude")
+ let newer = AgentProcessRecord(pid: 20, ppid: 1, startedAt: newerStart, command: "claude")
+ let olderTranscript = ClaudeSessionProjectMapper.Transcript(
+ url: URL(fileURLWithPath: "/tmp/older.jsonl"),
+ modifiedAt: Date(timeIntervalSince1970: 150))
+ let newerTranscript = ClaudeSessionProjectMapper.Transcript(
+ url: URL(fileURLWithPath: "/tmp/newer.jsonl"),
+ modifiedAt: Date(timeIntervalSince1970: 250))
+
+ let assignments = AgentSessionCorrelation.assignClaudeTranscripts(
+ processes: [older, newer],
+ cwdByPID: [10: "/project", 20: "/project"],
+ transcriptsByCWD: ["/project": [newerTranscript, olderTranscript]])
+
+ #expect(assignments.isEmpty)
+ }
+
+ @Test
+ func `newest process sorts first for rollout correlation`() {
+ let older = AgentProcessRecord(
+ pid: 10,
+ ppid: 1,
+ startedAt: Date(timeIntervalSince1970: 100),
+ command: "codex exec")
+ let newer = AgentProcessRecord(
+ pid: 20,
+ ppid: 1,
+ startedAt: Date(timeIntervalSince1970: 200),
+ command: "codex exec")
+
+ #expect(AgentSessionCorrelation.newestProcessesFirst([older, newer]).map(\ .pid) == [20, 10])
+ }
+
+ static func fixtureURL(_ name: String, extension fileExtension: String) throws -> URL {
+ try #require(Bundle.module.url(forResource: name, withExtension: fileExtension, subdirectory: "Fixtures"))
+ }
+
+ static func fixtureString(_ name: String, extension fileExtension: String) throws -> String {
+ try String(contentsOf: self.fixtureURL(name, extension: fileExtension), encoding: .utf8)
+ }
+}
diff --git a/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift
new file mode 100644
index 0000000000..e9401ffb03
--- /dev/null
+++ b/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift
@@ -0,0 +1,27 @@
+import CodexBarCore
+import Testing
+@testable import CodexBar
+
+@MainActor
+@Suite(.serialized)
+struct AlibabaTokenPlanDashboardActionTests {
+ @Test
+ func `dashboard action follows selected region`() {
+ let settings = testSettingsStore(suiteName: "AlibabaTokenPlanDashboardActionTests")
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = false
+ settings.alibabaTokenPlanAPIRegion = .chinaMainland
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(
+ fetcher: fetcher,
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+
+ withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in
+ #expect(controller.dashboardURL(for: .alibabatokenplan) ==
+ AlibabaTokenPlanAPIRegion.chinaMainland.dashboardURL)
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift
index fb17b4d0fa..e6233e0ca2 100644
--- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift
+++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift
@@ -47,13 +47,21 @@ struct AlibabaTokenPlanSettingsReaderTests {
])
#expect(httpHost == nil)
- #expect(httpsHost == "https://dashboard.token-plan.test")
+ #expect(httpsHost == "dashboard.token-plan.test")
#expect(bareHost == "dashboard.token-plan.test")
}
@Test
func `default quota URL targets subscription summary API`() {
let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL
+ #expect(url.host == "modelstudio.console.alibabacloud.com")
+ #expect(url.absoluteString.contains("GetSubscriptionSummary"))
+ #expect(url.absoluteString.contains("BssOpenAPI-V3"))
+ }
+
+ @Test
+ func `default quota URL for china mainland targets bailian`() {
+ let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainland)
#expect(url.host == "bailian.console.aliyun.com")
#expect(url.absoluteString.contains("GetSubscriptionSummary"))
#expect(url.absoluteString.contains("BssOpenAPI-V3"))
@@ -63,6 +71,26 @@ struct AlibabaTokenPlanSettingsReaderTests {
struct AlibabaTokenPlanCookieHeaderTests {
@Test
func `builds URL scoped headers for API and dashboard`() throws {
+ let cookies = [
+ self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"),
+ self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"),
+ self.cookie(name: "sec_token", value: "shared", domain: ".console.alibabacloud.com"),
+ self.cookie(name: "sec_token", value: "dashboard", domain: "modelstudio.console.alibabacloud.com"),
+ self.cookie(name: "bailian_only", value: "bailian", domain: "bailian.console.aliyun.com"),
+ ]
+
+ let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies))
+
+ #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket"))
+ #expect(headers.apiCookieHeader.contains("login_current_pk=account"))
+ #expect(headers.apiCookieHeader.contains("sec_token=dashboard"))
+ #expect(!headers.apiCookieHeader.contains("bailian_only=bailian"))
+ #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard"))
+ #expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian"))
+ }
+
+ @Test
+ func `builds URL scoped headers for china mainland region`() throws {
let cookies = [
self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"),
self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"),
@@ -71,7 +99,7 @@ struct AlibabaTokenPlanCookieHeaderTests {
self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"),
]
- let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies))
+ let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies, region: .chinaMainland))
#expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket"))
#expect(headers.apiCookieHeader.contains("login_current_pk=account"))
@@ -101,8 +129,11 @@ struct AlibabaTokenPlanCookieHeaderTests {
self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"),
self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"),
self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"),
- self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"),
- self.cookie(name: "prod_dashboard_only", value: "prod-dashboard", domain: "bailian.console.aliyun.com"),
+ self.cookie(name: "prod_api_only", value: "prod-api", domain: "modelstudio.console.alibabacloud.com"),
+ self.cookie(
+ name: "prod_dashboard_only",
+ value: "prod-dashboard",
+ domain: "modelstudio.console.alibabacloud.com"),
]
let headers = try #require(AlibabaTokenPlanCookieHeader.headers(
@@ -346,11 +377,19 @@ struct AlibabaTokenPlanUsageParsingTests {
AlibabaTokenPlanStubURLProtocol.handler = nil
}
+ let hostOverride = "https://alibaba-token-plan.test:9443"
+ let environment: [String: String] = [
+ AlibabaTokenPlanSettingsReader.hostKey: hostOverride,
+ ]
+ let expectedReferer = AlibabaTokenPlanUsageFetcher.dashboardURL(
+ region: .international,
+ environment: environment).absoluteString
+
AlibabaTokenPlanStubURLProtocol.handler = { request in
guard let url = request.url else { throw URLError(.badURL) }
if url.host == "alibaba-token-plan.test",
- url.path == "/cn-beijing",
+ url.path == "/ap-southeast-1/",
request.httpMethod == "GET"
{
#expect(url.port == 9443)
@@ -378,15 +417,14 @@ struct AlibabaTokenPlanUsageParsingTests {
if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" {
#expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep")
- #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com")
- #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL
- .absoluteString)
+ #expect(request.value(forHTTPHeaderField: "Origin") == "https://modelstudio.console.alibabacloud.com")
+ #expect(request.value(forHTTPHeaderField: "Referer") == expectedReferer)
let body = Self.requestBodyString(from: request)
#expect(body.contains("sec_token=user-info-token"))
#expect(body.contains("GetSubscriptionSummary"))
#expect(body.contains("BssOpenAPI-V3"))
#expect(body.contains("ProductCode"))
- #expect(body.contains("sfm_tokenplanteams_dp_cn"))
+ #expect(body.contains("sfm_tokenplanteams_dp_intl"))
let json = """
{
"Success": true,
@@ -409,7 +447,7 @@ struct AlibabaTokenPlanUsageParsingTests {
let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage(
apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep",
dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep",
- environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test:9443"],
+ environment: environment,
session: session)
#expect(snapshot.planName == "TOKEN PLAN")
@@ -565,6 +603,13 @@ struct AlibabaTokenPlanWebStrategyTests {
}
}
+ private func clearCookieCaches() {
+ CookieHeaderCache.clear(provider: .alibabatokenplan)
+ for region in AlibabaTokenPlanAPIRegion.allCases {
+ CookieHeaderCache.clear(provider: .alibabatokenplan, scope: region.cookieCacheScope)
+ }
+ }
+
@Test
func `auto web strategy surfaces cookie import errors`() async throws {
let strategy = AlibabaTokenPlanWebFetchStrategy()
@@ -585,13 +630,14 @@ struct AlibabaTokenPlanWebStrategyTests {
claudeFetcher: StubClaudeFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0))
- CookieHeaderCache.clear(provider: .alibabatokenplan)
+ self.clearCookieCaches()
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
throw AlibabaCodingPlanSettingsError.missingCookie(
details: "macOS Keychain denied access to Chrome Safe Storage.")
}
defer {
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
+ self.clearCookieCaches()
}
#expect(await strategy.isAvailable(context))
@@ -630,35 +676,38 @@ struct AlibabaTokenPlanWebStrategyTests {
claudeFetcher: StubClaudeFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0))
- CookieHeaderCache.clear(provider: .alibabatokenplan)
+ self.clearCookieCaches()
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
AlibabaCodingPlanCookieImporter.SessionInfo(
cookies: [
- self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"),
- self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"),
- self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"),
+ self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"),
+ self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"),
self.cookie(
- name: "modelstudio_only",
- value: "modelstudio",
+ name: "dashboard_only",
+ value: "dashboard",
domain: "modelstudio.console.alibabacloud.com"),
- self.cookie(name: "alibabacloud_only", value: "cloud", domain: ".alibabacloud.com"),
+ self.cookie(
+ name: "bailian_only",
+ value: "bailian",
+ domain: "bailian.console.aliyun.com"),
+ self.cookie(name: "aliyun_only", value: "aliyun", domain: ".aliyun.com"),
],
sourceLabel: "Chrome Default")
}
defer {
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
- CookieHeaderCache.clear(provider: .alibabatokenplan)
+ self.clearCookieCaches()
}
let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false)
#expect(headers.apiCookieHeader == headers.dashboardCookieHeader)
#expect(headers.apiCookieHeader.contains("dashboard_only=dashboard"))
- #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio"))
- #expect(!headers.apiCookieHeader.contains("alibabacloud_only=cloud"))
+ #expect(!headers.apiCookieHeader.contains("bailian_only=bailian"))
+ #expect(!headers.apiCookieHeader.contains("aliyun_only=aliyun"))
#expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard"))
- #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio"))
- #expect(!headers.dashboardCookieHeader.contains("alibabacloud_only=cloud"))
+ #expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian"))
+ #expect(!headers.dashboardCookieHeader.contains("aliyun_only=aliyun"))
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import")
@@ -694,7 +743,7 @@ struct AlibabaTokenPlanWebStrategyTests {
claudeFetcher: StubClaudeFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0))
- CookieHeaderCache.clear(provider: .alibabatokenplan)
+ self.clearCookieCaches()
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
AlibabaCodingPlanCookieImporter.SessionInfo(
cookies: [
@@ -711,7 +760,7 @@ struct AlibabaTokenPlanWebStrategyTests {
}
defer {
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
- CookieHeaderCache.clear(provider: .alibabatokenplan)
+ self.clearCookieCaches()
}
let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false)
@@ -722,6 +771,109 @@ struct AlibabaTokenPlanWebStrategyTests {
#expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard"))
}
+ @Test
+ func `cached browser cookies stay isolated by gateway region`() throws {
+ self.clearCookieCaches()
+ defer { self.clearCookieCaches() }
+ CookieHeaderCache.store(
+ provider: .alibabatokenplan,
+ scope: AlibabaTokenPlanAPIRegion.international.cookieCacheScope,
+ cookieHeader: "login_aliyunid_ticket=intl-ticket; gateway=intl",
+ sourceLabel: "International fixture")
+ CookieHeaderCache.store(
+ provider: .alibabatokenplan,
+ scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope,
+ cookieHeader: "login_aliyunid_ticket=cn-ticket; gateway=cn",
+ sourceLabel: "China fixture")
+ AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
+ throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import")
+ }
+ defer { AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil }
+
+ let context = self.context(region: .international)
+ let international = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
+ context: context,
+ allowCached: true,
+ region: .international)
+ let china = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
+ context: context,
+ allowCached: true,
+ region: .chinaMainland)
+
+ #expect(international.apiCookieHeader.contains("gateway=intl"))
+ #expect(!international.apiCookieHeader.contains("gateway=cn"))
+ #expect(china.apiCookieHeader.contains("gateway=cn"))
+ #expect(!china.apiCookieHeader.contains("gateway=intl"))
+ }
+
+ @Test
+ func `legacy unscoped cache migrates only to China gateway`() throws {
+ self.clearCookieCaches()
+ defer {
+ AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
+ self.clearCookieCaches()
+ }
+ CookieHeaderCache.store(
+ provider: .alibabatokenplan,
+ cookieHeader: "login_aliyunid_ticket=legacy; gateway=legacy-cn",
+ sourceLabel: "Legacy fixture")
+ AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
+ AlibabaCodingPlanCookieImporter.SessionInfo(
+ cookies: [
+ self.cookie(
+ name: "login_aliyunid_ticket",
+ value: "intl",
+ domain: ".alibabacloud.com"),
+ self.cookie(
+ name: "gateway",
+ value: "intl",
+ domain: "modelstudio.console.alibabacloud.com"),
+ ],
+ sourceLabel: "International fixture")
+ }
+ let context = self.context(region: .international)
+
+ let international = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
+ context: context,
+ allowCached: true,
+ region: .international)
+ AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
+ throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected China import")
+ }
+ let china = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
+ context: context,
+ allowCached: true,
+ region: .chinaMainland)
+
+ #expect(international.apiCookieHeader.contains("gateway=intl"))
+ #expect(!international.apiCookieHeader.contains("gateway=legacy-cn"))
+ #expect(china.apiCookieHeader.contains("gateway=legacy-cn"))
+ #expect(CookieHeaderCache.load(provider: .alibabatokenplan) == nil)
+ #expect(CookieHeaderCache.load(
+ provider: .alibabatokenplan,
+ scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope) != nil)
+ }
+
+ private func context(region: AlibabaTokenPlanAPIRegion) -> ProviderFetchContext {
+ let settings = ProviderSettingsSnapshot.make(
+ alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil,
+ apiRegion: region))
+ return ProviderFetchContext(
+ runtime: .cli,
+ sourceMode: .web,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: [:],
+ settings: settings,
+ fetcher: UsageFetcher(environment: [:]),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0))
+ }
+
private func cookie(
name: String,
value: String,
diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift
index 150042c73c..14fb5c9282 100644
--- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift
@@ -29,6 +29,11 @@ struct AmpUsageFetcherTests {
"https://ampcode.com/api/internal?userDisplayBalanceInfo")
}
+ @Test
+ func `provider dashboard points to current usage page`() {
+ #expect(AmpProviderDescriptor.descriptor.metadata.dashboardURL == "https://ampcode.com/settings/usage")
+ }
+
@Test
func `web fallback requires browser import or a manual session cookie`() {
let disabled = ProviderSettingsSnapshot.AmpProviderSettings(cookieSource: .off, manualCookieHeader: nil)
diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift
index c8cf68d48a..8824497bb2 100644
--- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift
+++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift
@@ -49,8 +49,25 @@ struct BrowserCookieOrderStatusStringTests {
}
@Test
- func `opencode automatic cookies keep chrome only default`() {
- #expect(OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) == [.chrome])
+ func `opencode automatic cookies only use chrome and dia`() {
+ let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode)
+ #expect(order == ProviderDefaults.metadata[.opencode]?.browserCookieOrder)
+ #expect(order == ProviderBrowserCookieDefaults.opencodeCookieImportOrder)
+ #expect(order == [.chrome, .dia])
+ }
+
+ @Test
+ func `opencode automatic cookies bound keychain prompt labels to chrome and dia`() {
+ let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode)
+ let labels = order.flatMap(\.safeStorageLabels).map(\.service)
+
+ #expect(labels == ["Chrome Safe Storage", "Dia Safe Storage"])
+ #expect(!order.contains(.safari))
+ #expect(!order.contains(.firefox))
+ #expect(!order.contains(.edge))
+ #expect(!order.contains(.brave))
+ #expect(!order.contains(.arc))
+ #expect(!order.contains(.chromium))
}
@Test
diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift
index eeda8a0fe0..b585d655a0 100644
--- a/Tests/CodexBarTests/BrowserDetectionTests.swift
+++ b/Tests/CodexBarTests/BrowserDetectionTests.swift
@@ -30,6 +30,14 @@ struct BrowserDetectionTests {
profileAccessIssue: { _ in nil })
}
+ private static func labelIDs(for browser: Browser) -> [String] {
+ browser.safeStorageLabels.map { self.labelID(service: $0.service, account: $0.account) }
+ }
+
+ private static func labelID(service: String, account: String?) -> String {
+ "\(service)|\(account ?? "")"
+ }
+
@Test(.disabled(
if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1",
"Default-home cookie access is explicitly enabled for this test run."))
@@ -205,7 +213,7 @@ struct BrowserDetectionTests {
}
@Test
- func `keychain interaction suppresses chromium cookie source during cooldown`() {
+ func `keychain interaction suppresses chromium family during cooldown`() {
BrowserCookieAccessGate.resetForTesting()
defer { BrowserCookieAccessGate.resetForTesting() }
@@ -213,7 +221,7 @@ struct BrowserDetectionTests {
var preflightCount = 0
KeychainAccessGate.withTaskOverrideForTesting(false) {
- ProviderInteractionContext.$current.withValue(.userInitiated) {
+ ProviderInteractionContext.$current.withValue(.background) {
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
preflightCount += 1
return .interactionRequired
@@ -226,6 +234,7 @@ struct BrowserDetectionTests {
return .allowed
} operation: {
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false)
#expect(
BrowserCookieAccessGate.shouldAttempt(
.chrome,
@@ -281,6 +290,157 @@ struct BrowserDetectionTests {
#expect(preflightCount == 1)
}
+ @Test
+ func `recorded browser denial suppresses automatic family and permits explicit source retry`() {
+ BrowserCookieAccessGate.resetForTesting()
+ defer { BrowserCookieAccessGate.resetForTesting() }
+
+ let start = Date(timeIntervalSince1970: 1500)
+ var preflightCount = 0
+
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ BrowserCookieAccessGate.recordIfNeeded(
+ BrowserCookieError.accessDenied(browser: .arc, details: "denied"),
+ now: start)
+ KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
+ preflightCount += 1
+ return .allowed
+ } operation: {
+ ProviderInteractionContext.$current.withValue(.background) {
+ #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(1)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(1)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.safari, now: start.addingTimeInterval(1)) == true)
+ }
+ BrowserCookieAccessGate.withExplicitRetry {
+ ProviderInteractionContext.$current.withValue(.userInitiated) {
+ #expect(BrowserCookieAccessGate
+ .shouldAttempt(.chrome, now: start.addingTimeInterval(2)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(2)) == true)
+ #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc))
+ BrowserCookieAccessGate.recordAllowed(for: .arc)
+ }
+ }
+ ProviderInteractionContext.$current.withValue(.background) {
+ #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(3)) == true)
+ }
+ }
+ }
+
+ #expect(preflightCount == 2)
+ }
+
+ @Test
+ func `denied explicit cookie read closes retry scope`() {
+ BrowserCookieAccessGate.resetForTesting()
+ defer { BrowserCookieAccessGate.resetForTesting() }
+
+ let start = Date(timeIntervalSince1970: 1700)
+ BrowserCookieAccessGate.recordDenied(for: .arc, now: start)
+
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ BrowserCookieAccessGate.withExplicitRetry {
+ ProviderInteractionContext.$current.withValue(.userInitiated) {
+ #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1)))
+ #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc))
+
+ BrowserCookieAccessGate.recordDenied(for: .arc, now: start.addingTimeInterval(2))
+
+ #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(3)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(3)) == false)
+ }
+ }
+ }
+ }
+
+ @Test
+ func `chrome keychain preflight queries only chrome labels`() {
+ BrowserCookieAccessGate.resetForTesting()
+ defer { BrowserCookieAccessGate.resetForTesting() }
+
+ let chromeLabels = Self.labelIDs(for: .chrome)
+ let chromeLabelSet = Set(chromeLabels)
+ var queriedLabels: [String] = []
+
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in
+ let label = Self.labelID(service: service, account: account)
+ queriedLabels.append(label)
+ return .notFound
+ } operation: {
+ #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true)
+ }
+ }
+
+ #expect(queriedLabels == chromeLabels)
+ #expect(queriedLabels.allSatisfy { chromeLabelSet.contains($0) })
+ }
+
+ @Test
+ func `dia keychain preflight queries only dia labels`() {
+ BrowserCookieAccessGate.resetForTesting()
+ defer { BrowserCookieAccessGate.resetForTesting() }
+
+ let diaLabels = Self.labelIDs(for: .dia)
+ let diaLabelSet = Set(diaLabels)
+ var queriedLabels: [String] = []
+
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in
+ let label = Self.labelID(service: service, account: account)
+ queriedLabels.append(label)
+ return .notFound
+ } operation: {
+ #expect(BrowserCookieAccessGate.shouldAttempt(.dia) == true)
+ }
+ }
+
+ #expect(queriedLabels == diaLabels)
+ #expect(queriedLabels.allSatisfy { diaLabelSet.contains($0) })
+ }
+
+ @Test
+ func `browser keychain interaction suppresses family and permits scoped explicit retry`() throws {
+ BrowserCookieAccessGate.resetForTesting()
+ defer { BrowserCookieAccessGate.resetForTesting() }
+
+ let start = Date(timeIntervalSince1970: 2000)
+ let chromeLabels = Self.labelIDs(for: .chrome)
+ let diaLabels = Self.labelIDs(for: .dia)
+ let firstChromeLabel = try #require(chromeLabels.first)
+ let firstDiaLabel = try #require(diaLabels.first)
+ let allowedLabels = Set(chromeLabels + diaLabels)
+ var queriedLabels: [String] = []
+
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in
+ let label = Self.labelID(service: service, account: account)
+ queriedLabels.append(label)
+ if label == firstChromeLabel { return .allowed }
+ if label == firstDiaLabel { return .interactionRequired }
+ return .notFound
+ } operation: {
+ ProviderInteractionContext.$current.withValue(.background) {
+ #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false)
+ }
+ BrowserCookieAccessGate.withExplicitRetry {
+ ProviderInteractionContext.$current.withValue(.userInitiated) {
+ #expect(BrowserCookieAccessGate
+ .shouldAttempt(.chrome, now: start.addingTimeInterval(61)) == false)
+ #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(61)) == true)
+ #expect(BrowserCookieAccessGate
+ .shouldAttempt(.edge, now: start.addingTimeInterval(61)) == false)
+ }
+ }
+ }
+ }
+
+ #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstDiaLabel])
+ #expect(queriedLabels.allSatisfy { allowedLabels.contains($0) })
+ }
+
@Test
func `dia requires profile data`() throws {
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
diff --git a/Tests/CodexBarTests/CLICardsRendererTests.swift b/Tests/CodexBarTests/CLICardsRendererTests.swift
new file mode 100644
index 0000000000..10040de896
--- /dev/null
+++ b/Tests/CodexBarTests/CLICardsRendererTests.swift
@@ -0,0 +1,615 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBarCLI
+
+struct CLICardsRendererTests {
+ @Test
+ func `computes column count from terminal width`() {
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 80) == 2)
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 120) == 3)
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 160) == 4)
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 30) == 1)
+ }
+
+ @Test
+ func `renders single codex card without color`() {
+ let identity = ProviderIdentitySnapshot(
+ providerID: .codex,
+ accountEmail: "user@example.com",
+ accountOrganization: nil,
+ loginMethod: "pro")
+ let snapshot = UsageSnapshot(
+ primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: "today at 3:00 PM"),
+ secondary: .init(usedPercent: 25, windowMinutes: 10080, resetsAt: nil, resetDescription: "Fri at 9:00 AM"),
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0),
+ identity: identity)
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()),
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .absolute,
+ weeklyWorkDays: nil,
+ now: Date()))
+
+ let output = CLICardsRenderer.render(cards: [card], failures: [], terminalWidth: 80, useColor: false)
+
+ #expect(output.contains("Codex"))
+ #expect(output.contains("[oauth]"))
+ #expect(output.contains("PLAN Pro 20x"))
+ #expect(output.contains("Session"))
+ #expect(output.contains("88% left"))
+ #expect(output.contains("[ "))
+ #expect(output.contains("━"))
+ #expect(output.contains("Credits:"))
+ #expect(output.contains("42 left"))
+ #expect(output.contains("@ user@example.com"))
+ #expect(output.contains("╰"))
+ }
+
+ @Test
+ func `card includes account line`() {
+ let identity = ProviderIdentitySnapshot(
+ providerID: .codex,
+ accountEmail: "user@example.com",
+ accountOrganization: nil,
+ loginMethod: "pro")
+ let snapshot = UsageSnapshot(
+ primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0),
+ identity: identity)
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: nil,
+ source: "cli",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .absolute,
+ weeklyWorkDays: nil,
+ now: Date()))
+
+ let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false)
+ let joined = lines.joined(separator: "\n")
+
+ #expect(joined.contains("@ user@example.com"))
+ #expect(joined.contains("Session"))
+ #expect(!joined.contains("Plan: Pro 20x"))
+ }
+
+ @Test
+ func `renders two card grid at fixed width`() {
+ let codex = CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let claude = CLICardModel(
+ provider: .claude,
+ title: "Claude",
+ sourceLabel: "web",
+ planBadge: "Max",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+
+ let output = CLICardsRenderer.render(cards: [codex, claude], failures: [], terminalWidth: 120, useColor: false)
+
+ #expect(output.contains("Codex"))
+ #expect(output.contains("Claude"))
+ #expect(output.contains("88% left"))
+ #expect(output.contains("50% left"))
+ #expect(output.components(separatedBy: "╰").count >= 3)
+ }
+
+ @Test
+ func `renders failure footer without cards`() {
+ let failures = [
+ CLICardFailure(provider: .cursor, accountLabel: nil, message: "not configured"),
+ ]
+ let output = CLICardsRenderer.render(cards: [], failures: failures, terminalWidth: 80, useColor: false)
+
+ #expect(output.contains("Failed providers:"))
+ #expect(output.contains("Cursor: not configured"))
+ }
+
+ @Test
+ func `appends failure footer after successful cards`() {
+ let card = CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let failures = [
+ CLICardFailure(provider: .grok, accountLabel: nil, message: "timeout"),
+ ]
+
+ let output = CLICardsRenderer.render(cards: [card], failures: failures, terminalWidth: 80, useColor: false)
+
+ #expect(output.contains("88% left"))
+ #expect(output.contains("Failed providers:"))
+ #expect(output.contains("Grok: timeout"))
+ }
+
+ @Test
+ func `brief mode renders usage table`() {
+ let card = CLICardModel(
+ provider: .claude,
+ title: "Claude",
+ sourceLabel: "web",
+ planBadge: "Max",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 2, resetText: "⏳ Resets in 1h 49m")],
+ extraLines: [],
+ statusLine: nil)
+ let rows = CLICardsBriefRenderer.makeRows(cards: [card])
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(output.contains("codexbar • AI Usage & Limits"))
+ #expect(output.contains("Provider"))
+ #expect(output.contains("Claude"))
+ #expect(output.contains("web"))
+ #expect(output.contains("Max"))
+ #expect(output.contains("98%"))
+ #expect(output.contains("█"))
+ #expect(output.contains("1h 49m"))
+ #expect(output.contains("⚠ Warnings:"))
+ let tableLine = output.split(separator: "\n").first { $0.hasPrefix("┌") } ?? ""
+ #expect(tableLine.count >= 50)
+ #expect(tableLine.count <= 72)
+ }
+
+ @Test
+ func `synthetic quota lanes do not replace real brief usage`() {
+ let snapshot = UsageSnapshot(
+ primary: .init(
+ usedPercent: 0,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil,
+ isSyntheticPlaceholder: true),
+ secondary: .init(
+ usedPercent: 20,
+ windowMinutes: 10080,
+ resetsAt: nil,
+ resetDescription: nil),
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0))
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .claude,
+ snapshot: snapshot,
+ credits: nil,
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .countdown,
+ weeklyWorkDays: nil,
+ now: Date(timeIntervalSince1970: 0)))
+ let rows = CLICardsBriefRenderer.makeRows(cards: [card])
+
+ #expect(card.metrics.map(\.label) == ["Weekly"])
+ #expect(rows.first?.usedPercent == 20)
+ }
+
+ @Test
+ func `brief reset summary wraps to terminal width`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let card = CLICardModel(
+ provider: .alibabatokenplan,
+ title: "Alibaba Token Plan",
+ sourceLabel: "web",
+ planBadge: "International",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(
+ label: "Monthly budget",
+ remainingPercent: 50,
+ resetText: "⏳ Resets July 30 at 11:59 PM",
+ resetAt: now.addingTimeInterval(3600))],
+ extraLines: [],
+ statusLine: nil)
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: [card]),
+ failures: [],
+ terminalWidth: 40,
+ useColor: false,
+ now: now)
+ let lines = output.split(separator: "\n", omittingEmptySubsequences: false)
+
+ #expect(output.contains("Next reset: Alibaba Token Plan"))
+ #expect(lines.allSatisfy { $0.count <= 40 })
+ }
+
+ @Test
+ func `detail backed quota descriptions are not rendered as resets`() {
+ let snapshot = UsageSnapshot(
+ primary: .init(
+ usedPercent: 25,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "25/100 credits"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0))
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .kilo,
+ snapshot: snapshot,
+ credits: nil,
+ source: "api",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .countdown,
+ weeklyWorkDays: nil,
+ now: Date(timeIntervalSince1970: 0)))
+
+ #expect(card.metrics.first?.resetText == nil)
+ #expect(card.metrics.first?.detailText == "25/100 credits")
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: [card]),
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+ #expect(!output.contains("Next reset"))
+ #expect(!output.contains("Reset 25/100 credits"))
+ }
+
+ @Test
+ func `card metrics honor reset display style`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let snapshot = UsageSnapshot(
+ primary: .init(
+ usedPercent: 25,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now)
+ let countdown = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: nil,
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .countdown,
+ weeklyWorkDays: nil,
+ now: now))
+ let absolute = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: nil,
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .absolute,
+ weeklyWorkDays: nil,
+ now: now))
+
+ #expect(countdown.metrics.first?.resetText != absolute.metrics.first?.resetText)
+ #expect(countdown.metrics.first?.resetText?.contains("in 1h") == true)
+ #expect(absolute.metrics.first?.resetAt == now.addingTimeInterval(3600))
+ }
+
+ @Test
+ func `long detail rows stay within card width`() {
+ let card = CLICardModel(
+ provider: .clawrouter,
+ title: "ClawRouter",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: ["Workspace: " + String(repeating: "long-name-", count: 12)],
+ metrics: [],
+ extraLines: [],
+ statusLine: nil)
+
+ let lines = CLICardsRenderer.renderCard(card, width: 38, useColor: true, enhanced: true)
+ #expect(lines.allSatisfy { TextParsing.stripANSICodes($0).count == 38 })
+ }
+
+ @Test
+ func `brief warnings name the actual quota metric`() {
+ let card = CLICardModel(
+ provider: .openrouter,
+ title: "OpenRouter",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Spend", remainingPercent: 10, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: [card]),
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(output.contains("OpenRouter Spend: 90% used"))
+ #expect(!output.contains("session limit"))
+ }
+
+ @Test
+ func `brief rows preserve account identity`() {
+ let cards = [
+ CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: "one@x.dev",
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 80, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: "two@x.dev",
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 60, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ ]
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: cards),
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(output.contains("one@x.dev"))
+ #expect(output.contains("two@x.dev"))
+ }
+
+ @Test
+ func `brief warnings wrap to terminal width`() {
+ let cards = ["OpenRouter", "Antigravity", "CommandCode"].map { title in
+ CLICardModel(
+ provider: .openrouter,
+ title: title,
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Monthly budget", remainingPercent: 5, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ }
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: cards),
+ failures: [],
+ terminalWidth: 40,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+ let warningLines = output.split(separator: "\n").filter {
+ $0.contains("Warnings:") || $0.contains("% used")
+ }
+
+ #expect(warningLines.count > 1)
+ #expect(warningLines.allSatisfy { $0.count <= 40 })
+ }
+
+ @Test
+ func `brief summary ignores unparseable reset labels and fits narrow terminals`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let rows = CLICardsBriefRenderer.makeRows(cards: [
+ CLICardModel(
+ provider: .kilo,
+ title: "Kilo",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Credits", remainingPercent: 75, resetText: "Reset Unlimited")],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(
+ label: "Session",
+ remainingPercent: 50,
+ resetText: "⏳ Resets in 5h",
+ resetAt: now.addingTimeInterval(5 * 3600))],
+ extraLines: [],
+ statusLine: nil),
+ ])
+
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 40,
+ useColor: false,
+ now: now)
+ let lines = output.split(separator: "\n", omittingEmptySubsequences: false)
+
+ #expect(output.contains("Next reset: Codex in 5h"))
+ #expect(!output.contains("Next reset: Kilo"))
+ #expect(lines.allSatisfy { $0.count <= 40 })
+ }
+
+ @Test
+ func `enhanced brief mode fills bars from used percentage`() {
+ let rows = CLICardsBriefRenderer.makeRows(cards: [
+ CLICardModel(
+ provider: .codex,
+ title: "Unused",
+ sourceLabel: "oauth",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ ])
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 80,
+ useColor: true,
+ enhanced: true,
+ now: Date(timeIntervalSince1970: 0))
+ let plainLines = TextParsing.stripANSICodes(output).split(separator: "\n")
+ let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "")
+ let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "")
+
+ #expect(unusedLine.contains("0%"))
+ #expect(unusedLine.filter { $0 == "█" }.isEmpty)
+ #expect(exhaustedLine.contains("100%"))
+ #expect(exhaustedLine.filter { $0 == "░" }.isEmpty)
+ }
+
+ @Test
+ func `standard brief mode fills bars from used percentage`() {
+ let rows = CLICardsBriefRenderer.makeRows(cards: [
+ CLICardModel(
+ provider: .codex,
+ title: "Unused",
+ sourceLabel: "oauth",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ ])
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ enhanced: false,
+ now: Date(timeIntervalSince1970: 0))
+ let plainLines = output.split(separator: "\n")
+ let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "")
+ let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "")
+
+ #expect(unusedLine.contains("0%"))
+ #expect(unusedLine.filter { $0 == "█" }.isEmpty)
+ #expect(exhaustedLine.contains("100%"))
+ #expect(exhaustedLine.filter { $0 == "░" }.isEmpty)
+ }
+
+ @Test
+ func `standard card grid shows empty remaining bar at exhaustion`() {
+ let card = CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false, enhanced: false)
+ let barLine = String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? "")
+ #expect(barLine.filter { $0 == "━" }.isEmpty)
+ }
+
+ @Test
+ func `enhanced card grid shows empty remaining bar at exhaustion`() {
+ let card = CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: true, enhanced: true)
+ let plainBarLine = TextParsing.stripANSICodes(
+ String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? ""))
+ #expect(plainBarLine.filter { !$0.isWhitespace && $0 != "│" && $0 != "[" && $0 != "]" }.isEmpty)
+ }
+
+ @Test
+ func `enhanced mode uses truecolor gradient bars`() {
+ let card = CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let output = CLICardsRenderer.render(
+ cards: [card],
+ failures: [],
+ terminalWidth: 80,
+ useColor: true,
+ enhanced: true)
+ #expect(output.contains("38;2;"))
+ #expect(output.contains("48;2;"))
+ #expect(output.contains("[ "))
+ }
+}
diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift
index 1b223bce56..008bf2f6e5 100644
--- a/Tests/CodexBarTests/CLICostTests.swift
+++ b/Tests/CodexBarTests/CLICostTests.swift
@@ -38,6 +38,67 @@ struct CLICostTests {
#expect(output.contains("Claude Code /status"))
}
+ @Test
+ func `renders codex project grouped cost text`() {
+ let snap = CostUsageTokenSnapshot(
+ sessionTokens: 1200,
+ sessionCostUSD: 1.25,
+ last30DaysTokens: 9000,
+ last30DaysCostUSD: 9.99,
+ historyDays: 30,
+ daily: [],
+ projects: [
+ CostUsageProjectBreakdown(
+ name: "client-a",
+ path: "/work/client-a",
+ totalTokens: 7000,
+ totalCostUSD: 7.5,
+ daily: [],
+ modelBreakdowns: nil,
+ sources: [
+ CostUsageProjectSourceBreakdown(
+ name: "client-a",
+ path: "/work/client-a",
+ totalTokens: 5000,
+ totalCostUSD: 5.25,
+ daily: [],
+ modelBreakdowns: nil),
+ CostUsageProjectSourceBreakdown(
+ name: "client-a",
+ path: "/Users/test/.codex/worktrees/abcd/client-a",
+ totalTokens: 2000,
+ totalCostUSD: 2.25,
+ daily: [],
+ modelBreakdowns: nil),
+ ]),
+ CostUsageProjectBreakdown(
+ name: CostUsageProjectBreakdown.unknownProjectName,
+ path: nil,
+ totalTokens: 2000,
+ totalCostUSD: 2.49,
+ daily: [],
+ modelBreakdowns: nil),
+ ],
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ let output = CodexBarCLI.renderCostText(
+ provider: .codex,
+ snapshot: snap,
+ groupBy: .project,
+ useColor: false)
+ .replacingOccurrences(of: "\u{00A0}", with: " ")
+ .replacingOccurrences(of: "$ ", with: "$")
+
+ #expect(output.contains("Codex Cost (API-rate estimate)"))
+ #expect(output.contains("Projects (Last 30 days):"))
+ #expect(output.contains("client-a: $7.50 · 7K tokens"))
+ #expect(output.contains("/work/client-a"))
+ #expect(output.contains(" - client-a: $5.25 · 5K tokens"))
+ #expect(output.contains(" - client-a: $2.25 · 2K tokens"))
+ #expect(output.contains("/Users/test/.codex/worktrees/abcd/client-a"))
+ #expect(output.contains("Unknown project: $2.49 · 2K tokens"))
+ }
+
@Test
func `encodes cost payload JSON`() throws {
let payload = CostPayload(
@@ -95,6 +156,89 @@ struct CLICostTests {
#expect(json.contains("1700000000"))
}
+ @Test
+ func `codex cost payload includes project rollups`() throws {
+ let snapshot = CostUsageTokenSnapshot(
+ sessionTokens: 10,
+ sessionCostUSD: 0.01,
+ last30DaysTokens: 40,
+ last30DaysCostUSD: 0.04,
+ daily: [
+ CostUsageDailyReport.Entry(
+ date: "2026-04-02",
+ inputTokens: 30,
+ outputTokens: 10,
+ totalTokens: 40,
+ costUSD: 0.04,
+ modelsUsed: ["gpt-5.4"],
+ modelBreakdowns: [
+ CostUsageDailyReport.ModelBreakdown(
+ modelName: "gpt-5.4",
+ costUSD: 0.04,
+ totalTokens: 40),
+ ]),
+ ],
+ projects: [
+ CostUsageProjectBreakdown(
+ name: "client-a",
+ path: "/work/client-a",
+ totalTokens: 40,
+ totalCostUSD: 0.04,
+ daily: [
+ CostUsageDailyReport.Entry(
+ date: "2026-04-02",
+ inputTokens: 30,
+ outputTokens: 10,
+ totalTokens: 40,
+ costUSD: 0.04,
+ modelsUsed: ["gpt-5.4"],
+ modelBreakdowns: nil),
+ ],
+ modelBreakdowns: [
+ CostUsageDailyReport.ModelBreakdown(
+ modelName: "gpt-5.4",
+ costUSD: 0.04,
+ totalTokens: 40),
+ ],
+ sources: [
+ CostUsageProjectSourceBreakdown(
+ name: "client-a",
+ path: "/work/client-a",
+ totalTokens: 40,
+ totalCostUSD: 0.04,
+ daily: [
+ CostUsageDailyReport.Entry(
+ date: "2026-04-02",
+ inputTokens: 30,
+ outputTokens: 10,
+ totalTokens: 40,
+ costUSD: 0.04,
+ modelsUsed: ["gpt-5.4"],
+ modelBreakdowns: nil),
+ ],
+ modelBreakdowns: nil),
+ ]),
+ ],
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
+ let payload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: snapshot, error: nil)
+
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .secondsSince1970
+ let data = try encoder.encode(payload)
+ guard let json = String(data: data, encoding: .utf8) else {
+ Issue.record("Failed to decode cost payload JSON")
+ return
+ }
+
+ #expect(json.contains("\"projects\""))
+ #expect(json.contains("\"sources\""))
+ #expect(json.contains("\"name\":\"client-a\""))
+ #expect(json.contains("/work/client-a") || json.contains("\\/work\\/client-a"))
+ #expect(json.contains("\"totalCost\":0.04"))
+ #expect(json.contains("\"daily\""))
+ #expect(json.contains("\"gpt-5.4\""))
+ }
+
@Test
func `encodes exact codex model I ds and zero cost breakdowns`() throws {
let payload = CostPayload(
diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift
index cf5eee9d37..036415767f 100644
--- a/Tests/CodexBarTests/CLISnapshotTests.swift
+++ b/Tests/CodexBarTests/CLISnapshotTests.swift
@@ -94,7 +94,8 @@ struct CLISnapshotTests {
@Test
func `renders Codex limit reset credits`() {
- let expiresAt = Date().addingTimeInterval(7200)
+ let now = Date()
+ let expiresAt = now.addingTimeInterval(7200)
let resetCredits = CodexRateLimitResetCreditsSnapshot(
credits: [
CodexRateLimitResetCredit(
@@ -107,8 +108,18 @@ struct CLISnapshotTests {
redeemedAt: nil,
title: nil,
description: nil),
+ CodexRateLimitResetCredit(
+ id: "expired-credit",
+ resetType: "codex_rate_limits",
+ status: .available,
+ grantedAt: Date(timeIntervalSince1970: 0),
+ expiresAt: now,
+ redeemStartedAt: nil,
+ redeemedAt: nil,
+ title: nil,
+ description: nil),
],
- availableCount: 1,
+ availableCount: 99,
updatedAt: Date(timeIntervalSince1970: 0))
let snapshot = UsageSnapshot(
primary: .init(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
@@ -213,6 +224,34 @@ struct CLISnapshotTests {
#expect(!output.contains("Weekly:"))
}
+ @Test
+ func `renders Claude Max multiplier without uppercasing x`() {
+ let identity = ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Claude Max 5x")
+ let snapshot = UsageSnapshot(
+ primary: .init(usedPercent: 2, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0),
+ identity: identity)
+
+ let output = CLIRenderer.renderText(
+ provider: .claude,
+ snapshot: snapshot,
+ credits: nil,
+ context: RenderContext(
+ header: "Claude (oauth)",
+ status: nil,
+ useColor: false,
+ resetStyle: .absolute))
+
+ #expect(output.contains("Plan: Claude Max 5x"))
+ #expect(!output.contains("Plan: Claude Max 5X"))
+ }
+
@Test
func `renders warp unlimited as detail not reset`() {
let meta = ProviderDescriptorRegistry.descriptor(for: .warp).metadata
@@ -1092,4 +1131,31 @@ struct CLISnapshotTests {
#expect(output.contains("Tokens:"))
#expect(output.contains("MCP:"))
}
+
+ @Test
+ func `devin overage balance without primary window omits generic cost line`() {
+ let snap = UsageSnapshot(
+ primary: nil,
+ secondary: .init(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ tertiary: nil,
+ providerCost: ProviderCostSnapshot(
+ used: 48.0,
+ limit: 0,
+ currencyCode: "USD",
+ period: "Extra usage balance",
+ updatedAt: Date(timeIntervalSince1970: 0)),
+ updatedAt: Date(timeIntervalSince1970: 0))
+ let output = CLIRenderer.renderText(
+ provider: .devin,
+ snapshot: snap,
+ credits: nil,
+ context: RenderContext(
+ header: "Devin (devin)",
+ status: nil,
+ useColor: false,
+ resetStyle: .absolute))
+ #expect(output.contains("Extra usage: $48.00"))
+ #expect(!output.contains("Cost:"))
+ #expect(!output.contains(" / 0.0"))
+ }
}
diff --git a/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift
new file mode 100644
index 0000000000..0db3dc0d2c
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift
@@ -0,0 +1,277 @@
+import Foundation
+import Testing
+@testable import CodexBar
+@testable import CodexBarCore
+
+@MainActor
+struct ClaudeExtraWindowQuotaWarningTests {
+ private func makeSettings(suiteName: String) -> SettingsStore {
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ return SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suiteName),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ }
+
+ @MainActor
+ final class SessionQuotaNotifierSpy: SessionQuotaNotifying {
+ private(set) var quotaWarningPosts: [(
+ event: QuotaWarningEvent,
+ provider: UsageProvider,
+ soundEnabled: Bool,
+ onScreenAlertEnabled: Bool)] = []
+
+ func post(transition _: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) {}
+
+ func postQuotaWarning(
+ event: QuotaWarningEvent,
+ provider: UsageProvider,
+ soundEnabled: Bool,
+ onScreenAlertEnabled: Bool)
+ {
+ self.quotaWarningPosts.append((
+ event: event,
+ provider: provider,
+ soundEnabled: soundEnabled,
+ onScreenAlertEnabled: onScreenAlertEnabled))
+ }
+ }
+
+ @Test
+ func `claude scoped weekly and routines extra windows fire independent weekly warnings`() {
+ let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-independent")
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.quotaWarningNotificationsEnabled = true
+ settings.quotaWarningThresholds = [50, 20]
+ settings.setQuotaWarningWindowEnabled(.session, enabled: true)
+ settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
+
+ let notifier = SessionQuotaNotifierSpy()
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings,
+ sessionQuotaNotifier: notifier)
+
+ store.handleQuotaWarningTransitions(
+ provider: .claude,
+ snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40))
+ store.handleQuotaWarningTransitions(
+ provider: .claude,
+ snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55))
+
+ #expect(notifier.quotaWarningPosts.count == 2)
+ let fable = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-weekly-scoped-fable" }
+ let routines = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-routines" }
+ #expect(fable?.event.window == .weekly)
+ #expect(fable?.event.threshold == 50)
+ #expect(fable?.event.windowDisplayLabel == "Fable only")
+ #expect(routines?.event.threshold == 50)
+ #expect(routines?.event.windowDisplayLabel == "Daily Routines")
+
+ // Each window keeps independent fired-threshold state instead of clobbering the shared weekly key.
+ let fableKey = UsageStore.QuotaWarningStateKey(
+ provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
+ let routinesKey = UsageStore.QuotaWarningStateKey(
+ provider: .claude, window: .weekly, windowID: "claude-routines")
+ #expect(store.quotaWarningState[fableKey]?.firedThresholds.contains(50) == true)
+ #expect(store.quotaWarningState[routinesKey]?.firedThresholds.contains(50) == true)
+ }
+
+ @Test
+ func `antigravity summary extra windows do not trigger the claude extra-window lane`() {
+ let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-antigravity-guard")
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.quotaWarningNotificationsEnabled = true
+ settings.quotaWarningThresholds = [50, 20]
+ settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
+
+ let notifier = SessionQuotaNotifierSpy()
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings,
+ sessionQuotaNotifier: notifier)
+
+ func snapshot(used: Double) -> UsageSnapshot {
+ UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ extraRateWindows: [
+ NamedRateWindow(
+ id: "antigravity-quota-summary-model-weekly",
+ title: "Weekly",
+ window: RateWindow(
+ usedPercent: used,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: nil,
+ resetDescription: nil)),
+ ],
+ updatedAt: Date())
+ }
+ store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 40))
+ store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 55))
+
+ #expect(notifier.quotaWarningPosts.isEmpty)
+ }
+
+ @Test
+ func `claude scoped weekly window refires after recovering above threshold`() {
+ let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-refire")
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.quotaWarningNotificationsEnabled = true
+ settings.quotaWarningThresholds = [50]
+ settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
+
+ let notifier = SessionQuotaNotifierSpy()
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings,
+ sessionQuotaNotifier: notifier)
+
+ // 60% remaining -> 45% (fires 50) -> 60% (clears 50) -> 45% (refires 50).
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil))
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil))
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
+
+ #expect(notifier.quotaWarningPosts.count == 2)
+ #expect(notifier.quotaWarningPosts.allSatisfy { $0.event.windowID == "claude-weekly-scoped-fable" })
+ }
+
+ @Test
+ func `claude extra-window fired state is pruned when a window disappears but others remain`() {
+ let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-prune")
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.quotaWarningNotificationsEnabled = true
+ settings.quotaWarningThresholds = [50]
+ settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
+
+ let notifier = SessionQuotaNotifierSpy()
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings,
+ sessionQuotaNotifier: notifier)
+
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40))
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55))
+ let fableKey = UsageStore.QuotaWarningStateKey(
+ provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
+ let routinesKey = UsageStore.QuotaWarningStateKey(
+ provider: .claude, window: .weekly, windowID: "claude-routines")
+ #expect(store.quotaWarningState[fableKey] != nil)
+ #expect(store.quotaWarningState[routinesKey] != nil)
+
+ // Fable ends while Routines is still present: this refresh carries authoritative extras, so
+ // Fable's stale state is dropped and Routines is kept.
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: nil, routinesUsed: 55))
+ #expect(store.quotaWarningState[fableKey] == nil)
+ #expect(store.quotaWarningState[routinesKey] != nil)
+ }
+
+ @Test
+ func `disabling weekly warnings clears all claude extra-window state`() {
+ let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-disable")
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.quotaWarningNotificationsEnabled = true
+ settings.quotaWarningThresholds = [50]
+ settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
+
+ let notifier = SessionQuotaNotifierSpy()
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings,
+ sessionQuotaNotifier: notifier)
+
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40))
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55))
+ let fableKey = UsageStore.QuotaWarningStateKey(
+ provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
+ let routinesKey = UsageStore.QuotaWarningStateKey(
+ provider: .claude, window: .weekly, windowID: "claude-routines")
+ #expect(store.quotaWarningState[fableKey] != nil)
+ #expect(store.quotaWarningState[routinesKey] != nil)
+
+ settings.setQuotaWarningWindowEnabled(.weekly, enabled: false)
+ store.handleQuotaWarningTransitions(
+ provider: .claude,
+ snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date()))
+ #expect(store.quotaWarningState[fableKey] == nil)
+ #expect(store.quotaWarningState[routinesKey] == nil)
+ }
+
+ @Test
+ func `claude extra-window state survives a transient extras miss without re-posting`() {
+ let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-transient-miss")
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.quotaWarningNotificationsEnabled = true
+ settings.quotaWarningThresholds = [50]
+ settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
+
+ let notifier = SessionQuotaNotifierSpy()
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings,
+ sessionQuotaNotifier: notifier)
+
+ // Fable crosses 50% and warns once.
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil))
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
+ #expect(notifier.quotaWarningPosts.count == 1)
+ let fableKey = UsageStore.QuotaWarningStateKey(
+ provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
+
+ // A failed web-extras fetch delivers nil extras while the main snapshot is intact. The fired
+ // state must persist so the warning is not re-posted when extras recover.
+ store.handleQuotaWarningTransitions(
+ provider: .claude,
+ snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date()))
+ #expect(store.quotaWarningState[fableKey] != nil)
+
+ store.handleQuotaWarningTransitions(
+ provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
+ #expect(notifier.quotaWarningPosts.count == 1)
+ }
+
+ private func claudeExtraWindowSnapshot(fableUsed: Double?, routinesUsed: Double?) -> UsageSnapshot {
+ var windows: [NamedRateWindow] = []
+ if let fableUsed {
+ windows.append(NamedRateWindow(
+ id: "claude-weekly-scoped-fable",
+ title: "Fable only",
+ window: RateWindow(
+ usedPercent: fableUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil)))
+ }
+ if let routinesUsed {
+ windows.append(NamedRateWindow(
+ id: "claude-routines",
+ title: "Daily Routines",
+ window: RateWindow(
+ usedPercent: routinesUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil)))
+ }
+ return UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: windows, updatedAt: Date())
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift
index 31bc939006..e219a413aa 100644
--- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift
@@ -5,6 +5,28 @@ import Testing
#if os(macOS)
@Suite(.serialized)
struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests {
+ @Test
+ func `safety blocks security CLI access to the login keychain`() {
+ let blockedEnvironment = [KeychainTestSafety.suppressAccessEnvironmentKey: "1"]
+ #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
+ account: nil,
+ environment: blockedEnvironment) == nil)
+
+ let explicitOptIn = [
+ KeychainTestSafety.suppressAccessEnvironmentKey: "1",
+ KeychainTestSafety.allowAccessEnvironmentKey: "1",
+ ]
+ let expectedArguments = [
+ "find-generic-password",
+ "-s",
+ "Claude Code-credentials",
+ "-w",
+ ]
+ #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
+ account: nil,
+ environment: explicitOptIn) == expectedArguments)
+ }
+
@Test
func `isolated security CLI keychain requires global keychain disable`() {
let keychainPath = "/tmp/codexbar-fixtures/verify.keychain-db"
diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
index 82f4fb58cb..57a1a4640e 100644
--- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
@@ -144,7 +144,9 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests {
_ = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(recordWithoutRequiredScope) {
await ProviderInteractionContext.$current.withValue(.userInitiated) {
- await strategy.isAvailable(context)
+ await self.withAvailabilityKeychainDoubles {
+ await strategy.isAvailable(context)
+ }
}
}
@@ -181,9 +183,11 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests {
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
- await ProviderRefreshContext.$current.withValue(.startup) {
- await ProviderInteractionContext.$current.withValue(.background) {
- await strategy.isAvailable(context)
+ await self.withAvailabilityKeychainDoubles {
+ await ProviderRefreshContext.$current.withValue(.startup) {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await strategy.isAvailable(context)
+ }
}
}
}
@@ -246,9 +250,11 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests {
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.nonZeroExit) {
- await ProviderRefreshContext.$current.withValue(.startup) {
- await ProviderInteractionContext.$current.withValue(.background) {
- await strategy.isAvailable(context)
+ await self.withAvailabilityKeychainDoubles {
+ await ProviderRefreshContext.$current.withValue(.startup) {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await strategy.isAvailable(context)
+ }
}
}
}
@@ -365,5 +371,15 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests {
#expect(available == false)
}
+
+ private func withAvailabilityKeychainDoubles(
+ operation: () async throws -> T) async rethrows -> T
+ {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+ return try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(
+ true,
+ operation: operation)
+ }
}
#endif
diff --git a/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift b/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift
index d53505c252..15afcf5f66 100644
--- a/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift
@@ -46,9 +46,33 @@ struct ClaudeOAuthHistoryCredentialRoutingTests {
.matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingEnvironmentRecord) == nil)
#expect(ClaudeOAuthCredentialsStore
.matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingCodexBarRecord) == nil)
+ #expect(ClaudeOAuthCredentialsStore
+ .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) ==
+ .matched(persistentRefHash: "opaque-ref"))
+ #expect(ClaudeOAuthCredentialsStore
+ .claudeKeychainCredentialMatchWithoutPrompt(for: differentCLIRecord) == .mismatch)
+ #expect(ClaudeOAuthCredentialsStore
+ .claudeKeychainCredentialMatchWithoutPrompt(for: matchingEnvironmentRecord) == .notApplicable)
}
}
}
+
+ ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: nil,
+ fingerprint: fingerprint)
+ {
+ let unavailable = ClaudeOAuthCredentialsStore
+ .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord)
+ #expect(unavailable == .unavailable)
+ #expect(unavailable.isUnavailable)
+ #expect(!unavailable.isMismatch)
+ }
+
+ let absentStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore()
+ ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(absentStore) {
+ #expect(ClaudeOAuthCredentialsStore
+ .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) == .absent)
+ }
}
@Test
diff --git a/Tests/CodexBarTests/ClaudePlanResolverTests.swift b/Tests/CodexBarTests/ClaudePlanResolverTests.swift
index caeb2d17d1..f7ae24cc1f 100644
--- a/Tests/CodexBarTests/ClaudePlanResolverTests.swift
+++ b/Tests/CodexBarTests/ClaudePlanResolverTests.swift
@@ -5,12 +5,28 @@ import Testing
struct ClaudePlanResolverTests {
@Test
func `oauth rate limit tier maps to branded plan`() {
- #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_max_20x") == "Claude Max")
#expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_pro") == "Claude Pro")
#expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_team") == "Claude Team")
#expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_enterprise") == "Claude Enterprise")
}
+ @Test
+ func `oauth rate limit tier preserves the Max usage multiplier`() {
+ #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_max_5x") == "Claude Max 5x")
+ #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_max_20x") == "Claude Max 20x")
+ #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "v2_default_claude_max_20x") == "Claude Max 20x")
+ // A bare Max tier without a multiplier keeps the plain label.
+ #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_max") == "Claude Max")
+ #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_team_5x") == "Claude Team")
+ // A resolved non-Max plan never inherits a Max multiplier from a disagreeing tier.
+ #expect(
+ ClaudePlan.oauthLoginMethod(subscriptionType: "team", rateLimitTier: "default_claude_max_5x")
+ == "Claude Team")
+ #expect(
+ ClaudePlan.webLoginMethod(rateLimitTier: "default_claude_max_20x", billingType: nil)
+ == "Claude Max 20x")
+ }
+
@Test
func `oauth subscription type overrides generic rate limit tier`() {
#expect(
diff --git a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift
new file mode 100644
index 0000000000..f9c35ca98f
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift
@@ -0,0 +1,259 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+@MainActor
+struct ClaudeProviderRuntimeTests {
+ @Test
+ func `disabling adapter immediately clears retained accounts`() {
+ let (settings, store) = self.makeStore()
+ store.claudeSwapAccountSnapshots = [self.accountSnapshot()]
+ store.claudeSwapLastRefreshAt = Date()
+ store.claudeSwapLastError = "stale"
+ let runtime = ClaudeProviderRuntime()
+
+ runtime.settingsDidChange(context: ProviderRuntimeContext(provider: .claude, settings: settings, store: store))
+
+ #expect(store.claudeSwapAccountSnapshots.isEmpty)
+ #expect(store.claudeSwapLastRefreshAt == nil)
+ #expect(store.claudeSwapLastError == nil)
+ }
+
+ @Test
+ func `disabled Claude provider does not restart adapter`() {
+ let (settings, store) = self.makeStore()
+ settings.claudeSwapExecutablePath = "/path/to/cswap"
+ settings.claudeSwapEnabled = true
+ let runtime = ClaudeProviderRuntime()
+ let context = ProviderRuntimeContext(provider: .claude, settings: settings, store: store)
+
+ runtime.stop(context: context)
+ runtime.settingsDidChange(context: context)
+
+ #expect(!store.isEnabled(.claude))
+ #expect(store.claudeSwapRefreshTask == nil)
+ }
+
+ @Test
+ func `late adapter result is rejected after executable path changes`() async throws {
+ let (settings, store) = self.makeStore()
+ let executable = try self.makeFakeExecutable()
+ let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
+ settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
+ settings.claudeSwapExecutablePath = executable
+ settings.claudeSwapEnabled = true
+
+ let refresh = Task { @MainActor in
+ await store.refreshClaudeSwapAccounts()
+ }
+ try await Task.sleep(for: .milliseconds(100))
+ settings.claudeSwapExecutablePath = "/new/path/to/cswap"
+ await refresh.value
+
+ #expect(store.claudeSwapAccountSnapshots.isEmpty)
+ #expect(store.claudeSwapLastRefreshAt == nil)
+ }
+
+ @Test
+ func `explicit account activation is serialized through claude swap and refreshes Claude`() async throws {
+ let (settings, store) = self.makeStore()
+ let marker = FileManager.default.temporaryDirectory
+ .appendingPathComponent("claude-swap-switch-args-\(UUID().uuidString)")
+ let executable = try self.makeSwitchExecutable(marker: marker)
+ let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
+ settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
+ settings.claudeSwapExecutablePath = executable
+ settings.claudeSwapEnabled = true
+ let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
+ store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
+ id: accountID,
+ provider: .claude,
+ displayLabel: "switch@example.com",
+ isActive: false,
+ canActivate: true,
+ snapshot: nil,
+ error: nil,
+ sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
+ var refreshedProviders: [UsageProvider] = []
+ store._test_providerRefreshOverride = { refreshedProviders.append($0) }
+ defer { store._test_providerRefreshOverride = nil }
+
+ store.switchClaudeSwapAccount(accountID)
+ let task = try #require(store.claudeSwapTransientState.task)
+ await task.value
+
+ let arguments = try String(contentsOf: marker, encoding: .utf8)
+ #expect(arguments == "--switch-to\n2\n--json\n")
+ #expect(refreshedProviders == [.claude])
+ #expect(store.claudeSwapTransientState.task == nil)
+ #expect(store.claudeSwapTransientState.switchingAccountID == nil)
+ #expect(store.claudeSwapTransientState.lastError == nil)
+ #expect(store.claudeSwapTransientState.lastErrorAccountID == nil)
+ }
+
+ @Test
+ func `non actionable account cannot start credential transaction`() throws {
+ let (settings, store) = self.makeStore()
+ let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
+ settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
+ settings.claudeSwapExecutablePath = "/path/to/cswap"
+ settings.claudeSwapEnabled = true
+ let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
+ store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
+ id: accountID,
+ provider: .claude,
+ displayLabel: "expired@example.com",
+ isActive: false,
+ canActivate: false,
+ snapshot: nil,
+ error: "Token expired",
+ sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
+
+ store.switchClaudeSwapAccount(accountID)
+
+ #expect(store.claudeSwapTransientState.task == nil)
+ }
+
+ @Test
+ func `failed activation stays scoped to its requested account`() async throws {
+ let (settings, store) = self.makeStore()
+ let executable = try self.makeFailedSwitchExecutable()
+ let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
+ settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
+ settings.claudeSwapExecutablePath = executable
+ settings.claudeSwapEnabled = true
+ let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
+ store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
+ id: accountID,
+ provider: .claude,
+ displayLabel: "switch@example.com",
+ isActive: false,
+ canActivate: true,
+ snapshot: nil,
+ error: nil,
+ sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
+ store._test_providerRefreshOverride = { _ in }
+ defer { store._test_providerRefreshOverride = nil }
+
+ store.switchClaudeSwapAccount(accountID)
+ let task = try #require(store.claudeSwapTransientState.task)
+ await task.value
+
+ #expect(store.claudeSwapTransientState.lastError?.contains("credentials missing") == true)
+ #expect(store.claudeSwapTransientState.lastErrorAccountID == accountID)
+ }
+
+ @Test
+ func `configuration change during provider refresh discards switch result`() async throws {
+ let (settings, store) = self.makeStore()
+ let executable = try self.makeFailedSwitchExecutable()
+ let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
+ settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
+ settings.claudeSwapExecutablePath = executable
+ settings.claudeSwapEnabled = true
+ let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
+ store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
+ id: accountID,
+ provider: .claude,
+ displayLabel: "switch@example.com",
+ isActive: false,
+ canActivate: true,
+ snapshot: nil,
+ error: nil,
+ sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
+ store._test_providerRefreshOverride = { _ in
+ settings.claudeSwapExecutablePath = "/new/path/to/cswap"
+ }
+ defer { store._test_providerRefreshOverride = nil }
+
+ store.switchClaudeSwapAccount(accountID)
+ let task = try #require(store.claudeSwapTransientState.task)
+ await task.value
+
+ #expect(store.claudeSwapTransientState.task == nil)
+ #expect(store.claudeSwapTransientState.switchingAccountID == nil)
+ #expect(store.claudeSwapTransientState.lastError == nil)
+ #expect(store.claudeSwapTransientState.lastErrorAccountID == nil)
+ }
+
+ private func makeStore() -> (SettingsStore, UsageStore) {
+ let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ return (settings, store)
+ }
+
+ private func accountSnapshot() -> ProviderAccountUsageSnapshot {
+ ProviderAccountUsageSnapshot(
+ id: ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "1"),
+ provider: .claude,
+ displayLabel: "account@example.com",
+ isActive: false,
+ snapshot: nil,
+ error: "Token expired",
+ sourceLabel: ClaudeSwapAccountProjection.sourceLabel)
+ }
+
+ private func makeFakeExecutable() throws -> String {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("claude-runtime-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let url = directory.appendingPathComponent("cswap")
+ let script = """
+ #!/bin/sh
+ if [ "$1" = "--version" ]; then
+ echo 'cswap 0.16.0'
+ exit 0
+ fi
+ sleep 0.3
+ cat <<'EOF'
+ {"schemaVersion":1,"activeAccountNumber":1,"accounts":[
+ {"number":1,"email":"a@b.c","active":true,"usageStatus":"ok","usage":{"fiveHour":{"pct":12.5}}}
+ ]}
+ EOF
+ """
+ try script.write(to: url, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
+ return url.path
+ }
+
+ private func makeSwitchExecutable(marker: URL) throws -> String {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("claude-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let url = directory.appendingPathComponent("cswap")
+ let script = """
+ #!/bin/sh
+ printf '%s\n' "$@" > '\(marker.path)'
+ echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}'
+ """
+ try script.write(to: url, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
+ return url.path
+ }
+
+ private func makeFailedSwitchExecutable() throws -> String {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("claude-failed-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let url = directory.appendingPathComponent("cswap")
+ let script = """
+ #!/bin/sh
+ echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}'
+ exit 1
+ """
+ try script.write(to: url, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
+ return url.path
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeSessionMappingTests.swift b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift
new file mode 100644
index 0000000000..779691c4bb
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift
@@ -0,0 +1,36 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct ClaudeSessionMappingTests {
+ @Test
+ func `cwd escaping replaces every non alphanumeric ASCII byte`() {
+ #expect(ClaudeSessionProjectMapper.escapedCWD("/Users/test/My Project_v2") == "-Users-test-My-Project-v2")
+ }
+
+ @Test
+ func `newest transcript is selected from mapped project directory`() throws {
+ let home = FileManager.default.temporaryDirectory
+ .appendingPathComponent("ClaudeSessionMappingTests-\(UUID().uuidString)", isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let cwd = "/Users/test/Projects/alpha"
+ let projectDirectory = home
+ .appendingPathComponent(".claude/projects", isDirectory: true)
+ .appendingPathComponent(ClaudeSessionProjectMapper.escapedCWD(cwd), isDirectory: true)
+ try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true)
+ let older = projectDirectory.appendingPathComponent("older.jsonl")
+ let newer = projectDirectory.appendingPathComponent("newer.jsonl")
+ try Data("fixture\n".utf8).write(to: older)
+ try Data("fixture\n".utf8).write(to: newer)
+ try FileManager.default.setAttributes(
+ [.modificationDate: Date(timeIntervalSince1970: 100)],
+ ofItemAtPath: older.path)
+ try FileManager.default.setAttributes(
+ [.modificationDate: Date(timeIntervalSince1970: 200)],
+ ofItemAtPath: newer.path)
+
+ let match = try #require(ClaudeSessionProjectMapper.newestTranscript(cwd: cwd, homeDirectory: home))
+ #expect(match.url.lastPathComponent == "newer.jsonl")
+ #expect(match.modifiedAt == Date(timeIntervalSince1970: 200))
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift
new file mode 100644
index 0000000000..8da5a1b566
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift
@@ -0,0 +1,144 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct ClaudeSwapAccountProjectionTests {
+ @Test
+ func `adapter failures mark retained account snapshots as stale`() {
+ #expect(ClaudeSwapAccountProjection.displayError(
+ accountError: nil,
+ adapterError: "timed out") == "Showing the last successful update: timed out")
+ #expect(ClaudeSwapAccountProjection.displayError(
+ accountError: "Token expired.",
+ adapterError: "timed out") == "Token expired.")
+ #expect(ClaudeSwapAccountProjection.displayError(
+ accountError: nil,
+ adapterError: "timed out",
+ switchError: "store locked") == "Account switch failed: store locked")
+ #expect(ClaudeSwapAccountProjection.displayError(
+ accountError: "API-key account",
+ adapterError: nil,
+ switchError: "store locked") == "Account switch failed: store locked")
+ }
+
+ private let now = Date(timeIntervalSince1970: 1_782_000_000)
+
+ @Test
+ func `projects rows into provider neutral snapshots with active account first`() throws {
+ let reset = Date(timeIntervalSince1970: 1_782_170_999)
+ let list = ClaudeSwapAccountList(
+ activeAccountNumber: 2,
+ accounts: [
+ ClaudeSwapAccountRow(
+ number: 1,
+ email: "work@example.com",
+ isActive: false,
+ usageStatus: .ok,
+ fiveHour: ClaudeSwapUsageWindow(usedPercent: 25, resetsAt: reset),
+ sevenDay: ClaudeSwapUsageWindow(usedPercent: 16.5, resetsAt: nil)),
+ ClaudeSwapAccountRow(
+ number: 2,
+ email: "personal@example.com",
+ isActive: true,
+ usageStatus: .ok,
+ fiveHour: ClaudeSwapUsageWindow(usedPercent: 80, resetsAt: nil),
+ sevenDay: nil),
+ ])
+
+ let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now)
+ #expect(snapshots.count == 2)
+
+ let active = try #require(snapshots.first)
+ #expect(active.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "2"))
+ #expect(active.provider == .claude)
+ #expect(active.displayLabel == "personal@example.com")
+ #expect(active.isActive == true)
+ #expect(active.canActivate == false)
+ #expect(active.error == nil)
+ #expect(active.sourceLabel == "claude-swap")
+ #expect(active.snapshot?.primary?.usedPercent == 80)
+ #expect(active.snapshot?.primary?.windowMinutes == 300)
+ #expect(active.snapshot?.secondary == nil)
+ #expect(active.snapshot?.updatedAt == self.now)
+ #expect(active.snapshot?.identity?.accountEmail == "personal@example.com")
+ #expect(active.snapshot?.identity?.loginMethod == "claude-swap")
+
+ let inactive = try #require(snapshots.last)
+ #expect(inactive.id.opaqueID == "1")
+ #expect(inactive.isActive == false)
+ #expect(inactive.canActivate == true)
+ #expect(inactive.snapshot?.primary?.resetsAt == reset)
+ #expect(inactive.snapshot?.secondary?.usedPercent == 16.5)
+ #expect(inactive.snapshot?.secondary?.windowMinutes == 10080)
+ }
+
+ @Test
+ func `maps sentinel statuses to per account errors without usage`() throws {
+ let rows: [(ClaudeSwapUsageStatus, String)] = [
+ (.tokenExpired, "Token expired"),
+ (.apiKey, "API-key account"),
+ (.keychainUnavailable, "Keychain"),
+ (.noCredentials, "No stored credentials"),
+ (.unavailable, "Usage fetch failed"),
+ (.unknown("mystery"), "mystery"),
+ ]
+
+ for (index, entry) in rows.enumerated() {
+ let list = ClaudeSwapAccountList(
+ activeAccountNumber: nil,
+ accounts: [
+ ClaudeSwapAccountRow(
+ number: index + 1,
+ email: "a@b.c",
+ isActive: false,
+ usageStatus: entry.0,
+ fiveHour: nil,
+ sevenDay: nil),
+ ])
+ let snapshot = try #require(
+ ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
+ #expect(snapshot.snapshot == nil)
+ let error = try #require(snapshot.error)
+ #expect(error.contains(entry.1))
+ let expectedCanActivate = entry.0 == .apiKey || entry.0 == .unavailable
+ #expect(snapshot.canActivate == expectedCanActivate)
+ }
+ }
+
+ @Test
+ func `ok row without windows reports missing usage instead of an empty card`() throws {
+ let list = ClaudeSwapAccountList(
+ activeAccountNumber: 1,
+ accounts: [
+ ClaudeSwapAccountRow(
+ number: 1,
+ email: "a@b.c",
+ isActive: true,
+ usageStatus: .ok,
+ fiveHour: nil,
+ sevenDay: nil),
+ ])
+
+ let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
+ #expect(snapshot.snapshot == nil)
+ #expect(snapshot.error == "No usage windows reported.")
+ }
+
+ @Test
+ func `falls back to ordinal label when email is empty`() throws {
+ let list = ClaudeSwapAccountList(
+ activeAccountNumber: nil,
+ accounts: [
+ ClaudeSwapAccountRow(
+ number: 3,
+ email: "",
+ isActive: false,
+ usageStatus: .noCredentials,
+ fiveHour: nil,
+ sevenDay: nil),
+ ])
+
+ let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
+ #expect(snapshot.displayLabel == "Account 3")
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift
new file mode 100644
index 0000000000..4870bcfae3
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift
@@ -0,0 +1,201 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+/// Reader tests use fake executables only: no real
+/// claude-swap install, no credentials, no Keychain access.
+struct ClaudeSwapAccountReaderTests {
+ private func makeFakeExecutable(_ script: String) throws -> String {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("claude-swap-reader-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let url = directory.appendingPathComponent("cswap")
+ try "#!/bin/sh\n\(script)\n".write(to: url, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
+ return url.path
+ }
+
+ @Test
+ func `reads and parses a schema v1 list from the executable`() async throws {
+ let path = try self.makeFakeExecutable("""
+ [ "$1" = "--list" ] || exit 2
+ [ "$2" = "--json" ] || exit 2
+ cat <<'EOF'
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok",
+ "usage": {"fiveHour": {"pct": 12.5}}}
+ ]}
+ EOF
+ """)
+
+ let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
+ #expect(list.activeAccountNumber == 1)
+ #expect(list.accounts.first?.fiveHour?.usedPercent == 12.5)
+ }
+
+ @Test
+ func `surfaces the error envelope from a non zero exit`() async throws {
+ let path = try self.makeFakeExecutable("""
+ echo '{"schemaVersion": 1, "error": {"type": "SwitchError", "message": "store locked"}}'
+ exit 1
+ """)
+
+ await #expect(throws: ClaudeSwapListParserError.reportedError(type: "SwitchError", message: "store locked")) {
+ try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
+ }
+ }
+
+ @Test
+ func `terminates executables that exceed the timeout`() async throws {
+ let path = try self.makeFakeExecutable("sleep 30")
+
+ await #expect(throws: (any Error).self) {
+ try await ClaudeSwapAccountReader.readAccountList(executablePath: path, timeout: 0.5)
+ }
+ }
+
+ @Test
+ func `rejects oversized output before parsing`() async throws {
+ let path = try self.makeFakeExecutable("""
+ i=0
+ while [ $i -lt 5000 ]; do
+ printf '%s' '{"filler": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'
+ i=$((i+1))
+ done
+ """)
+
+ await #expect(throws: (any Error).self) {
+ try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
+ }
+ }
+
+ @Test
+ func `fails cleanly when the executable is missing`() async throws {
+ await #expect(throws: (any Error).self) {
+ try await ClaudeSwapAccountReader.readAccountList(
+ executablePath: "/nonexistent/path/to/cswap")
+ }
+ await #expect(throws: ClaudeSwapAccountReaderError.self) {
+ try await ClaudeSwapAccountReader.readAccountList(executablePath: " ")
+ }
+ }
+
+ @Test
+ func `reads the executable version`() async throws {
+ let path = try self.makeFakeExecutable("""
+ [ "$1" = "--version" ] || exit 2
+ echo 'cswap 0.16.0'
+ """)
+
+ let version = await ClaudeSwapAccountReader.readVersion(executablePath: path)
+ #expect(version == "0.16.0")
+ }
+
+ @Test
+ func `switches only by validated numeric slot with fixed arguments`() async throws {
+ let path = try self.makeFakeExecutable("""
+ [ "$1" = "--switch-to" ] || exit 2
+ [ "$2" = "7" ] || exit 2
+ [ "$3" = "--json" ] || exit 2
+ [ -z "$4" ] || exit 2
+ echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":7},"reason":"switched"}'
+ """)
+
+ let result = try await ClaudeSwapAccountReader.switchAccount(
+ executablePath: path,
+ accountNumber: 7)
+
+ #expect(result.switched)
+ #expect(result.fromAccountNumber == 1)
+ #expect(result.toAccountNumber == 7)
+ }
+
+ @Test
+ func `rejects switch result for another slot`() async throws {
+ let path = try self.makeFakeExecutable("""
+ echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":8},"reason":"switched"}'
+ """)
+
+ await #expect(throws: ClaudeSwapSwitchParserError.mismatchedTarget(expected: 7, actual: 8)) {
+ try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 7)
+ }
+ }
+
+ @Test
+ func `surfaces switch error envelope from non zero exit`() async throws {
+ let path = try self.makeFakeExecutable("""
+ echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}'
+ exit 1
+ """)
+
+ await #expect(throws: ClaudeSwapSwitchParserError.reportedError(
+ type: "SwitchError",
+ message: "credentials missing"))
+ {
+ try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2)
+ }
+ }
+
+ @Test
+ func `started credential switch reaches natural exit after caller cancellation`() async throws {
+ let marker = FileManager.default.temporaryDirectory
+ .appendingPathComponent("claude-swap-switch-finished-\(UUID().uuidString)")
+ let path = try self.makeFakeExecutable("""
+ sleep 0.3
+ touch '\(marker.path)'
+ echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}'
+ """)
+ let task = Task {
+ try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2)
+ }
+
+ try await Task.sleep(for: .milliseconds(100))
+ task.cancel()
+ let result = try await task.value
+
+ #expect(result.switched)
+ #expect(FileManager.default.fileExists(atPath: marker.path))
+ }
+
+ @Test
+ func `version probe returns nil when the executable fails`() async throws {
+ let path = try self.makeFakeExecutable("exit 3")
+
+ let version = await ClaudeSwapAccountReader.readVersion(executablePath: path)
+ #expect(version == nil)
+ }
+
+ @Test
+ func `cancellation during version probe prevents account list launch`() async throws {
+ let marker = FileManager.default.temporaryDirectory
+ .appendingPathComponent("claude-swap-list-launched-\(UUID().uuidString)")
+ let path = try self.makeFakeExecutable("""
+ if [ "$1" = "--version" ]; then
+ sleep 30
+ exit 0
+ fi
+ touch '\(marker.path)'
+ echo '{"schemaVersion":1,"activeAccountNumber":null,"accounts":[]}'
+ """)
+ let task = Task {
+ _ = await ClaudeSwapAccountReader.readVersion(executablePath: path)
+ return try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
+ }
+
+ try await Task.sleep(for: .milliseconds(100))
+ task.cancel()
+
+ await #expect(throws: CancellationError.self) {
+ try await task.value
+ }
+ #expect(!FileManager.default.fileExists(atPath: marker.path))
+ }
+
+ @Test
+ func `expands tilde in configured paths`() throws {
+ let resolved = try ClaudeSwapAccountReader.resolvedExecutablePath("~/bin/cswap")
+ #expect(resolved.hasPrefix("/"))
+ #expect(!resolved.contains("~"))
+ #expect(resolved.hasSuffix("/bin/cswap"))
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeSwapListParserTests.swift b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift
new file mode 100644
index 0000000000..7b5853aabb
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift
@@ -0,0 +1,254 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct ClaudeSwapListParserTests {
+ private func parse(_ json: String) throws -> ClaudeSwapAccountList {
+ try ClaudeSwapListParser.parse(Data(json.utf8))
+ }
+
+ @Test
+ func `parses schema v1 list payload`() throws {
+ let json = """
+ {
+ "schemaVersion": 1,
+ "activeAccountNumber": 2,
+ "accounts": [
+ {
+ "number": 1,
+ "email": "work@example.com",
+ "organizationName": "",
+ "organizationUuid": "",
+ "isOrganization": false,
+ "active": false,
+ "usageStatus": "ok",
+ "usage": {
+ "fiveHour": {"pct": 25.0, "resetsAt": "2026-06-22T23:29:59Z", "countdown": "1h"},
+ "sevenDay": {"pct": 16.5, "resetsAt": "2026-06-26T17:59:59Z"}
+ },
+ "usageFetchedAt": "2026-06-22T20:00:00Z",
+ "usageAgeSeconds": 42.0
+ },
+ {
+ "number": 2,
+ "email": "personal@example.com",
+ "active": true,
+ "usageStatus": "ok",
+ "usage": {"fiveHour": {"pct": 80}}
+ }
+ ]
+ }
+ """
+
+ let list = try self.parse(json)
+ #expect(list.activeAccountNumber == 2)
+ #expect(list.accounts.count == 2)
+
+ let first = try #require(list.accounts.first)
+ #expect(first.number == 1)
+ #expect(first.email == "work@example.com")
+ #expect(first.isActive == false)
+ #expect(first.usageStatus == .ok)
+ #expect(first.fiveHour?.usedPercent == 25.0)
+ #expect(first.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_782_170_999))
+ #expect(first.sevenDay?.usedPercent == 16.5)
+
+ let second = try #require(list.accounts.last)
+ #expect(second.isActive == true)
+ #expect(second.fiveHour?.usedPercent == 80)
+ #expect(second.fiveHour?.resetsAt == nil)
+ #expect(second.sevenDay == nil)
+ }
+
+ @Test
+ func `parses empty account list without accounts configured`() throws {
+ let json = """
+ {"schemaVersion": 1, "activeAccountNumber": null, "accounts": []}
+ """
+
+ let list = try self.parse(json)
+ #expect(list.activeAccountNumber == nil)
+ #expect(list.accounts.isEmpty)
+ }
+
+ @Test
+ func `maps usage status sentinels including unknown values`() throws {
+ let json = """
+ {
+ "schemaVersion": 1,
+ "activeAccountNumber": 1,
+ "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "token_expired", "usage": null},
+ {"number": 2, "email": "d@e.f", "active": false, "usageStatus": "api_key", "usage": null},
+ {"number": 3, "email": "g@h.i", "active": false, "usageStatus": "keychain_unavailable", "usage": null},
+ {"number": 4, "email": "j@k.l", "active": false, "usageStatus": "no_credentials", "usage": null},
+ {"number": 5, "email": "m@n.o", "active": false, "usageStatus": "unavailable", "usage": null},
+ {"number": 6, "email": "p@q.r", "active": false, "usageStatus": "brand_new_status", "usage": null}
+ ]
+ }
+ """
+
+ let statuses = try self.parse(json).accounts.map(\.usageStatus)
+ #expect(statuses == [
+ .tokenExpired,
+ .apiKey,
+ .keychainUnavailable,
+ .noCredentials,
+ .unavailable,
+ .unknown("brand_new_status"),
+ ])
+ }
+
+ @Test
+ func `surfaces schema v1 error envelope`() throws {
+ let json = """
+ {"schemaVersion": 1, "error": {"type": "SwitchError", "message": "boom"}}
+ """
+
+ #expect(throws: ClaudeSwapListParserError.reportedError(type: "SwitchError", message: "boom")) {
+ try self.parse(json)
+ }
+ }
+
+ @Test
+ func `rejects unknown schema versions`() throws {
+ let json = """
+ {"schemaVersion": 2, "activeAccountNumber": 1, "accounts": []}
+ """
+
+ #expect(throws: ClaudeSwapListParserError.unsupportedSchemaVersion(2)) {
+ try self.parse(json)
+ }
+ }
+
+ @Test
+ func `rejects payloads without schema version or accounts`() throws {
+ #expect(throws: ClaudeSwapListParserError.missingSchemaVersion) {
+ try self.parse(#"{"accounts": []}"#)
+ }
+ #expect(throws: ClaudeSwapListParserError.malformedShape("missing accounts array")) {
+ try self.parse(#"{"schemaVersion": 1}"#)
+ }
+ #expect(throws: ClaudeSwapListParserError.malformedShape("missing activeAccountNumber")) {
+ try self.parse(#"{"schemaVersion": 1, "accounts": []}"#)
+ }
+ #expect(throws: ClaudeSwapListParserError.notJSONObject) {
+ try self.parse("not json at all")
+ }
+ #expect(throws: ClaudeSwapListParserError.notJSONObject) {
+ try self.parse(#"["schemaVersion", 1]"#)
+ }
+ }
+
+ @Test
+ func `rejects invalid or duplicate account slots`() throws {
+ #expect(throws: ClaudeSwapListParserError.malformedShape("account slot must be positive")) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": null, "accounts": [
+ {"number": 0, "active": false, "usageStatus": "ok"}
+ ]}
+ """)
+ }
+ #expect(throws: ClaudeSwapListParserError.malformedShape("duplicate account slot 1")) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "active": true, "usageStatus": "ok"},
+ {"number": 1, "active": false, "usageStatus": "ok"}
+ ]}
+ """)
+ }
+ #expect(throws: ClaudeSwapListParserError.malformedShape(
+ "activeAccountNumber is not a numeric slot or null"))
+ {
+ try self.parse(#"{"schemaVersion": 1, "activeAccountNumber": "1", "accounts": []}"#)
+ }
+ #expect(throws: ClaudeSwapListParserError.malformedShape("active account fields disagree")) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 2, "accounts": [
+ {"number": 1, "active": true, "usageStatus": "ok"},
+ {"number": 2, "active": false, "usageStatus": "ok"}
+ ]}
+ """)
+ }
+ }
+
+ @Test
+ func `rejects rows with missing required fields`() throws {
+ #expect(throws: (any Error).self) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"email": "a@b.c", "active": true, "usageStatus": "ok"}
+ ]}
+ """)
+ }
+ #expect(throws: (any Error).self) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "usageStatus": "ok"}
+ ]}
+ """)
+ }
+ #expect(throws: (any Error).self) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true}
+ ]}
+ """)
+ }
+ }
+
+ @Test
+ func `rejects invalid percentages and timestamps`() throws {
+ #expect(throws: (any Error).self) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok",
+ "usage": {"fiveHour": {"pct": "not-a-number"}}}
+ ]}
+ """)
+ }
+ #expect(throws: (any Error).self) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok",
+ "usage": {"fiveHour": {"pct": true}}}
+ ]}
+ """)
+ }
+ #expect(throws: (any Error).self) {
+ try self.parse("""
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok",
+ "usage": {"fiveHour": {"pct": 10, "resetsAt": "yesterday-ish"}}}
+ ]}
+ """)
+ }
+ }
+
+ @Test
+ func `clamps out of range percentages`() throws {
+ let json = """
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok",
+ "usage": {"fiveHour": {"pct": 130.5}, "sevenDay": {"pct": -4}}}
+ ]}
+ """
+
+ let row = try #require(self.parse(json).accounts.first)
+ #expect(row.fiveHour?.usedPercent == 100)
+ #expect(row.sevenDay?.usedPercent == 0)
+ }
+
+ @Test
+ func `parses fractional second timestamps`() throws {
+ let json = """
+ {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
+ {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok",
+ "usage": {"fiveHour": {"pct": 10, "resetsAt": "2026-06-22T23:29:59.500Z"}}}
+ ]}
+ """
+
+ let row = try #require(self.parse(json).accounts.first)
+ #expect(row.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_782_170_999.5))
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift b/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift
new file mode 100644
index 0000000000..052fe883e2
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift
@@ -0,0 +1,17 @@
+import CodexBarCore
+import Testing
+@testable import CodexBar
+
+struct ClaudeSwapMenuPrecedenceTests {
+ @Test
+ func `multiple Claude swap accounts take precedence`() {
+ #expect(ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .claude, accountCount: 2))
+ }
+
+ @Test
+ func `precedence requires Claude and multiple swap accounts`() {
+ #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .claude, accountCount: 0))
+ #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .claude, accountCount: 1))
+ #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .openai, accountCount: 2))
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift b/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift
new file mode 100644
index 0000000000..b06982d037
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct ClaudeSwapSwitchParserTests {
+ private func parse(_ json: String) throws -> ClaudeSwapAccountSwitchResult {
+ try ClaudeSwapSwitchParser.parse(Data(json.utf8))
+ }
+
+ @Test
+ func `parses direct switch result without retaining display identity`() throws {
+ let result = try self.parse("""
+ {
+ "schemaVersion": 1,
+ "switched": true,
+ "from": {"number": 1, "email": "old@example.com"},
+ "to": {"number": 2, "email": "new@example.com"},
+ "strategy": "direct",
+ "reason": "switched",
+ "message": "Switched",
+ "warnings": []
+ }
+ """)
+
+ #expect(result == ClaudeSwapAccountSwitchResult(
+ switched: true,
+ fromAccountNumber: 1,
+ toAccountNumber: 2,
+ reason: "switched"))
+ }
+
+ @Test
+ func `accepts unmanaged source and already active no op`() throws {
+ let freshActivation = try self.parse("""
+ {"schemaVersion":1,"switched":true,"from":null,
+ "to":{"number":2},"reason":"switched"}
+ """)
+ #expect(freshActivation.fromAccountNumber == nil)
+ #expect(freshActivation.toAccountNumber == 2)
+
+ let unmanaged = try self.parse("""
+ {"schemaVersion":1,"switched":true,"from":{"number":null},
+ "to":{"number":3},"reason":"switched"}
+ """)
+ #expect(unmanaged.fromAccountNumber == nil)
+ #expect(unmanaged.toAccountNumber == 3)
+
+ let active = try self.parse("""
+ {"schemaVersion":1,"switched":false,"from":{"number":3},
+ "to":{"number":3},"reason":"already-active"}
+ """)
+ #expect(active.switched == false)
+ #expect(active.reason == "already-active")
+ }
+
+ @Test
+ func `surfaces switch error envelope`() {
+ #expect(throws: ClaudeSwapSwitchParserError.reportedError(
+ type: "SwitchError",
+ message: "store locked"))
+ {
+ try self.parse("""
+ {"schemaVersion":1,"error":{"type":"SwitchError","message":"store locked"}}
+ """)
+ }
+ }
+
+ @Test
+ func `rejects malformed or unsupported switch results`() {
+ #expect(throws: ClaudeSwapSwitchParserError.notJSONObject) {
+ try self.parse("not json")
+ }
+ #expect(throws: ClaudeSwapSwitchParserError.missingSchemaVersion) {
+ try self.parse(#"{"switched":true}"#)
+ }
+ #expect(throws: ClaudeSwapSwitchParserError.missingSchemaVersion) {
+ try self.parse(#"{"schemaVersion":true}"#)
+ }
+ #expect(throws: ClaudeSwapSwitchParserError.unsupportedSchemaVersion(2)) {
+ try self.parse(#"{"schemaVersion":2}"#)
+ }
+ #expect(throws: ClaudeSwapSwitchParserError.malformedShape("missing switched flag")) {
+ try self.parse(#"{"schemaVersion":1}"#)
+ }
+ #expect(throws: (any Error).self) {
+ try self.parse("""
+ {"schemaVersion":1,"switched":true,"from":{"number":1},
+ "to":{"number":true},"reason":"switched"}
+ """)
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift
index 0c6033e781..662684a735 100644
--- a/Tests/CodexBarTests/ClaudeUsageTests.swift
+++ b/Tests/CodexBarTests/ClaudeUsageTests.swift
@@ -802,7 +802,7 @@ struct ClaudeUsageTests {
let data = Data(json.utf8)
let info = ClaudeWebAPIFetcher._parseAccountInfoForTesting(data, orgId: "org-123")
#expect(info?.email == "steipete@gmail.com")
- #expect(info?.loginMethod == "Claude Max")
+ #expect(info?.loginMethod == "Claude Max 20x")
}
@Test
diff --git a/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift
new file mode 100644
index 0000000000..68713024d8
--- /dev/null
+++ b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift
@@ -0,0 +1,274 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+@testable import CodexBarCLI
+
+struct ClawRouterUsageFetcherTests {
+ @Test
+ func `parses monthly budget and provider agnostic usage`() throws {
+ let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting(
+ Data(Self.budgetedResponse.utf8),
+ updatedAt: Date(timeIntervalSince1970: 1))
+
+ #expect(parsed.budgetLimitUSD == 25)
+ #expect(parsed.budgetSpentUSD == 0.006)
+ #expect(parsed.budgetRemainingUSD == 24.994)
+ #expect(parsed.requestCount == 6)
+ #expect(parsed.totalTokens == 54191)
+ #expect(parsed.providers.map(\.provider) == ["openai", "anthropic"])
+
+ let snapshot = parsed.toUsageSnapshot()
+ #expect(snapshot.identity?.providerID == .clawrouter)
+ #expect(snapshot.primary?.usedPercent == 0.024)
+ #expect(snapshot.secondary == nil)
+ #expect(snapshot.providerCost?.used == 0.006)
+ #expect(snapshot.providerCost?.limit == 25)
+ #expect(snapshot.clawRouterUsage?.providers.map(\.provider) == ["openai", "anthropic"])
+ #expect(snapshot.dataConfidence == .exact)
+
+ let reset = try #require(snapshot.primary?.resetsAt)
+ let expected = try #require(DateComponents(
+ calendar: Calendar(identifier: .gregorian),
+ timeZone: TimeZone(secondsFromGMT: 0),
+ year: 2026,
+ month: 8,
+ day: 1).date)
+ #expect(reset == expected)
+ }
+
+ @Test
+ func `supports unmetered policies and arbitrary providers`() throws {
+ let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting(
+ Data(Self.unmeteredResponse.utf8),
+ updatedAt: Date(timeIntervalSince1970: 1))
+ let snapshot = parsed.toUsageSnapshot()
+
+ #expect(!parsed.budgetConfigured)
+ #expect(parsed.providers.map(\.provider) == ["replicate", "tavily"])
+ #expect(snapshot.primary == nil)
+ #expect(snapshot.identity?.loginMethod == "Unmetered")
+ #expect(snapshot.providerCost?.used == 1.25)
+ #expect(snapshot.providerCost?.limit == 0)
+ }
+
+ @Test
+ func `usage URL accepts root and versioned base URLs`() throws {
+ #expect(
+ try ClawRouterUsageFetcher._usageURLForTesting(
+ baseURL: #require(URL(string: "https://router.example.com"))).absoluteString ==
+ "https://router.example.com/v1/usage")
+ #expect(
+ try ClawRouterUsageFetcher._usageURLForTesting(
+ baseURL: #require(URL(string: "https://router.example.com/v1"))).absoluteString ==
+ "https://router.example.com/v1/usage")
+ }
+
+ @Test
+ func `fetch sends bearer key and maps authorization failure`() async throws {
+ let transport = ProviderHTTPTransportStub { request in
+ #expect(request.url?.absoluteString == "https://router.example.com/v1/usage")
+ #expect(request.httpMethod == "GET")
+ #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer smoke-key")
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 401,
+ httpVersion: nil,
+ headerFields: nil))
+ return (Data(), response)
+ }
+
+ await #expect(throws: ClawRouterUsageError.invalidCredentials) {
+ _ = try await ClawRouterUsageFetcher.fetchUsage(
+ apiKey: "smoke-key",
+ baseURL: #require(URL(string: "https://router.example.com")),
+ transport: transport)
+ }
+ }
+
+ @Test
+ func `config projects API key and optional base URL`() {
+ let config = ProviderConfig(
+ id: .clawrouter,
+ apiKey: "router-token",
+ enterpriseHost: "https://router.example.com")
+ let environment = ProviderConfigEnvironment.applyProviderConfigOverrides(
+ base: [:],
+ provider: .clawrouter,
+ config: config)
+
+ #expect(environment[ClawRouterSettingsReader.apiKeyEnvironmentKey] == "router-token")
+ #expect(environment[ClawRouterSettingsReader.baseURLEnvironmentKey] == "https://router.example.com")
+ #expect(ProviderTokenResolver.clawRouterToken(environment: environment) == "router-token")
+ }
+
+ @Test
+ func `endpoint override is HTTPS only`() throws {
+ let key = ClawRouterSettingsReader.baseURLEnvironmentKey
+ try ClawRouterSettingsReader.validateEndpointOverride(environment: [key: "router.example.com/v1"])
+ #expect(ClawRouterSettingsReader.baseURL(environment: [key: "router.example.com/v1"]).absoluteString ==
+ "https://router.example.com/v1")
+ #expect(throws: ClawRouterSettingsError.invalidEndpointOverride(key)) {
+ try ClawRouterSettingsReader.validateEndpointOverride(environment: [key: "http://router.example.com"])
+ }
+ }
+
+ @Test
+ @MainActor
+ func `descriptor and settings are registered`() throws {
+ let descriptor = ProviderDescriptorRegistry.descriptor(for: .clawrouter)
+ #expect(descriptor.metadata.displayName == "ClawRouter")
+ #expect(descriptor.cli.aliases.contains("claw-router"))
+
+ let implementation = try #require(ProviderImplementationRegistry.implementation(for: .clawrouter))
+ #expect(implementation.id == .clawrouter)
+ }
+
+ @Test
+ func `usage snapshot preserves ClawRouter detail when cached`() throws {
+ let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting(
+ Data(Self.budgetedResponse.utf8),
+ updatedAt: Date(timeIntervalSince1970: 1))
+ let encoded = try JSONEncoder().encode(parsed.toUsageSnapshot())
+ let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded)
+
+ #expect(decoded.clawRouterUsage == parsed)
+ #expect(decoded.identity?.providerID == .clawrouter)
+ }
+
+ @Test
+ func `text CLI renders budgeted spend and routed usage`() throws {
+ let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting(
+ Data(Self.budgetedResponse.utf8),
+ updatedAt: Date(timeIntervalSince1970: 1))
+
+ let output = Self.renderText(parsed.toUsageSnapshot())
+
+ #expect(output.contains("Spend: $0.01 / $25.00"))
+ #expect(output.contains("Usage: 6 requests · 54K tokens"))
+ #expect(output.contains("Results: 5 succeeded · 1 failed"))
+ #expect(output.contains("Routed providers: openai: 4 · anthropic: 2"))
+ }
+
+ @Test
+ func `text CLI renders unmetered and zero spend without a zero limit`() throws {
+ let unmetered = try ClawRouterUsageFetcher._parseSnapshotForTesting(
+ Data(Self.unmeteredResponse.utf8),
+ updatedAt: Date(timeIntervalSince1970: 1))
+ let zeroSpend = try ClawRouterUsageFetcher._parseSnapshotForTesting(
+ Data(Self.unmeteredResponse.replacingOccurrences(of: "1250000", with: "0").utf8),
+ updatedAt: Date(timeIntervalSince1970: 1))
+
+ let unmeteredOutput = Self.renderText(unmetered.toUsageSnapshot())
+ let zeroSpendOutput = Self.renderText(zeroSpend.toUsageSnapshot())
+
+ #expect(unmeteredOutput.contains("Spend: $1.25"))
+ #expect(unmeteredOutput.contains("Usage: 3 requests · 0 tokens"))
+ #expect(!unmeteredOutput.contains(" / 0.0"))
+ #expect(zeroSpendOutput.contains("Spend: $0.00"))
+ #expect(zeroSpendOutput.contains("Usage: 3 requests · 0 tokens"))
+ #expect(!zeroSpendOutput.contains(" / 0.0"))
+ }
+
+ private static func renderText(_ snapshot: UsageSnapshot) -> String {
+ CLIRenderer.renderText(
+ provider: .clawrouter,
+ snapshot: snapshot,
+ credits: nil,
+ context: RenderContext(
+ header: "ClawRouter (api)",
+ status: nil,
+ useColor: false,
+ resetStyle: .countdown))
+ }
+
+ private static let budgetedResponse = """
+ {
+ "policyId": "openclaw-smoke",
+ "budget": {
+ "configured": true,
+ "ledger": "durable_object",
+ "windowKey": "openclaw/openclaw-smoke/2026-07",
+ "limitMicros": 25000000,
+ "spentMicros": 6000,
+ "remainingMicros": 24994000
+ },
+ "usage": {
+ "ledger": "ready",
+ "summary": {
+ "requestCount": 6,
+ "successCount": 5,
+ "errorCount": 1,
+ "inputTokens": 50000,
+ "outputTokens": 4191,
+ "totalTokens": 54191,
+ "actualCostMicros": 6000
+ },
+ "providers": [
+ {
+ "provider": "anthropic",
+ "requestCount": 2,
+ "successCount": 2,
+ "errorCount": 0,
+ "totalTokens": 12191,
+ "actualCostMicros": 2000
+ },
+ {
+ "provider": "openai",
+ "requestCount": 4,
+ "successCount": 3,
+ "errorCount": 1,
+ "totalTokens": 42000,
+ "actualCostMicros": 4000
+ }
+ ],
+ "events": []
+ }
+ }
+ """
+
+ private static let unmeteredResponse = """
+ {
+ "policyId": "any-provider-policy",
+ "budget": {
+ "configured": false,
+ "ledger": "unmetered",
+ "windowKey": null,
+ "limitMicros": null,
+ "spentMicros": null,
+ "remainingMicros": null
+ },
+ "usage": {
+ "ledger": "ready",
+ "summary": {
+ "requestCount": 3,
+ "successCount": 3,
+ "errorCount": 0,
+ "inputTokens": 0,
+ "outputTokens": 0,
+ "totalTokens": 0,
+ "actualCostMicros": 1250000
+ },
+ "providers": [
+ {
+ "provider": "tavily",
+ "requestCount": 2,
+ "successCount": 2,
+ "errorCount": 0,
+ "totalTokens": 0,
+ "actualCostMicros": 250000
+ },
+ {
+ "provider": "replicate",
+ "requestCount": 1,
+ "successCount": 1,
+ "errorCount": 0,
+ "totalTokens": 0,
+ "actualCostMicros": 1000000
+ }
+ ],
+ "events": []
+ }
+ }
+ """
+}
diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
index 25785e6e55..3adcd9d2f0 100644
--- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
+++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
@@ -259,6 +259,157 @@ struct CodexBarWidgetProviderTests {
#expect(ProviderChoice.opencodego.provider == .opencodego)
}
+ @Test
+ func `provider choice supports devin`() {
+ #expect(ProviderChoice(provider: .devin) == .devin)
+ #expect(ProviderChoice.devin.provider == .devin)
+ }
+
+ @Test
+ func `widget entry carries devin overage balance through providerCost`() {
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .devin,
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [],
+ providerCost: ProviderCostSnapshot(
+ used: 48.0,
+ limit: 0,
+ currencyCode: "USD",
+ period: "Extra usage balance",
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000)))
+ let encoded = try? JSONEncoder().encode(entry)
+ #expect(encoded != nil)
+ let decoded = encoded.flatMap { try? JSONDecoder().decode(WidgetSnapshot.ProviderEntry.self, from: $0) }
+ #expect(decoded?.providerCost?.period == "Extra usage balance")
+ #expect(decoded?.providerCost?.used == 48.0)
+ #expect(decoded?.providerCost?.limit == 0)
+ #expect(decoded?.provider == .devin)
+ }
+
+ @Test
+ func `widget balance formatter renders devin extra usage balance`() {
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .devin,
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [],
+ providerCost: ProviderCostSnapshot(
+ used: 48.0,
+ limit: 0,
+ currencyCode: "USD",
+ period: "Extra usage balance",
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000)))
+ let line = WidgetBalanceFormatter.extraUsageBalance(for: entry)
+ #expect(line?.title == "Extra usage")
+ #expect(line?.value.hasPrefix("Balance: ") == true)
+ #expect(line?.value.contains("48") == true)
+ }
+
+ @Test
+ func `compact credits render Devin extra usage balance`() {
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .devin,
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [],
+ providerCost: ProviderCostSnapshot(
+ used: 48.0,
+ limit: 0,
+ currencyCode: "USD",
+ period: "Extra usage balance",
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000)))
+
+ let display = CompactMetricFormatter.display(for: entry, metric: .credits)
+
+ #expect(display.value.contains("48"))
+ #expect(display.label == "Extra usage balance")
+ #expect(display.detail == nil)
+ }
+
+ @Test
+ func `widget balance formatter does not leak another provider balance`() {
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .factory,
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [],
+ providerCost: ProviderCostSnapshot(
+ used: 12.0,
+ limit: 100.0,
+ currencyCode: "USD",
+ period: "Extra usage balance",
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000)))
+ #expect(WidgetBalanceFormatter.extraUsageBalance(for: entry) == nil)
+ }
+
+ @Test
+ func `provider choice supports Mistral`() {
+ #expect(ProviderChoice(provider: .mistral) == .mistral)
+ #expect(ProviderChoice.mistral.provider == .mistral)
+ }
+
+ @Test
+ func `provider choice supports Kimi`() {
+ #expect(ProviderChoice(provider: .kimi) == .kimi)
+ #expect(ProviderChoice.kimi.provider == .kimi)
+ }
+
+ @Test
+ func `compact Kimi widgets keep established row fit while large widgets show all quotas`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .kimi,
+ updatedAt: now,
+ primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ tertiary: nil,
+ usageRows: [
+ WidgetSnapshot.WidgetUsageRowSnapshot(id: "primary", title: "Weekly", percentLeft: 75),
+ WidgetSnapshot.WidgetUsageRowSnapshot(id: "secondary", title: "Rate Limit", percentLeft: 50),
+ WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-monthly", title: "Monthly", percentLeft: 25),
+ WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-code-7d", title: "Code 7-day", percentLeft: 90),
+ ],
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [])
+
+ let smallRows = WidgetUsageRow.rows(
+ for: entry,
+ limit: WidgetUsageRow.smallWidgetRowLimit(for: entry))
+ let mediumRows = WidgetUsageRow.rows(
+ for: entry,
+ limit: WidgetUsageRow.mediumWidgetRowLimit(for: entry))
+ let largeRows = WidgetUsageRow.rows(for: entry)
+
+ #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == 3)
+ #expect(WidgetUsageRow.mediumWidgetRowLimit(for: entry) == 3)
+ #expect(smallRows.map(\.id) == ["primary", "secondary", "kimi-monthly"])
+ #expect(mediumRows == smallRows)
+ #expect(largeRows.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"])
+ }
+
@Test
func `provider choice excludes unsupported Chutes widgets`() {
#expect(ProviderChoice(provider: .chutes) == nil)
@@ -307,6 +458,42 @@ struct CodexBarWidgetProviderTests {
#expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.alibabatokenplan])
}
+ @Test
+ func `supported providers keep Mistral when it is the only enabled provider`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .mistral,
+ updatedAt: now,
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [])
+ let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.mistral], generatedAt: now)
+
+ #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.mistral])
+ }
+
+ @Test
+ func `supported providers keep Kimi when it is the only enabled provider`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .kimi,
+ updatedAt: now,
+ primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ tertiary: nil,
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [])
+ let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.kimi], generatedAt: now)
+
+ #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.kimi])
+ }
+
@Test
func `codex weekly only widget rows omit session`() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
@@ -371,6 +558,50 @@ struct CodexBarWidgetProviderTests {
#expect(rows == [WidgetUsageRow(id: "weekly", title: "Weekly", percentLeft: 75)])
}
+ @Test
+ func `codex widget session cap lifts at weekly reset without a new snapshot`() {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let weeklyReset = now.addingTimeInterval(3600)
+ let sessionWindow = RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(1800),
+ resetDescription: nil)
+ let weeklyWindow = RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil)
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .codex,
+ updatedAt: now.addingTimeInterval(-7200),
+ primary: sessionWindow,
+ secondary: weeklyWindow,
+ tertiary: nil,
+ usageRows: [
+ WidgetSnapshot.WidgetUsageRowSnapshot(
+ id: "session",
+ title: "Session",
+ percentLeft: 99,
+ window: sessionWindow),
+ WidgetSnapshot.WidgetUsageRowSnapshot(
+ id: "weekly",
+ title: "Weekly",
+ percentLeft: 0,
+ window: weeklyWindow),
+ ],
+ creditsRemaining: nil,
+ codeReviewRemainingPercent: nil,
+ tokenUsage: nil,
+ dailyUsage: [])
+
+ let capped = WidgetUsageRow.rows(for: entry, now: now)
+ let reset = WidgetUsageRow.rows(for: entry, now: weeklyReset)
+
+ #expect(capped.map(\.percentLeft) == [0, 0])
+ #expect(reset.map(\.percentLeft) == [99, 0])
+ }
+
@Test
func `legacy widget usage rows use antigravity grouped slots`() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
@@ -642,3 +873,27 @@ struct CodexBarWidgetProviderTests {
return WidgetSnapshot(entries: [entry], generatedAt: entry.updatedAt)
}
}
+
+extension CodexBarWidgetProviderTests {
+ @Test
+ func `usage history chart mode requires every point to expose cost`() {
+ let costPoints = [
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: 1.2),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: 2.4),
+ ]
+ let tokenPoints = [
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: nil),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: nil),
+ ]
+ let mixedPoints = [
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: 1.2),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: nil),
+ ]
+ let emptyPoints: [WidgetSnapshot.DailyUsagePoint] = []
+
+ #expect(UsageHistoryChartMode.isCostMode(costPoints) == true)
+ #expect(UsageHistoryChartMode.isCostMode(tokenPoints) == false)
+ #expect(UsageHistoryChartMode.isCostMode(mixedPoints) == false)
+ #expect(UsageHistoryChartMode.isCostMode(emptyPoints) == false)
+ }
+}
diff --git a/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift
index 6218335245..31194288bb 100644
--- a/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift
+++ b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift
@@ -26,6 +26,68 @@ struct CodexCombinedMetricHighestUsageTests {
#expect(highest?.usedPercent == 91)
}
+ @Test
+ func `combined codex metric ignores expired weekly lane when ranking highest usage`() {
+ let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-expired-weekly-ranking")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: now,
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-7200)),
+ provider: .codex)
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: now),
+ provider: .claude)
+
+ let highest = store.providerWithHighestUsage(now: now)
+ #expect(highest?.provider == .claude)
+ #expect(highest?.usedPercent == 80)
+ }
+
+ @Test
+ func `combined codex metric excludes an actively binding weekly cap`() {
+ let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-binding-weekly-cap")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: now.addingTimeInterval(7200),
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: now),
+ provider: .claude)
+
+ let highest = store.providerWithHighestUsage(now: now)
+ #expect(highest?.provider == .claude)
+ #expect(highest?.usedPercent == 80)
+ }
+
@Test
func `combined codex metric stays eligible when only one lane is exhausted`() {
let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-one-exhausted")
diff --git a/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift
index 865e6ac15b..68fa4888c9 100644
--- a/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift
+++ b/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift
@@ -114,7 +114,7 @@ struct CodexConsumerProjectionCharacterizationTests {
snapshotOverride: overrideSnapshot,
errorOverride: "Override error"))
- #expect(model.creditsText == "Credits unavailable; keep Codex running to refresh.")
+ #expect(model.creditsText == nil)
#expect(model.tokenUsage == nil)
#expect(model.metrics.contains { $0.id == "code-review" } == false)
#expect(model.subtitleText == "Override error")
diff --git a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift
index 63410b7310..03babc0dd1 100644
--- a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift
+++ b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift
@@ -231,6 +231,245 @@ struct CodexConsumerProjectionTests {
#expect(projection.credits?.remaining == 92239)
}
+ @Test
+ func `exhausted weekly lane caps session display until weekly reset`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-session")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let sessionReset = now.addingTimeInterval(3 * 3600)
+ let weeklyReset = now.addingTimeInterval(4 * 24 * 3600)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 157,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+
+ let projection = store.codexConsumerProjection(surface: .liveCard, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+ let weekly = try #require(projection.rateWindow(for: .weekly))
+
+ #expect(session.remainingPercent == 0)
+ #expect(session.resetsAt == weeklyReset)
+ #expect(weekly.remainingPercent == 0)
+ #expect(weekly.resetsAt == weeklyReset)
+ #expect(projection.planUtilizationLanes.first?.window.usedPercent == 1)
+ }
+
+ @Test
+ func `exhausted weekly lane retargets session reset when session is also exhausted`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-both-exhausted")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let sessionReset = now.addingTimeInterval(42 * 60)
+ let weeklyReset = now.addingTimeInterval(4 * 24 * 3600)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 157,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+
+ let projection = store.codexConsumerProjection(surface: .liveCard, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+
+ #expect(session.remainingPercent == 0)
+ #expect(session.resetsAt == weeklyReset)
+ #expect(session.resetsAt != sessionReset)
+ }
+
+ @Test
+ func `both exhausted lanes use the later session reset`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-binds-later")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let weeklyReset = now.addingTimeInterval(60 * 60)
+ let sessionReset = now.addingTimeInterval(4 * 60 * 60)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: "session reset"),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: "weekly reset"),
+ updatedAt: now),
+ provider: .codex)
+
+ let projection = store.codexConsumerProjection(surface: .liveCard, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+
+ #expect(session.remainingPercent == 0)
+ #expect(session.resetsAt == sessionReset)
+ #expect(session.resetDescription == "session reset")
+ }
+
+ @Test
+ func `both exhausted lanes keep effective reset unknown when session reset is unknown`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-unknown")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(60 * 60),
+ resetDescription: "weekly reset"),
+ updatedAt: now),
+ provider: .codex)
+
+ let projection = store.codexConsumerProjection(surface: .liveCard, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+
+ #expect(session.remainingPercent == 0)
+ #expect(session.resetsAt == nil)
+ #expect(session.resetDescription == nil)
+ }
+
+ @Test
+ func `exhausted weekly lane leaves session reset unknown when weekly reset is unknown`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-unknown-reset")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let sessionReset = now.addingTimeInterval(42 * 60)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: "in 42m"),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: nil,
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+
+ let projection = store.codexConsumerProjection(surface: .liveCard, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+
+ #expect(session.remainingPercent == 0)
+ #expect(session.resetsAt == nil)
+ #expect(session.resetDescription == nil)
+ }
+
+ @Test
+ func `weekly cap lifts after weekly reset even with stale snapshot timestamp`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-cap-stale-snapshot")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let snapshotCapturedAt = now.addingTimeInterval(-2 * 3600)
+ let sessionReset = now.addingTimeInterval(3 * 3600)
+ let weeklyReset = now.addingTimeInterval(-3600)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil),
+ updatedAt: snapshotCapturedAt),
+ provider: .codex)
+ store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now)
+
+ let projection = store.codexConsumerProjection(surface: .menuBar, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+
+ #expect(session.remainingPercent == 99)
+ #expect(session.resetsAt == sessionReset)
+ #expect(projection.menuBarFallback == .none)
+ }
+
+ @Test
+ func `weekly cap does not alter session display when weekly has reset`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-session-uncapped")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let sessionReset = now.addingTimeInterval(3 * 3600)
+ let weeklyReset = now.addingTimeInterval(-3600)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+
+ let projection = store.codexConsumerProjection(surface: .liveCard, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+
+ #expect(session.remainingPercent == 99)
+ #expect(session.resetsAt == sessionReset)
+ }
+
+ @Test
+ func `weekly cap lifts at the weekly reset boundary`() throws {
+ let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-boundary")
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let sessionReset = now.addingTimeInterval(3 * 3600)
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: now,
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-3600)),
+ provider: .codex)
+
+ let projection = store.codexConsumerProjection(surface: .liveCard, now: now)
+ let session = try #require(projection.rateWindow(for: .session))
+
+ #expect(session.remainingPercent == 99)
+ #expect(session.resetsAt == sessionReset)
+ }
+
private func makeStore(suite: String) -> UsageStore {
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
diff --git a/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift
new file mode 100644
index 0000000000..97ad743689
--- /dev/null
+++ b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift
@@ -0,0 +1,54 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+@testable import CodexBarWidget
+
+struct CodexLegacyWidgetSnapshotTests {
+ @Test
+ func `codex widget caps legacy decoded rows without window metadata`() throws {
+ let json = """
+ {
+ "entries": [
+ {
+ "provider": "codex",
+ "updatedAt": "2027-01-15T08:00:00Z",
+ "primary": {
+ "usedPercent": 1,
+ "windowMinutes": 300,
+ "resetsAt": "2027-01-15T09:00:00Z",
+ "resetDescription": null
+ },
+ "secondary": {
+ "usedPercent": 100,
+ "windowMinutes": 10080,
+ "resetsAt": "2027-01-15T10:00:00Z",
+ "resetDescription": null
+ },
+ "tertiary": null,
+ "usageRows": [
+ { "id": "session", "title": "Session", "percentLeft": 99 },
+ { "id": "weekly", "title": "Weekly", "percentLeft": 0 }
+ ],
+ "creditsRemaining": null,
+ "codeReviewRemainingPercent": null,
+ "tokenUsage": null,
+ "dailyUsage": []
+ }
+ ],
+ "enabledProviders": ["codex"],
+ "generatedAt": "2027-01-15T08:00:00Z"
+ }
+ """
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let snapshot = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8))
+ let entry = try #require(snapshot.entries.first)
+ let now = try #require(ISO8601DateFormatter().date(from: "2027-01-15T08:30:00Z"))
+
+ let rows = WidgetUsageRow.rows(for: entry, now: now)
+
+ #expect(entry.usageRows?.allSatisfy { $0.window == nil } == true)
+ #expect(rows.map(\.id) == ["session", "weekly"])
+ #expect(rows.map(\.percentLeft) == [0, 0])
+ }
+}
diff --git a/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift
new file mode 100644
index 0000000000..356c1a5a50
--- /dev/null
+++ b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift
@@ -0,0 +1,161 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct CodexOAuthResetCreditFetchTests {
+ @Test
+ func `app enrichment can rescue reset-credit-only O auth usage`() throws {
+ let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"#
+ let result = try CodexOAuthFetchStrategy._mapResultForTesting(
+ Data(json.utf8),
+ credentials: Self.credentials(),
+ allowEmptyUsageForResetCreditEnrichment: true)
+
+ #expect(result.usage.primary == nil)
+ #expect(result.usage.secondary == nil)
+ #expect(result.usage.codexResetCredits == nil)
+ #expect(result.credits == nil)
+ #expect(result.strategyID == "codex.oauth")
+ }
+
+ @Test
+ func `app defers reset credit GET while CLI attempts it once on failure`() async throws {
+ let credentials = Self.credentials()
+ let recorder = CodexOAuthResetCreditFetchRecorder()
+ let fetcher: @Sendable (CodexOAuthCredentials) async throws -> CodexRateLimitResetCreditsSnapshot = { _ in
+ await recorder.recordRequest()
+ throw CodexOAuthFetchError.serverError(500, nil)
+ }
+
+ let appResult = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting(
+ context: Self.context(runtime: .app),
+ credentials: credentials,
+ fetcher: fetcher)
+ #expect(appResult == nil)
+ #expect(await recorder.requestCount() == 0)
+
+ let cliResult = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting(
+ context: Self.context(runtime: .cli),
+ credentials: credentials,
+ fetcher: fetcher)
+ #expect(cliResult == nil)
+ #expect(await recorder.requestCount() == 1)
+ }
+
+ @Test
+ func `CLI reset credit GET preserves cancellation without retry`() async throws {
+ let recorder = CodexOAuthResetCreditFetchRecorder()
+
+ await #expect(throws: CancellationError.self) {
+ _ = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting(
+ context: Self.context(runtime: .cli),
+ credentials: Self.credentials(),
+ fetcher: { _ in
+ await recorder.recordRequest()
+ throw CancellationError()
+ })
+ }
+ #expect(await recorder.requestCount() == 1)
+ }
+
+ @Test
+ func `reset credit inventory only O auth payload still returns usage result`() throws {
+ let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"#
+ let now = Date()
+ let resetCredits = CodexRateLimitResetCreditsSnapshot(
+ credits: [
+ CodexRateLimitResetCredit(
+ id: "available-no-expiry",
+ resetType: "codex_rate_limits",
+ status: .available,
+ grantedAt: now,
+ expiresAt: nil,
+ redeemStartedAt: nil,
+ redeemedAt: nil,
+ title: nil,
+ description: nil),
+ ],
+ availableCount: 1,
+ updatedAt: now)
+
+ let result = try CodexOAuthFetchStrategy._mapResultForTesting(
+ Data(json.utf8),
+ credentials: Self.credentials(),
+ resetCredits: resetCredits)
+
+ #expect(result.usage.primary == nil)
+ #expect(result.usage.secondary == nil)
+ #expect(result.usage.codexResetCredits?.availableInventory(at: now).count == 1)
+ #expect(result.credits == nil)
+ #expect(result.sourceLabel == "oauth")
+ }
+
+ @Test
+ func `empty reset credits do not mask missing O auth usage`() {
+ let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"#
+ let resetCredits = CodexRateLimitResetCreditsSnapshot(
+ credits: [],
+ availableCount: 0,
+ updatedAt: Date())
+
+ #expect(throws: UsageError.self) {
+ try CodexOAuthFetchStrategy._mapResultForTesting(
+ Data(json.utf8),
+ credentials: Self.credentials(),
+ resetCredits: resetCredits)
+ }
+ }
+
+ @Test
+ func `O auth strategy defers app inventory and CLI follows credits flag`() {
+ let appContext = Self.context(runtime: .app, includeCredits: false, includeOptionalUsage: false)
+ let cliNoCreditsContext = Self.context(runtime: .cli, includeCredits: false, includeOptionalUsage: true)
+ let cliCreditsContext = Self.context(runtime: .cli, includeCredits: true, includeOptionalUsage: false)
+
+ #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(appContext) == false)
+ #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliNoCreditsContext) == false)
+ #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliCreditsContext))
+ }
+
+ private static func context(
+ runtime: ProviderRuntime,
+ includeCredits: Bool = true,
+ includeOptionalUsage: Bool = false) -> ProviderFetchContext
+ {
+ let browserDetection = BrowserDetection(cacheTTL: 0)
+ return ProviderFetchContext(
+ runtime: runtime,
+ sourceMode: .auto,
+ includeCredits: includeCredits,
+ includeOptionalUsage: includeOptionalUsage,
+ webTimeout: 60,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: [:],
+ settings: nil,
+ fetcher: UsageFetcher(),
+ claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
+ browserDetection: browserDetection)
+ }
+
+ private static func credentials() -> CodexOAuthCredentials {
+ CodexOAuthCredentials(
+ accessToken: "access",
+ refreshToken: "refresh",
+ idToken: nil,
+ accountId: "account-123",
+ lastRefresh: Date())
+ }
+}
+
+private actor CodexOAuthResetCreditFetchRecorder {
+ private var count = 0
+
+ func recordRequest() {
+ self.count += 1
+ }
+
+ func requestCount() -> Int {
+ self.count
+ }
+}
diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift
index 2a36905b5c..d6a338b038 100644
--- a/Tests/CodexBarTests/CodexOAuthTests.swift
+++ b/Tests/CodexBarTests/CodexOAuthTests.swift
@@ -83,6 +83,32 @@ struct CodexOAuthTests {
#expect(creds.accountId == nil)
}
+ @Test
+ func `reset-credit token load ignores an API key beside O auth tokens`() throws {
+ let home = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-reset-credit-oauth-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let json = """
+ {
+ "OPENAI_API_KEY": "sk-test",
+ "tokens": {
+ "access_token": "oauth-access-token",
+ "refresh_token": "oauth-refresh-token",
+ "account_id": "account-123"
+ },
+ "last_refresh": "2026-07-01T12:00:00Z"
+ }
+ """
+ try Data(json.utf8).write(to: home.appendingPathComponent("auth.json"))
+
+ let credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: ["CODEX_HOME": home.path])
+
+ #expect(credentials.accessToken == "oauth-access-token")
+ #expect(credentials.refreshToken == "oauth-refresh-token")
+ #expect(credentials.accountId == "account-123")
+ }
+
@Test
func `decodes credits balance string`() throws {
let json = """
@@ -703,56 +729,6 @@ struct CodexOAuthTests {
#expect(result.sourceLabel == "oauth")
}
- @Test
- func `reset credits only O auth payload still returns usage result`() throws {
- let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"#
- let now = Date()
- let resetCredits = CodexRateLimitResetCreditsSnapshot(
- credits: [],
- availableCount: 2,
- updatedAt: now)
- let creds = CodexOAuthCredentials(
- accessToken: "access",
- refreshToken: "refresh",
- idToken: nil,
- accountId: nil,
- lastRefresh: now)
-
- let result = try CodexOAuthFetchStrategy._mapResultForTesting(
- Data(json.utf8),
- credentials: creds,
- resetCredits: resetCredits)
-
- #expect(result.usage.primary == nil)
- #expect(result.usage.secondary == nil)
- #expect(result.usage.codexResetCredits?.availableCount == 2)
- #expect(result.credits == nil)
- #expect(result.sourceLabel == "oauth")
- }
-
- @Test
- func `empty reset credits do not mask missing O auth usage`() {
- let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"#
- let now = Date()
- let resetCredits = CodexRateLimitResetCreditsSnapshot(
- credits: [],
- availableCount: 0,
- updatedAt: now)
- let creds = CodexOAuthCredentials(
- accessToken: "access",
- refreshToken: "refresh",
- idToken: nil,
- accountId: nil,
- lastRefresh: now)
-
- #expect(throws: UsageError.self) {
- try CodexOAuthFetchStrategy._mapResultForTesting(
- Data(json.utf8),
- credentials: creds,
- resetCredits: resetCredits)
- }
- }
-
@Test
func `auto mode only falls back from O auth on auth failures`() {
let strategy = CodexOAuthFetchStrategy()
@@ -777,23 +753,6 @@ struct CodexOAuthTests {
context: context))
}
- @Test
- func `reset credits fetch follows app runtime and CLI credits flag`() {
- let appContext = self.makeContext(includeCredits: false, includeOptionalUsage: false)
- let cliNoCreditsContext = self.makeContext(
- runtime: .cli,
- includeCredits: false,
- includeOptionalUsage: true)
- let cliCreditsContext = self.makeContext(
- runtime: .cli,
- includeCredits: true,
- includeOptionalUsage: false)
-
- #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(appContext))
- #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliNoCreditsContext) == false)
- #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliCreditsContext))
- }
-
@Test
func `non 401 invalid grant refresh failure is treated as revoked`() {
let data = Data(#"{"error":"invalid_grant"}"#.utf8)
diff --git a/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift
index cade97d1a5..c3e407cf4f 100644
--- a/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift
+++ b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift
@@ -120,12 +120,132 @@ struct CodexRateLimitResetCreditsTests {
}
"""
- let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting(Data(json.utf8))
+ let now = try #require(ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z"))
+ let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting(
+ Data(json.utf8),
+ now: now)
#expect(snapshot.availableCount == 2)
#expect(snapshot.credits.count == 4)
#expect(snapshot.credits[0].resetType == "codex_rate_limits")
#expect(snapshot.credits[3].status == .unknown("future_status"))
- #expect(snapshot.nextExpiringAvailableCredit?.id == "RateLimitResetCredit_earlier")
+ #expect(snapshot.nextExpiringAvailableCredit?.id == CodexRateLimitResetCredit.stableID(
+ forProviderID: "RateLimitResetCredit_earlier"))
+ #expect(snapshot.credits.allSatisfy { !$0.id.contains("RateLimitResetCredit_") })
+
+ let usage = UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ codexResetCredits: snapshot,
+ updatedAt: now)
+ let encoded = try JSONEncoder().encode(usage)
+ let encodedText = try #require(String(data: encoded, encoding: .utf8))
+ #expect(!encodedText.contains("RateLimitResetCredit_earlier"))
+ #expect(!String(reflecting: usage).contains("RateLimitResetCredit_earlier"))
+
+ let roundTripped = try JSONDecoder().decode(UsageSnapshot.self, from: encoded)
+ #expect(roundTripped.codexResetCredits?.credits.map(\.id) == snapshot.credits.map(\.id))
+ }
+
+ @Test
+ func `available inventory keeps no-expiry credits and sorts deterministically`() {
+ let now = Date(timeIntervalSince1970: 1_788_134_400)
+ let tiedExpiry = now.addingTimeInterval(3600)
+ let snapshot = CodexRateLimitResetCreditsSnapshot(
+ credits: [
+ Self.credit(id: "nil-b", status: .available, expiresAt: nil),
+ Self.credit(id: "expired", status: .available, expiresAt: now),
+ Self.credit(id: "finite-b", status: .available, expiresAt: tiedExpiry),
+ Self.credit(id: "redeemed", status: .redeemed, expiresAt: now.addingTimeInterval(7200)),
+ Self.credit(id: "nil-a", status: .available, expiresAt: nil),
+ Self.credit(id: "finite-a", status: .available, expiresAt: tiedExpiry),
+ ],
+ availableCount: 99,
+ updatedAt: now)
+
+ let inventory = snapshot.availableInventory(at: now)
+
+ #expect(inventory.count == 4)
+ let expectedFiniteIDs = ["finite-a", "finite-b"]
+ .map(CodexRateLimitResetCredit.stableID(forProviderID:))
+ .sorted()
+ let expectedNoExpiryIDs = ["nil-a", "nil-b"]
+ .map(CodexRateLimitResetCredit.stableID(forProviderID:))
+ .sorted()
+ #expect(inventory.credits.map(\.id) == expectedFiniteIDs + expectedNoExpiryIDs)
+ #expect(inventory.nextExpiringCredit?.id == expectedFiniteIDs.first)
+ }
+
+ @Test
+ func `provider IDs always hash even when shaped like persisted stable IDs`() throws {
+ let canonicalLookingRawID = "codex-reset-credit-v1-" + String(repeating: "a", count: 64)
+ let json = """
+ {
+ "credits": [{
+ "id": "\(canonicalLookingRawID)",
+ "reset_type": "codex_rate_limits",
+ "status": "available",
+ "granted_at": "2026-06-18T00:39:53Z",
+ "expires_at": null
+ }],
+ "available_count": 1
+ }
+ """
+ let now = Date(timeIntervalSince1970: 1_788_134_400)
+
+ let decoded = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting(
+ Data(json.utf8),
+ now: now)
+ let decodedID = try #require(decoded.credits.first?.id)
+ let expectedID = CodexRateLimitResetCredit.stableID(forProviderID: canonicalLookingRawID)
+
+ #expect(decodedID == expectedID)
+ #expect(decodedID != canonicalLookingRawID)
+
+ let publicModel = Self.credit(id: canonicalLookingRawID, status: .available, expiresAt: nil)
+ #expect(publicModel.id == expectedID)
+ #expect(publicModel.id != canonicalLookingRawID)
+
+ let encoded = try JSONEncoder().encode(publicModel)
+ let roundTripped = try JSONDecoder().decode(CodexRateLimitResetCredit.self, from: encoded)
+ #expect(roundTripped.id == expectedID)
+
+ let ordinaryFirst = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil)
+ let ordinarySecond = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil)
+ #expect(ordinaryFirst.id == ordinarySecond.id)
+ #expect(ordinaryFirst.id != "ordinary-provider-id")
+ }
+
+ @Test
+ func `reset credit GET preserves transport cancellation`() async throws {
+ let transport = ProviderHTTPTransportStub { request in
+ #expect(request.httpMethod == "GET")
+ throw URLError(.cancelled)
+ }
+
+ await #expect(throws: CancellationError.self) {
+ _ = try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits(
+ accessToken: "test-token",
+ accountId: "account-123",
+ env: ["CODEX_HOME": "/tmp/codexbar-reset-credit-cancellation-test"],
+ session: transport)
+ }
+ }
+
+ private static func credit(
+ id: String,
+ status: CodexRateLimitResetCreditStatus,
+ expiresAt: Date?) -> CodexRateLimitResetCredit
+ {
+ CodexRateLimitResetCredit(
+ id: id,
+ resetType: "codex_rate_limits",
+ status: status,
+ grantedAt: Date(timeIntervalSince1970: 1_788_000_000),
+ expiresAt: expiresAt,
+ redeemStartedAt: nil,
+ redeemedAt: nil,
+ title: nil,
+ description: nil)
}
}
diff --git a/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift
new file mode 100644
index 0000000000..688a1de8db
--- /dev/null
+++ b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift
@@ -0,0 +1,107 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+@MainActor
+struct CodexResetCreditExpiryNotifierTests {
+ @Test
+ func `posts one bounded summary without persisting or logging raw credit IDs`() throws {
+ let suite = "CodexResetCreditExpiryNotifierTests-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let rawID = "private-provider-credit-id"
+ var posts: [(prefix: String, title: String, body: String)] = []
+ let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { prefix, title, body in
+ posts.append((prefix, title, body))
+ }
+ let snapshot = CodexRateLimitResetCreditsSnapshot(
+ credits: [
+ Self.credit(id: rawID, expiresAt: now.addingTimeInterval(86400)),
+ Self.credit(id: "no-expiry-private-id", expiresAt: nil),
+ ],
+ availableCount: 2,
+ updatedAt: now)
+
+ notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, resetStyle: .countdown, now: now)
+ notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, resetStyle: .countdown, now: now)
+
+ #expect(posts.count == 1)
+ #expect(posts[0].prefix == CodexResetCreditExpiryNotifier.notificationPrefix)
+ #expect(posts[0].title == "Limit Reset Credits")
+ #expect(posts[0].body == "1. Expires in 1d")
+ #expect(!posts[0].prefix.contains(rawID))
+ #expect(!posts[0].body.contains(rawID))
+ let fingerprints = try #require(defaults.stringArray(
+ forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey))
+ let fingerprint = try #require(fingerprints.first)
+ #expect(fingerprints.count == 1)
+ #expect(fingerprint.count == 64)
+ #expect(!fingerprint.contains(rawID))
+ }
+
+ @Test
+ func `switching account inventories does not repeat either notification`() throws {
+ let suite = "CodexResetCreditExpiryNotifierAccountTests-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ var postCount = 0
+ let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { _, _, _ in
+ postCount += 1
+ }
+ let firstAccount = CodexRateLimitResetCreditsSnapshot(
+ credits: [Self.credit(id: "first-account-credit", expiresAt: now.addingTimeInterval(86400))],
+ availableCount: 1,
+ updatedAt: now)
+ let secondAccount = CodexRateLimitResetCreditsSnapshot(
+ credits: [Self.credit(id: "second-account-credit", expiresAt: now.addingTimeInterval(172_800))],
+ availableCount: 1,
+ updatedAt: now)
+
+ notifier.postExpiringCreditsIfNeeded(snapshot: firstAccount, resetStyle: .countdown, now: now)
+ notifier.postExpiringCreditsIfNeeded(snapshot: secondAccount, resetStyle: .countdown, now: now)
+ notifier.postExpiringCreditsIfNeeded(snapshot: firstAccount, resetStyle: .countdown, now: now)
+ notifier.postExpiringCreditsIfNeeded(snapshot: secondAccount, resetStyle: .countdown, now: now)
+
+ #expect(postCount == 2)
+ #expect(defaults.stringArray(forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey)?.count == 2)
+ }
+
+ @Test
+ func `no-expiry inventory does not trigger an expiry notification`() throws {
+ let suite = "CodexResetCreditExpiryNotifierNoExpiryTests-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ var postCount = 0
+ let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { _, _, _ in
+ postCount += 1
+ }
+
+ notifier.postExpiringCreditsIfNeeded(
+ snapshot: CodexRateLimitResetCreditsSnapshot(
+ credits: [Self.credit(id: "no-expiry", expiresAt: nil)],
+ availableCount: 1,
+ updatedAt: now),
+ resetStyle: .countdown,
+ now: now)
+
+ #expect(postCount == 0)
+ #expect(defaults.stringArray(forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey) == nil)
+ }
+
+ private static func credit(id: String, expiresAt: Date?) -> CodexRateLimitResetCredit {
+ CodexRateLimitResetCredit(
+ id: id,
+ resetType: "codex_rate_limits",
+ status: .available,
+ grantedAt: Date(timeIntervalSince1970: 1_781_700_000),
+ expiresAt: expiresAt,
+ redeemStartedAt: nil,
+ redeemedAt: nil,
+ title: nil,
+ description: nil)
+ }
+}
diff --git a/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift
new file mode 100644
index 0000000000..6aae6f23e2
--- /dev/null
+++ b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift
@@ -0,0 +1,271 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+struct CodexResetCreditOutcomeTests {
+ @Test
+ func `supplemental inventory skips stale credentials without issuing a request`() async throws {
+ let recorder = ResetCreditRequestRecorder()
+ let result = try await UsageStore._fetchCodexResetCreditsForTesting(
+ credentials: Self.credentials(lastRefresh: .distantPast),
+ request: { accessToken, accountID, environment in
+ await recorder.record(accessToken: accessToken, accountID: accountID, environment: environment)
+ return Self.resetSnapshot(id: "unexpected", now: Date())
+ })
+
+ #expect(result == nil)
+ #expect(await recorder.count() == 0)
+ }
+
+ @Test
+ func `supplemental inventory uses fresh credentials for one read only request`() async throws {
+ let recorder = ResetCreditRequestRecorder()
+ let now = Date()
+ let expected = Self.resetSnapshot(id: "fresh", now: now)
+ let result = try await UsageStore._fetchCodexResetCreditsForTesting(
+ credentials: Self.credentials(lastRefresh: now),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ request: { accessToken, accountID, environment in
+ await recorder.record(accessToken: accessToken, accountID: accountID, environment: environment)
+ return expected
+ })
+
+ #expect(result == expected)
+ #expect(await recorder.count() == 1)
+ #expect(await recorder.lastAccessToken() == "access")
+ #expect(await recorder.lastAccountID() == "account-123")
+ #expect(await recorder.lastEnvironment()["CODEX_HOME"] == "/tmp/account-a")
+ }
+
+ @Test
+ func `embedded OAuth inventory prevents a duplicate supplemental GET`() async throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let embedded = Self.resetSnapshot(id: "embedded", now: now)
+ let recorder = ResetCreditFetchRecorder()
+
+ let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: embedded, now: now),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ fetcher: { env in
+ await recorder.record(env)
+ return Self.resetSnapshot(id: "supplemental", now: now)
+ })
+
+ #expect(try Self.usage(from: outcome).codexResetCredits == embedded)
+ #expect(await recorder.environments().isEmpty)
+ }
+
+ @Test
+ func `supplemental inventory uses each scoped account environment once`() async throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let recorder = ResetCreditFetchRecorder()
+ let fetcher: UsageStore.CodexResetCreditsFetcher = { env in
+ await recorder.record(env)
+ let home = env["CODEX_HOME"] ?? "missing"
+ return Self.resetSnapshot(id: home, now: now)
+ }
+
+ let first = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: nil, now: now),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ fetcher: fetcher)
+ let second = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: nil, now: now),
+ env: ["CODEX_HOME": "/tmp/account-b"],
+ fetcher: fetcher)
+
+ #expect(try Self.usage(from: first).codexResetCredits?.credits.first?.id ==
+ Self.resetSnapshot(id: "/tmp/account-a", now: now).credits.first?.id)
+ #expect(try Self.usage(from: second).codexResetCredits?.credits.first?.id ==
+ Self.resetSnapshot(id: "/tmp/account-b", now: now).credits.first?.id)
+ #expect(await recorder.environments().compactMap { $0["CODEX_HOME"] } == [
+ "/tmp/account-a",
+ "/tmp/account-b",
+ ])
+ }
+
+ @Test
+ func `failed supplemental GET clears inventory on a successful usage refresh`() async throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let recorder = ResetCreditFetchRecorder()
+ let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: nil, now: now),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ fetcher: { env in
+ await recorder.record(env)
+ throw ResetCreditFetchTestError.failed
+ })
+
+ #expect(try Self.usage(from: outcome).codexResetCredits == nil)
+ #expect(await recorder.environments().count == 1)
+ }
+
+ @Test
+ func `single failed GET restores failure for reset-credit-only O auth usage`() async {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let recorder = ResetCreditFetchRecorder()
+ let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: nil, now: now, primary: nil, strategyID: "codex.oauth"),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ fetcher: { env in
+ await recorder.record(env)
+ throw ResetCreditFetchTestError.failed
+ })
+
+ guard case let .failure(error) = outcome.result else {
+ Issue.record("Expected no-data failure")
+ return
+ }
+ #expect(error is UsageError)
+ #expect(await recorder.environments().count == 1)
+ }
+
+ @Test
+ func `single GET rescues reset-credit-only O auth usage`() async throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let recorder = ResetCreditFetchRecorder()
+ let resetCredits = Self.resetSnapshot(id: "rescued", now: now)
+ let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: nil, now: now, primary: nil, strategyID: "codex.oauth"),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ fetcher: { env in
+ await recorder.record(env)
+ return resetCredits
+ })
+
+ #expect(try Self.usage(from: outcome).codexResetCredits == resetCredits)
+ #expect(await recorder.environments().count == 1)
+ }
+
+ @Test
+ func `supplemental GET cancellation remains a cancelled provider outcome`() async {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: nil, now: now),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ fetcher: { _ in throw CancellationError() })
+
+ guard case let .failure(error) = outcome.result else {
+ Issue.record("Expected cancellation failure")
+ return
+ }
+ #expect(error is CancellationError)
+ }
+
+ @Test
+ func `display preference does not strip embedded inventory or issue a duplicate GET`() async throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let recorder = ResetCreditFetchRecorder()
+ let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded(
+ to: Self.outcome(resetCredits: Self.resetSnapshot(id: "embedded", now: now), now: now),
+ env: ["CODEX_HOME": "/tmp/account-a"],
+ fetcher: { env in
+ await recorder.record(env)
+ return Self.resetSnapshot(id: "supplemental", now: now)
+ })
+
+ #expect(try Self.usage(from: outcome).codexResetCredits == Self.resetSnapshot(id: "embedded", now: now))
+ #expect(await recorder.environments().isEmpty)
+ }
+
+ private static func outcome(
+ resetCredits: CodexRateLimitResetCreditsSnapshot?,
+ now: Date,
+ primary: RateWindow? = nil,
+ strategyID: String = "test") -> ProviderFetchOutcome
+ {
+ let resolvedPrimary = strategyID == "codex.oauth" ? primary : primary ?? RateWindow(
+ usedPercent: 25,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil)
+ return ProviderFetchOutcome(
+ result: .success(ProviderFetchResult(
+ usage: UsageSnapshot(
+ primary: resolvedPrimary,
+ secondary: nil,
+ codexResetCredits: resetCredits,
+ updatedAt: now),
+ credits: nil,
+ dashboard: nil,
+ sourceLabel: "test",
+ strategyID: strategyID,
+ strategyKind: .cli)),
+ attempts: [])
+ }
+
+ private static func resetSnapshot(id: String, now: Date) -> CodexRateLimitResetCreditsSnapshot {
+ CodexRateLimitResetCreditsSnapshot(
+ credits: [CodexRateLimitResetCredit(
+ id: id,
+ resetType: "codex_rate_limits",
+ status: .available,
+ grantedAt: now,
+ expiresAt: now.addingTimeInterval(86400),
+ redeemStartedAt: nil,
+ redeemedAt: nil,
+ title: nil,
+ description: nil)],
+ availableCount: 1,
+ updatedAt: now)
+ }
+
+ private static func credentials(lastRefresh: Date?) -> CodexOAuthCredentials {
+ CodexOAuthCredentials(
+ accessToken: "access",
+ refreshToken: "refresh",
+ idToken: nil,
+ accountId: "account-123",
+ lastRefresh: lastRefresh)
+ }
+
+ private static func usage(from outcome: ProviderFetchOutcome) throws -> UsageSnapshot {
+ switch outcome.result {
+ case let .success(result):
+ result.usage
+ case let .failure(error):
+ throw error
+ }
+ }
+}
+
+private actor ResetCreditRequestRecorder {
+ private var requests: [(accessToken: String, accountID: String?, environment: [String: String])] = []
+
+ func record(accessToken: String, accountID: String?, environment: [String: String]) {
+ self.requests.append((accessToken, accountID, environment))
+ }
+
+ func count() -> Int {
+ self.requests.count
+ }
+
+ func lastAccessToken() -> String? {
+ self.requests.last?.accessToken
+ }
+
+ func lastAccountID() -> String? {
+ self.requests.last?.accountID
+ }
+
+ func lastEnvironment() -> [String: String] {
+ self.requests.last?.environment ?? [:]
+ }
+}
+
+private actor ResetCreditFetchRecorder {
+ private var capturedEnvironments: [[String: String]] = []
+
+ func record(_ env: [String: String]) {
+ self.capturedEnvironments.append(env)
+ }
+
+ func environments() -> [[String: String]] {
+ self.capturedEnvironments
+ }
+}
+
+private enum ResetCreditFetchTestError: Error {
+ case failed
+}
diff --git a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift
index fb097da5df..c288149d44 100644
--- a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift
+++ b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift
@@ -5,143 +5,124 @@ import Testing
struct CodexResetCreditsMenuCardTests {
@Test
- func `reset credits render when optional usage is enabled`() throws {
- let metadata = try #require(ProviderDefaults.metadata[.codex])
+ func `presentation shows only available inventory in stable expiry order`() throws {
let now = Date(timeIntervalSince1970: 1_781_726_400)
- let usage = UsageSnapshot(
- primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
- secondary: nil,
- codexResetCredits: CodexRateLimitResetCreditsSnapshot(
- credits: [
- CodexRateLimitResetCredit(
- id: "reset-1",
- resetType: "codex_rate_limits",
- status: .available,
- grantedAt: now,
- expiresAt: now.addingTimeInterval(86400),
- redeemStartedAt: nil,
- redeemedAt: nil,
- title: "One free rate limit reset",
- description: nil),
- ],
- availableCount: 1,
- updatedAt: now),
- updatedAt: now,
- identity: ProviderIdentitySnapshot(
- providerID: .codex,
- accountEmail: "user@example.com",
- accountOrganization: nil,
- loginMethod: "pro"))
-
- let model = UsageMenuCardView.Model.make(Self.input(
- metadata: metadata,
- snapshot: usage,
- showOptionalUsage: true,
- now: now))
+ let snapshot = Self.snapshot(
+ now: now,
+ credits: [
+ Self.credit(id: "no-expiry", status: .available, now: now, expiresIn: nil),
+ Self.credit(id: "late", status: .available, now: now, expiresIn: 172_800),
+ Self.credit(id: "redeemed", status: .redeemed, now: now, expiresIn: 43200),
+ Self.credit(id: "expired", status: .available, now: now, expiresIn: -1),
+ Self.credit(id: "early", status: .available, now: now, expiresIn: 86400),
+ ],
+ availableCount: 99)
- #expect(model.codexResetCreditsText == "1 available")
- #expect(model.codexResetCreditsDetailText == "Next expires in 1d")
+ let model = try Self.model(snapshot: snapshot, now: now)
+ let presentation = try #require(model.codexResetCredits)
+
+ #expect(presentation.text == "3 available")
+ #expect(presentation.items.map(\.expiryText) == ["Expires in 1d", "Expires in 2d", "No expiry"])
+ #expect(presentation.expirySummaryText == "1d · 2d · No expiry")
+ #expect(presentation.helpText == "1. Expires in 1d\n2. Expires in 2d\n3. No expiry")
+ #expect(presentation.accessibilityLabel.contains(presentation.helpText))
}
@Test
- func `reset credits plural count uses trimmed copy`() throws {
- let metadata = try #require(ProviderDefaults.metadata[.codex])
+ func `no-expiry reset remains visible without a next-expiry date`() throws {
let now = Date(timeIntervalSince1970: 1_781_726_400)
- let usage = UsageSnapshot(
- primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
- secondary: nil,
- codexResetCredits: CodexRateLimitResetCreditsSnapshot(
- credits: [
- CodexRateLimitResetCredit(
- id: "reset-1",
- resetType: "codex_rate_limits",
- status: .available,
- grantedAt: now,
- expiresAt: now.addingTimeInterval(86400),
- redeemStartedAt: nil,
- redeemedAt: nil,
- title: "One free rate limit reset",
- description: nil),
- CodexRateLimitResetCredit(
- id: "reset-2",
- resetType: "codex_rate_limits",
- status: .available,
- grantedAt: now,
- expiresAt: now.addingTimeInterval(172_800),
- redeemStartedAt: nil,
- redeemedAt: nil,
- title: "One free rate limit reset",
- description: nil),
- ],
- availableCount: 2,
- updatedAt: now),
- updatedAt: now,
- identity: ProviderIdentitySnapshot(
- providerID: .codex,
- accountEmail: "user@example.com",
- accountOrganization: nil,
- loginMethod: "pro"))
-
- let model = UsageMenuCardView.Model.make(Self.input(
- metadata: metadata,
- snapshot: usage,
- showOptionalUsage: true,
- now: now))
+ let model = try Self.model(
+ snapshot: Self.snapshot(
+ now: now,
+ credits: [Self.credit(id: "no-expiry", status: .available, now: now, expiresIn: nil)]),
+ now: now)
+ let presentation = try #require(model.codexResetCredits)
- #expect(model.codexResetCreditsText == "2 available")
+ #expect(presentation.text == "1 available")
+ #expect(presentation.items.map(\.expiryText) == ["No expiry"])
+ #expect(presentation.expirySummaryText == "No expiry")
+ #expect(model.hasUsageContent)
}
@Test
- func `reset credits render when optional usage is disabled`() throws {
- let metadata = try #require(ProviderDefaults.metadata[.codex])
+ func `inventory respects absolute reset-time style`() throws {
let now = Date(timeIntervalSince1970: 1_781_726_400)
- let usage = UsageSnapshot(
- primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
- secondary: nil,
- codexResetCredits: CodexRateLimitResetCreditsSnapshot(
- credits: [
- CodexRateLimitResetCredit(
- id: "reset-1",
- resetType: "codex_rate_limits",
- status: .available,
- grantedAt: now,
- expiresAt: now.addingTimeInterval(86400),
- redeemStartedAt: nil,
- redeemedAt: nil,
- title: "One free rate limit reset",
- description: nil),
- CodexRateLimitResetCredit(
- id: "reset-2",
- resetType: "codex_rate_limits",
- status: .available,
- grantedAt: now,
- expiresAt: now.addingTimeInterval(172_800),
- redeemStartedAt: nil,
- redeemedAt: nil,
- title: "One free rate limit reset",
- description: nil),
- ],
- availableCount: 2,
- updatedAt: now),
- updatedAt: now)
+ let expiresAt = now.addingTimeInterval(86400)
+ let model = try Self.model(
+ snapshot: Self.snapshot(
+ now: now,
+ credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]),
+ resetStyle: .absolute,
+ now: now)
+ let presentation = try #require(model.codexResetCredits)
+ let formatted = UsageFormatter.resetDescription(from: expiresAt, now: now)
- let model = UsageMenuCardView.Model.make(Self.input(
- metadata: metadata,
- snapshot: usage,
+ #expect(presentation.items.map(\.expiryText) == ["Expires \(formatted)"])
+ #expect(presentation.expirySummaryText == formatted)
+ }
+
+ @Test
+ func `optional usage preference does not hide reset inventory`() throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let model = try Self.model(
+ snapshot: Self.snapshot(
+ now: now,
+ credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]),
showOptionalUsage: false,
- now: now))
+ now: now)
- #expect(model.codexResetCreditsText == "2 available")
- #expect(model.codexResetCreditsDetailText == "Next expires in 1d")
+ #expect(model.codexResetCredits?.text == "1 available")
+ #expect(model.codexResetCredits?.expirySummaryText == "1d")
}
- private static func input(
- metadata: ProviderMetadata,
+ @Test
+ func `compact expiry summary caps visible dates`() throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let credits = (1...6).map { day in
+ Self.credit(id: "day-\(day)", status: .available, now: now, expiresIn: Double(day * 86400))
+ }
+ let model = try Self.model(snapshot: Self.snapshot(now: now, credits: credits), now: now)
+
+ let presentation = try #require(model.codexResetCredits)
+ #expect(presentation.expirySummaryText == "1d · 2d · 3d · 4d · +2")
+ #expect(presentation.helpText.split(separator: "\n").count == 6)
+ }
+
+ @Test
+ func `hosted usage model keeps reset inventory compatible with live refresh`() throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let model = try Self.model(
+ snapshot: Self.snapshot(
+ now: now,
+ credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]),
+ now: now)
+
+ #expect(model.codexResetCredits != nil)
+ #expect(model.hasCompatibleTrackedLayout(with: model))
+ }
+
+ @Test
+ func `empty filtered inventory does not create hosted reset rows`() throws {
+ let now = Date(timeIntervalSince1970: 1_781_726_400)
+ let model = try Self.model(
+ snapshot: Self.snapshot(
+ now: now,
+ credits: [Self.credit(id: "expired", status: .available, now: now, expiresIn: -1)],
+ availableCount: 1),
+ now: now)
+
+ #expect(model.codexResetCredits == nil)
+ #expect(model.hasCompatibleTrackedLayout(with: model))
+ }
+
+ private static func model(
snapshot: UsageSnapshot,
- showOptionalUsage: Bool,
- now: Date) -> UsageMenuCardView.Model.Input
+ showOptionalUsage: Bool = true,
+ resetStyle: ResetTimeDisplayStyle = .countdown,
+ now: Date) throws -> UsageMenuCardView.Model
{
- UsageMenuCardView.Model.Input(
+ let metadata = try #require(ProviderDefaults.metadata[.codex])
+ return UsageMenuCardView.Model.make(UsageMenuCardView.Model.Input(
provider: .codex,
metadata: metadata,
snapshot: snapshot,
@@ -155,10 +136,43 @@ struct CodexResetCreditsMenuCardTests {
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
- resetTimeDisplayStyle: .countdown,
+ resetTimeDisplayStyle: resetStyle,
tokenCostUsageEnabled: false,
showOptionalCreditsAndExtraUsage: showOptionalUsage,
hidePersonalInfo: false,
- now: now)
+ now: now))
+ }
+
+ private static func snapshot(
+ now: Date,
+ credits: [CodexRateLimitResetCredit],
+ availableCount: Int? = nil) -> UsageSnapshot
+ {
+ UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ codexResetCredits: CodexRateLimitResetCreditsSnapshot(
+ credits: credits,
+ availableCount: availableCount ?? credits.count,
+ updatedAt: now),
+ updatedAt: now)
+ }
+
+ private static func credit(
+ id: String,
+ status: CodexRateLimitResetCreditStatus,
+ now: Date,
+ expiresIn: TimeInterval?) -> CodexRateLimitResetCredit
+ {
+ CodexRateLimitResetCredit(
+ id: id,
+ resetType: "codex_rate_limits",
+ status: status,
+ grantedAt: now.addingTimeInterval(-3600),
+ expiresAt: expiresIn.map(now.addingTimeInterval),
+ redeemStartedAt: nil,
+ redeemedAt: nil,
+ title: nil,
+ description: nil)
}
}
diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift
new file mode 100644
index 0000000000..6e474a0a99
--- /dev/null
+++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift
@@ -0,0 +1,65 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct CodexSessionRolloutTests {
+ @Test
+ func `first rollout line maps to file only agent session`() throws {
+ let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl")
+ let metadata = try #require(CodexRolloutFirstLineParser.read(from: url))
+ let now = Date(timeIntervalSince1970: 10000)
+ let modifiedAt = now.addingTimeInterval(-60)
+ let session = try #require(CodexRolloutFirstLineParser.makeSession(
+ metadata: metadata,
+ transcriptURL: url,
+ modifiedAt: modifiedAt,
+ host: "local-mac",
+ now: now))
+
+ #expect(session.id == "019f-session-fixture")
+ #expect(session.cwd == "/Users/test/Projects/alpha")
+ #expect(session.projectName == "alpha")
+ #expect(session.source == .cli)
+ #expect(session.state == .active)
+ #expect(session.pid == nil)
+ }
+
+ @Test
+ func `file only rollout outside window is excluded while live process remains`() throws {
+ let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl")
+ let metadata = try #require(CodexRolloutFirstLineParser.read(from: url))
+ let now = Date(timeIntervalSince1970: 10000)
+ let modifiedAt = now.addingTimeInterval(-1801)
+
+ #expect(CodexRolloutFirstLineParser.makeSession(
+ metadata: metadata,
+ transcriptURL: url,
+ modifiedAt: modifiedAt,
+ host: "local-mac",
+ now: now) == nil)
+ #expect(CodexRolloutFirstLineParser.makeSession(
+ metadata: metadata,
+ transcriptURL: url,
+ modifiedAt: modifiedAt,
+ pid: 42,
+ host: "local-mac",
+ now: now)?.state == .idle)
+ }
+
+ @Test
+ func `app server presence classifies unknown file only rollout as desktop`() {
+ #expect(AgentSessionCorrelation.fileOnlyCodexSource(
+ metadataSource: .unknown,
+ appServerPresent: true) == .desktopApp)
+ #expect(AgentSessionCorrelation.fileOnlyCodexSource(
+ metadataSource: .unknown,
+ appServerPresent: false) == .unknown)
+ }
+
+ @Test
+ func `codex cwd matching rejects missing paths`() {
+ #expect(AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", "/repo/./alpha"))
+ #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch(nil, nil))
+ #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil))
+ }
+}
diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift
index a8e81dbb73..ec8f569e14 100644
--- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift
+++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift
@@ -207,6 +207,38 @@ struct CodexUserFacingErrorTests {
model.creditsText == "Codex usage is temporarily unavailable. Try refreshing. Cached values from 1m ago.")
}
+ @Test
+ func `menu card hides optional codex setup diagnostics kept by providers pane`() throws {
+ let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-menu-diagnostics")
+ let store = self.makeUsageStore(settings: settings)
+ store.lastCreditsError = UsageError.noRateLimitsFound.errorDescription
+ store.lastOpenAIDashboardError =
+ "No matching OpenAI web session found. Sign in to chatgpt.com, then refresh OpenAI cookies."
+
+ let fetcher = UsageFetcher(environment: [:])
+ let menuModel = try withStatusItemControllerForTesting(
+ store: store,
+ settings: settings,
+ fetcher: fetcher)
+ { controller in
+ try #require(controller.menuCardModel(for: .codex))
+ }
+ let pane = ProvidersPane(settings: settings, store: store)
+ let settingsModel = pane._test_menuCardModel(for: .codex)
+ let settingsDiagnostic = pane._test_openAIWebDiagnostic(for: .codex)
+ let settingsInfoRows = ProviderMetricsInlineView.infoRows(
+ for: settingsModel,
+ openAIWebDiagnostic: settingsDiagnostic)
+
+ #expect(menuModel.creditsText == nil)
+ #expect(menuModel.creditsHintText == nil)
+ #expect(settingsModel.creditsText == UsageError.noRateLimitsFound.errorDescription)
+ #expect(settingsModel.creditsHintText?.contains("No matching OpenAI web session found") == true)
+ #expect(settingsInfoRows.contains { row in
+ row.id == .openAIWeb && row.value.contains("No matching OpenAI web session found")
+ })
+ }
+
@Test
func `providers pane codex error display keeps raw full text for copy`() {
let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-pane-error-display")
diff --git a/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift
new file mode 100644
index 0000000000..9f46cb6e63
--- /dev/null
+++ b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift
@@ -0,0 +1,219 @@
+import AppKit
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+@MainActor
+@Suite(.serialized)
+struct CodexWeeklyCapSurfaceTests {
+ @Test
+ func `menu card session metric shows weekly cap and reset`() throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let metadata = try #require(ProviderDefaults.metadata[.codex])
+ let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(3 * 60 * 60),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-2 * 60 * 60))
+ let projection = CodexConsumerProjection.make(
+ surface: .liveCard,
+ context: CodexConsumerProjection.Context(
+ snapshot: snapshot,
+ rawUsageError: nil,
+ liveCredits: nil,
+ rawCreditsError: nil,
+ liveDashboard: nil,
+ rawDashboardError: nil,
+ dashboardAttachmentAuthorized: false,
+ dashboardRequiresLogin: false,
+ now: now))
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .codex,
+ metadata: metadata,
+ snapshot: snapshot,
+ codexProjection: projection,
+ 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))
+
+ let session = try #require(model.metrics.first { $0.id == "primary" })
+ let weekly = try #require(model.metrics.first { $0.id == "secondary" })
+ #expect(session.percent == 0)
+ #expect(session.resetText == weekly.resetText)
+ #expect(session.resetText != nil)
+ }
+
+ @Test
+ func `primary menu bar metric and credits follow binding weekly reset`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-menu-bar"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+ settings.setMenuBarMetricPreference(.primary, for: .codex)
+
+ if let codexMeta = ProviderRegistry.shared.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ defer { controller.releaseStatusItemsForTesting() }
+
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let weeklyReset = now.addingTimeInterval(3600)
+ let sessionReset = now.addingTimeInterval(1800)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-7200))
+ store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now)
+
+ let capped = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now)
+ let reset = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: weeklyReset)
+ let cappedCredits = controller.menuBarCreditsRemainingForIcon(
+ provider: .codex,
+ snapshot: snapshot,
+ now: now)
+ let resetCredits = controller.menuBarCreditsRemainingForIcon(
+ provider: .codex,
+ snapshot: snapshot,
+ now: weeklyReset)
+
+ #expect(capped?.remainingPercent == 0)
+ #expect(capped?.resetsAt == weeklyReset)
+ #expect(reset?.remainingPercent == 99)
+ #expect(reset?.resetsAt == sessionReset)
+ #expect(cappedCredits == 80)
+ #expect(resetCredits == nil)
+ }
+
+ @Test
+ func `combined menu bar modes ignore exhausted weekly lane after its reset`() throws {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-combined-reset"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+ settings.usageBarsShowUsed = false
+ settings.resetTimesShowAbsolute = false
+ settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex)
+
+ if let codexMeta = ProviderRegistry.shared.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ defer { controller.releaseStatusItemsForTesting() }
+
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let sessionReset = now.addingTimeInterval(3600)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: sessionReset,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: now,
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-7200))
+ store._setSnapshotForTesting(snapshot, provider: .codex)
+
+ let selected = try #require(controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now))
+ #expect(selected.remainingPercent == 99)
+ #expect(selected.resetsAt == sessionReset)
+
+ settings.menuBarDisplayMode = .percent
+ #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "5h 99%")
+ settings.menuBarDisplayMode = .pace
+ #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%")
+ settings.menuBarDisplayMode = .both
+ #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%")
+ settings.menuBarDisplayMode = .resetTime
+ #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "↻ in 1h")
+
+ settings.setMenuBarMetricPreference(.primary, for: .codex)
+ settings.menuBarDisplayMode = .percent
+ let expiredSessionSnapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 300,
+ resetsAt: now,
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 40,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60),
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-7200))
+ store._setSnapshotForTesting(expiredSessionSnapshot, provider: .codex)
+ store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now)
+
+ let resetPrimary = try #require(controller.menuBarMetricWindow(
+ for: .codex,
+ snapshot: expiredSessionSnapshot,
+ now: now))
+ let resetIcon = IconRemainingResolver.resolvedRemaining(
+ snapshot: expiredSessionSnapshot,
+ style: .codex,
+ now: now)
+ #expect(resetPrimary.remainingPercent == 60)
+ #expect(controller.menuBarDisplayText(for: .codex, snapshot: expiredSessionSnapshot, now: now) == "60%")
+ #expect(resetIcon.primary == 60)
+ #expect(resetIcon.secondary == nil)
+ #expect(store.codexConsumerProjection(surface: .menuBar, now: now).menuBarFallback == .none)
+ }
+}
diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift
index 0d7f76db1d..7502ab9155 100644
--- a/Tests/CodexBarTests/CodexbarTests.swift
+++ b/Tests/CodexBarTests/CodexbarTests.swift
@@ -636,6 +636,35 @@ struct CodexBarTests {
#expect(remaining.secondary == nil)
}
+ @Test
+ func `codex icon caps session only until exhausted weekly lane resets`() {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let weeklyReset = now.addingTimeInterval(3600)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(1800),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: weeklyReset,
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-7200))
+
+ let capped = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .codex, now: now)
+ let reset = IconRemainingResolver.resolvedRemaining(
+ snapshot: snapshot,
+ style: .codex,
+ now: weeklyReset)
+
+ #expect(capped.primary == 0)
+ #expect(capped.secondary == 0)
+ #expect(reset.primary == 99)
+ #expect(reset.secondary == nil)
+ }
+
@Test
func `status overlays cut halos through the quota bar and keep glyphs visible`() throws {
let plain = IconRenderer.makeIcon(
diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift
index 1e1c0a500c..8cdab9e3df 100644
--- a/Tests/CodexBarTests/ConfigValidationTests.swift
+++ b/Tests/CodexBarTests/ConfigValidationTests.swift
@@ -3,6 +3,47 @@ import Foundation
import Testing
struct ConfigValidationTests {
+ @Test
+ func `fresh config defaults Alibaba Token Plan to International`() throws {
+ let config = CodexBarConfig.makeDefault()
+ let provider = try #require(config.providerConfig(for: .alibabatokenplan))
+ let issues = CodexBarConfigValidator.validate(config)
+
+ #expect(provider.region == AlibabaTokenPlanAPIRegion.international.rawValue)
+ #expect(!issues.contains(where: { $0.provider == .alibabatokenplan }))
+ }
+
+ @Test
+ func `normalization preserves legacy Alibaba Token Plan region`() throws {
+ let config = CodexBarConfig(providers: [
+ ProviderConfig(id: .alibabatokenplan, region: nil),
+ ]).normalized()
+ let provider = try #require(config.providerConfig(for: .alibabatokenplan))
+
+ #expect(provider.region == nil)
+ }
+
+ @Test
+ func `normalization adds missing Alibaba Token Plan as China mainland`() throws {
+ let config = CodexBarConfig(providers: [
+ ProviderConfig(id: .codex),
+ ]).normalized()
+ let provider = try #require(config.providerConfig(for: .alibabatokenplan))
+
+ #expect(provider.region == AlibabaTokenPlanAPIRegion.chinaMainland.rawValue)
+ }
+
+ @Test
+ func `reports invalid Alibaba Token Plan region`() {
+ var config = CodexBarConfig.makeDefault()
+ config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: "nowhere"))
+ let issues = CodexBarConfigValidator.validate(config)
+
+ #expect(issues.contains(where: {
+ $0.provider == .alibabatokenplan && $0.code == "invalid_region"
+ }))
+ }
+
@Test
func `reports unsupported source`() {
var config = CodexBarConfig.makeDefault()
diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift
index 188532812f..9718028687 100644
--- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift
+++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift
@@ -163,6 +163,43 @@ struct CostHistoryChartMenuViewTests {
daily: daily) == .hidden)
}
+ @Test
+ @MainActor
+ func `y-axis tick values are empty for flat or no data`() {
+ #expect(CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 0).isEmpty)
+ #expect(CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: -1).isEmpty)
+ }
+
+ @Test
+ @MainActor
+ func `y-axis tick values use two ticks for small ranges`() {
+ let ticks = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 0.50)
+ #expect(ticks == [0, 0.50])
+ }
+
+ @Test
+ @MainActor
+ func `y-axis tick values use three ticks for ranges at or above one dollar`() {
+ let ticks = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 12.0)
+ #expect(ticks == [0, 6.0, 12.0])
+
+ let large = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 1000.0)
+ #expect(large == [0, 500.0, 1000.0])
+ }
+
+ @Test(arguments: [
+ (0.0, "$0"),
+ (12.56, "$13"),
+ (0.50, "$0.50"),
+ ])
+ @MainActor
+ func `y-axis cost labels preserve cents only for nonzero sub-dollar values`(
+ value: Double,
+ expected: String)
+ {
+ #expect(CostHistoryChartMenuView._yAxisCostStringForTesting(value) == expected)
+ }
+
@Test
@MainActor
func `cost history total card height grows with rows and the total line`() {
@@ -181,5 +218,46 @@ struct CostHistoryChartMenuViewTests {
modeSubtitlePresence: [false],
hasTotal: true)
#expect(withTotal > withoutTotal)
+
+ let withProjects = CostHistoryChartMenuView._totalCardHeightForTesting(
+ modeSubtitlePresence: [false],
+ hasTotal: true,
+ projectCount: 3)
+ #expect(withProjects > withTotal)
+
+ let withProjectSources = CostHistoryChartMenuView._totalCardHeightForTesting(
+ modeSubtitlePresence: [false],
+ hasTotal: true,
+ projectSourceCounts: [3])
+ #expect(withProjectSources > withProjects)
+ }
+
+ @Test
+ @MainActor
+ func `single differing project source remains visible`() {
+ let matching = Self.project(path: "/tmp/main", sourcePath: "/tmp/main")
+ let differing = Self.project(path: "/tmp/main", sourcePath: "/tmp/worktree")
+
+ #expect(CostHistoryChartMenuView.visibleProjectSources(matching).isEmpty)
+ #expect(CostHistoryChartMenuView.visibleProjectSources(differing).compactMap(\.path) == ["/tmp/worktree"])
+ }
+
+ private static func project(path: String, sourcePath: String) -> CostUsageProjectBreakdown {
+ CostUsageProjectBreakdown(
+ name: "Project",
+ path: path,
+ totalTokens: 10,
+ totalCostUSD: 0.1,
+ daily: [],
+ modelBreakdowns: nil,
+ sources: [
+ CostUsageProjectSourceBreakdown(
+ name: "Source",
+ path: sourcePath,
+ totalTokens: 10,
+ totalCostUSD: 0.1,
+ daily: [],
+ modelBreakdowns: nil),
+ ])
}
}
diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift
index b7252a246d..7ecd2fcdcf 100644
--- a/Tests/CodexBarTests/CostUsageCacheTests.swift
+++ b/Tests/CodexBarTests/CostUsageCacheTests.swift
@@ -100,6 +100,26 @@ struct CostUsageCacheTests {
#expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3])
}
+ @Test
+ func `current codex cache accepts project metadata migration producer`() throws {
+ let root = try self.makeTemporaryCacheRoot()
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ var cache = CostUsageCache()
+ cache.lastScanUnixMs = 123
+ cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]]
+ CostUsageCacheIO.save(
+ provider: .codex,
+ cache: cache,
+ cacheRoot: root,
+ producerKey: "codex:cu:pc54070a94f6419ea")
+
+ let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root)
+
+ #expect(loaded.lastScanUnixMs == 123)
+ #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3])
+ }
+
@Test
func `non codex cache does not require producer key`() throws {
let root = try self.makeTemporaryCacheRoot()
diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift
index df7761a316..46c56ef043 100644
--- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift
+++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift
@@ -71,6 +71,46 @@ struct CostUsageFetcherCacheSnapshotTests {
#expect(managed == nil)
}
+ @Test
+ func `cached codex token snapshot omits projects until metadata migration`() async throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+
+ let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
+ try Self.writeCodexSessionFile(
+ homeRoot: env.codexHomeRoot,
+ env: env,
+ day: day,
+ filename: "cached.jsonl",
+ tokens: 42)
+
+ let options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ cacheRoot: env.cacheRoot)
+ _ = try await CostUsageFetcher.loadTokenSnapshot(
+ provider: .codex,
+ now: day,
+ historyDays: 1,
+ scannerOptions: options)
+
+ let current = await CostUsageFetcher.loadCachedCodexTokenSnapshot(
+ now: day,
+ historyDays: 1,
+ scannerOptions: options)
+ #expect(current?.projects.count == 1)
+
+ var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ cache.codexProjectMetadataVersion = nil
+ CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot)
+
+ let legacy = await CostUsageFetcher.loadCachedCodexTokenSnapshot(
+ now: day,
+ historyDays: 1,
+ scannerOptions: options)
+ #expect(legacy?.sessionTokens == 42)
+ #expect(legacy?.projects.isEmpty == true)
+ }
+
@Test
func `cached codex token snapshot refuses mismatched roots fingerprint`() async throws {
let env = try CostUsageTestEnvironment()
diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift
index 387e5bf28d..b5295f1cd6 100644
--- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift
+++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift
@@ -93,13 +93,206 @@ struct CostUsagePerformanceGateTests {
#expect(advanced.lastRowID == scanned.lastRowID + 1)
}
+ @Test
+ func `cached daily report resolves and uses the pricing catalog once`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+ let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
+ let model = "perf-custom-model"
+ _ = try Self.writeSyntheticCodexCorpus(
+ env: env,
+ day: day,
+ files: 3,
+ turnsPerFile: 4,
+ model: model)
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ claudeProjectsRoots: nil,
+ cacheRoot: env.cacheRoot,
+ codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"))
+ options.refreshMinIntervalSeconds = 0
+ _ = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+
+ let catalogJSON = """
+ {
+ "openai": {
+ "id": "openai",
+ "models": {
+ "\(model)": {
+ "id": "\(model)",
+ "cost": { "input": 10, "output": 50, "cache_read": 1 }
+ }
+ }
+ }
+ }
+ """
+ let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(catalogJSON.utf8))
+ let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ let cachedUsage = try #require(cache.files.values.first { !($0.codexRows?.isEmpty ?? true) })
+ let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
+ #expect(!CostUsageScanner.needsCodexCostCache(cachedUsage, range: range))
+ var catalogLoadCount = 0
+ let report = CostUsageScanner.buildCodexReportFromCache(
+ cache: cache,
+ range: range,
+ modelsDevCacheRoot: env.cacheRoot,
+ modelsDevCatalogLoader: { _ in
+ catalogLoadCount += 1
+ return catalog
+ })
+
+ #expect(report.summary?.totalCostUSD != nil)
+ #expect(catalogLoadCount == 1)
+ }
+
+ @Test
+ func `cached daily report uses complete aggregates without loading pricing`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+ let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
+ _ = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 4)
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ claudeProjectsRoots: nil,
+ cacheRoot: env.cacheRoot,
+ codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"))
+ options.refreshMinIntervalSeconds = 0
+ let scanned = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+
+ let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ var catalogLoadCount = 0
+ let cached = CostUsageScanner.buildCodexReportFromCache(
+ cache: cache,
+ range: CostUsageScanner.CostUsageDayRange(since: day, until: day),
+ modelsDevCacheRoot: env.cacheRoot,
+ modelsDevCatalogLoader: { _ in
+ catalogLoadCount += 1
+ return ModelsDevCatalog(providers: [:])
+ })
+
+ #expect(cached.data.map(\.totalTokens) == scanned.data.map(\.totalTokens))
+ #expect(cached.summary?.totalTokens == scanned.summary?.totalTokens)
+ #expect(abs((cached.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001)
+ #expect(catalogLoadCount == 0)
+ }
+
+ @Test
+ func `legacy missing aggregate cost backfills rows before threshold pricing`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+ let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
+ _ = try Self.writeSyntheticCodexCorpus(
+ env: env,
+ day: day,
+ files: 2,
+ turnsPerFile: 1,
+ model: "openai/gpt-5.5",
+ inputTokensPerTurn: 200_000)
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ claudeProjectsRoots: nil,
+ cacheRoot: env.cacheRoot,
+ codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"))
+ options.refreshMinIntervalSeconds = 0
+ let scanned = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+
+ var legacy = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ for path in legacy.files.keys {
+ legacy.files[path]?.codexCostCacheComplete = nil
+ legacy.files[path]?.codexCostNanos = nil
+ legacy.files[path]?.codexStandardCostNanos = nil
+ legacy.files[path]?.codexPriorityCostNanos = nil
+ }
+ let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
+ #expect(legacy.files.values.allSatisfy { CostUsageScanner.needsCodexCostCache($0, range: range) })
+
+ let backfilled = CostUsageScanner.buildCodexReportFromCache(cache: legacy, range: range)
+
+ #expect(abs((backfilled.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001)
+
+ var mixed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ let mixedPaths = mixed.files.keys.sorted()
+ let legacyPath = try #require(mixedPaths.first)
+ let rowlessPath = try #require(mixedPaths.last)
+ #expect(legacyPath != rowlessPath)
+ mixed.files[legacyPath]?.codexCostCacheComplete = nil
+ mixed.files[legacyPath]?.codexCostNanos = nil
+ mixed.files[legacyPath]?.codexStandardCostNanos = nil
+ mixed.files[legacyPath]?.codexPriorityCostNanos = nil
+ mixed.files[rowlessPath]?.codexRows = nil
+
+ let mixedBackfilled = CostUsageScanner.buildCodexReportFromCache(cache: mixed, range: range)
+ #expect(abs((mixedBackfilled.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001)
+
+ let aggregateCost = CostUsagePricing.codexCostUSD(
+ model: "gpt-5.5",
+ inputTokens: 400_000,
+ cachedInputTokens: 0,
+ outputTokens: 20)
+ #expect(abs((backfilled.summary?.totalCostUSD ?? 0) - (aggregateCost ?? 0)) > 0.1)
+ }
+
+ @Test
+ func `project rollups resolve the pricing catalog once per build`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+ let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
+ _ = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 4)
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ claudeProjectsRoots: nil,
+ cacheRoot: env.cacheRoot,
+ codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"))
+ options.refreshMinIntervalSeconds = 0
+ _ = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+
+ let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ var catalogLoadCount = 0
+ let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
+ cache: cache,
+ range: CostUsageScanner.CostUsageDayRange(since: day, until: day),
+ modelsDevCacheRoot: env.cacheRoot,
+ modelsDevCatalogLoader: { _ in
+ catalogLoadCount += 1
+ return ModelsDevCatalog(providers: [:])
+ })
+
+ #expect(!projects.isEmpty)
+ #expect(catalogLoadCount == 1)
+ }
+
private static func writeSyntheticCodexCorpus(
env: CostUsageTestEnvironment,
day: Date,
files: Int,
- turnsPerFile: Int) throws -> [URL]
+ turnsPerFile: Int,
+ model: String = "openai/gpt-5.2-codex",
+ inputTokensPerTurn: Int = 100) throws -> [URL]
{
- let model = "openai/gpt-5.2-codex"
let baseISO = env.isoString(for: day)
var fileURLs: [URL] = []
for fileIndex in 0.. [String: Any] {
+ [
+ "type": "session_meta",
+ "timestamp": timestamp,
+ "payload": [
+ "id": id,
+ "cwd": cwd,
+ ],
+ ]
+ }
+
private func codexTokenCount(
timestamp: String,
model: String,
@@ -195,6 +206,186 @@ struct CostUsageScannerBreakdownTests {
#expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0))
}
+ @Test
+ func `codex project breakdowns group by cwd and preserve daily totals`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+
+ let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2)
+ let iso0 = env.isoString(for: day)
+ let iso1 = env.isoString(for: day.addingTimeInterval(1))
+ let iso2 = env.isoString(for: day.addingTimeInterval(2))
+ let model = "gpt-5.4"
+ let projectA = env.root.appendingPathComponent("client-a", isDirectory: true).path
+ let projectB = env.root.appendingPathComponent("client-b", isDirectory: true).path
+ let projectAWorktree = env.root
+ .appendingPathComponent(".codex/worktrees/abcd/client-a", isDirectory: true)
+ .path
+
+ try self.makeGitRepositoryWithWorktree(projectPath: projectA, worktreePath: projectAWorktree)
+
+ func sessionMeta(id: String, cwd: String?) -> [String: Any] {
+ var payload: [String: Any] = ["id": id]
+ if let cwd { payload["cwd"] = cwd }
+ return [
+ "type": "session_meta",
+ "timestamp": iso0,
+ "payload": payload,
+ ]
+ }
+
+ let firstA = try env.writeCodexSessionFile(
+ day: day,
+ filename: "client-a-1.jsonl",
+ contents: env.jsonl([
+ sessionMeta(id: "client-a-1", cwd: projectA),
+ self.codexTurnContext(timestamp: iso0, model: model),
+ self.codexTokenCount(
+ timestamp: iso1,
+ model: model,
+ total: (input: 10, cached: 0, output: 1)),
+ ]))
+ _ = try env.writeCodexSessionFile(
+ day: day,
+ filename: "client-a-2.jsonl",
+ contents: env.jsonl([
+ sessionMeta(id: "client-a-2", cwd: projectA + "/."),
+ self.codexTurnContext(timestamp: iso0, model: model),
+ self.codexTokenCount(
+ timestamp: iso1,
+ model: model,
+ total: (input: 20, cached: 0, output: 2)),
+ ]))
+ _ = try env.writeCodexSessionFile(
+ day: day,
+ filename: "client-a-worktree.jsonl",
+ contents: env.jsonl([
+ sessionMeta(id: "client-a-worktree", cwd: projectAWorktree),
+ self.codexTurnContext(timestamp: iso0, model: model),
+ self.codexTokenCount(
+ timestamp: iso1,
+ model: model,
+ total: (input: 12, cached: 0, output: 1)),
+ ]))
+ _ = try env.writeCodexSessionFile(
+ day: day,
+ filename: "client-b.jsonl",
+ contents: env.jsonl([
+ sessionMeta(id: "client-b", cwd: projectB),
+ self.codexTurnContext(timestamp: iso0, model: model),
+ self.codexTokenCount(
+ timestamp: iso1,
+ model: model,
+ total: (input: 5, cached: 0, output: 5)),
+ ]))
+ _ = try env.writeCodexSessionFile(
+ day: day,
+ filename: "unknown.jsonl",
+ contents: env.jsonl([
+ sessionMeta(id: "unknown", cwd: nil),
+ self.codexTurnContext(timestamp: iso0, model: model),
+ self.codexTokenCount(
+ timestamp: iso1,
+ model: model,
+ total: (input: 7, cached: 0, output: 3)),
+ ]))
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ claudeProjectsRoots: nil,
+ cacheRoot: env.cacheRoot,
+ codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"))
+ options.refreshMinIntervalSeconds = 0
+
+ let report = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+ #expect(report.summary?.totalTokens == 66)
+
+ var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ var projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
+ cache: cache,
+ range: CostUsageScanner.CostUsageDayRange(since: day, until: day),
+ modelsDevCacheRoot: env.cacheRoot)
+ let projectABreakdown = projects.first { $0.path == projectA }
+ #expect(projectABreakdown?.totalTokens == 46)
+ #expect(projectABreakdown?.sources.count == 2)
+ #expect(projectABreakdown?.sources.first(where: { $0.path == projectA })?.totalTokens == 33)
+ #expect(projectABreakdown?.sources.first(where: { $0.path == projectAWorktree })?.totalTokens == 13)
+ #expect(projects.first(where: { $0.path == projectB })?.totalTokens == 10)
+ #expect(projects.first(where: { $0.path == nil })?.name == CostUsageProjectBreakdown.unknownProjectName)
+ #expect(projects.first(where: { $0.path == nil })?.totalTokens == 10)
+ #expect(cache.files.values.first(where: { $0.projectPath == projectAWorktree })?
+ .canonicalProjectPath == projectA)
+ #expect(cache.codexProjectMetadataVersion == 1)
+
+ let appended = try "\n" + env.jsonl([
+ self.codexTokenCount(
+ timestamp: iso2,
+ model: model,
+ total: (input: 15, cached: 0, output: 2)),
+ ])
+ let handle = try FileHandle(forWritingTo: firstA)
+ try handle.seekToEnd()
+ try handle.write(contentsOf: Data(appended.utf8))
+ try handle.close()
+
+ _ = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+ cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
+ cache: cache,
+ range: CostUsageScanner.CostUsageDayRange(since: day, until: day),
+ modelsDevCacheRoot: env.cacheRoot)
+ #expect(projects.first(where: { $0.path == projectA })?.totalTokens == 52)
+ }
+
+ private func makeGitRepositoryWithWorktree(projectPath: String, worktreePath: String) throws {
+ try FileManager.default.createDirectory(
+ at: URL(fileURLWithPath: projectPath, isDirectory: true),
+ withIntermediateDirectories: true)
+ try self.runGit(["init", projectPath])
+ try self.runGit(["-C", projectPath, "config", "user.email", "codexbar-test@example.com"])
+ try self.runGit(["-C", projectPath, "config", "user.name", "CodexBar Test"])
+ try self.runGit(["-C", projectPath, "config", "commit.gpgsign", "false"])
+ try "test\n".write(
+ to: URL(fileURLWithPath: projectPath).appendingPathComponent("README.md"),
+ atomically: false,
+ encoding: .utf8)
+ try self.runGit(["-C", projectPath, "add", "README.md"])
+ try self.runGit(["-C", projectPath, "commit", "-m", "init"])
+ try FileManager.default.createDirectory(
+ at: URL(fileURLWithPath: worktreePath).deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ try self.runGit(["-C", projectPath, "worktree", "add", "-b", "codex-test", worktreePath])
+ }
+
+ private func runGit(_ arguments: [String]) throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = ["git"] + arguments
+ let output = Pipe()
+ process.standardOutput = output
+ process.standardError = output
+ try process.run()
+ process.waitUntilExit()
+ if process.terminationStatus != 0 {
+ let data = output.fileHandleForReading.readDataToEndOfFile()
+ let message = String(data: data, encoding: .utf8) ?? ""
+ throw NSError(
+ domain: "CodexBarTests.Git",
+ code: Int(process.terminationStatus),
+ userInfo: [NSLocalizedDescriptionKey: message])
+ }
+ }
+
@Test
func `codex incremental append falls back to rescan when fork metadata appears late`() throws {
let env = try CostUsageTestEnvironment()
@@ -928,6 +1119,99 @@ struct CostUsageScannerBreakdownTests {
#expect(repeatedWide.summary?.totalTokens == 32)
}
+ @Test
+ func `codex project metadata migration drops unscanned legacy files`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+
+ let olderDay = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
+ let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18)
+ let model = "gpt-5.4"
+ let olderProject = env.root.appendingPathComponent("older-project", isDirectory: true).path
+ let currentProject = env.root.appendingPathComponent("current-project", isDirectory: true).path
+ let olderFile = try env.writeCodexSessionFile(
+ day: olderDay,
+ filename: "older-project.jsonl",
+ contents: env.jsonl([
+ self.codexSessionMeta(timestamp: env.isoString(for: olderDay), id: "older", cwd: olderProject),
+ self.codexTurnContext(timestamp: env.isoString(for: olderDay), model: model),
+ self.codexTokenCount(
+ timestamp: env.isoString(for: olderDay.addingTimeInterval(1)),
+ model: model,
+ last: (input: 20, cached: 0, output: 0)),
+ ]))
+ _ = try env.writeCodexSessionFile(
+ day: day,
+ filename: "current-project.jsonl",
+ contents: env.jsonl([
+ self.codexSessionMeta(timestamp: env.isoString(for: day), id: "current", cwd: currentProject),
+ self.codexTurnContext(timestamp: env.isoString(for: day), model: model),
+ self.codexTokenCount(
+ timestamp: env.isoString(for: day.addingTimeInterval(1)),
+ model: model,
+ last: (input: 10, cached: 0, output: 0)),
+ ]))
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ claudeProjectsRoots: nil,
+ cacheRoot: env.cacheRoot,
+ codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"))
+ options.refreshMinIntervalSeconds = 0
+
+ let wide = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: olderDay,
+ until: day,
+ now: day,
+ options: options)
+ #expect(wide.summary?.totalTokens == 30)
+
+ var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ cache.codexProjectMetadataVersion = nil
+ for key in cache.files.keys {
+ cache.files[key]?.projectPath = nil
+ cache.files[key]?.canonicalProjectPath = nil
+ }
+ CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot)
+
+ options.refreshMinIntervalSeconds = 60
+ let narrow = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: day,
+ until: day,
+ now: day.addingTimeInterval(1),
+ options: options)
+ #expect(narrow.summary?.totalTokens == 10)
+
+ cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ #expect(cache.codexProjectMetadataVersion == 1)
+ #expect(cache.scanSinceKey == "2026-05-17")
+ #expect(cache.scanUntilKey == "2026-05-19")
+ #expect(cache.files[olderFile.path] == nil)
+ let migratedProjects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
+ cache: cache,
+ range: CostUsageScanner.CostUsageDayRange(since: olderDay, until: day),
+ modelsDevCacheRoot: env.cacheRoot)
+ #expect(!migratedProjects.contains(where: { $0.path == olderProject }))
+ #expect(migratedProjects.first(where: { $0.path == currentProject })?.totalTokens == 10)
+
+ let repeatedWide = CostUsageScanner.loadDailyReport(
+ provider: .codex,
+ since: olderDay,
+ until: day,
+ now: day.addingTimeInterval(2),
+ options: options)
+ #expect(repeatedWide.summary?.totalTokens == 30)
+ cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ let rescannedProjects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
+ cache: cache,
+ range: CostUsageScanner.CostUsageDayRange(since: olderDay, until: day),
+ modelsDevCacheRoot: env.cacheRoot)
+ #expect(rescannedProjects.first(where: { $0.path == olderProject })?.totalTokens == 20)
+ #expect(rescannedProjects.first(where: { $0.path == currentProject })?.totalTokens == 10)
+ }
+
@Test
func `codex long turn context preserves model attribution`() throws {
let env = try CostUsageTestEnvironment()
diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift
new file mode 100644
index 0000000000..3db56c7272
--- /dev/null
+++ b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift
@@ -0,0 +1,161 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct CostUsageScannerClaudeDesktopTests {
+ @Test
+ func `claude daily report includes nested desktop local agent projects`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+
+ let day = try env.makeLocalNoon(year: 2026, month: 7, day: 5)
+ let iso0 = env.isoString(for: day)
+ let assistant: [String: Any] = [
+ "type": "assistant",
+ "timestamp": iso0,
+ "message": [
+ "model": "claude-test-model",
+ "usage": [
+ "input_tokens": 120,
+ "cache_creation_input_tokens": 30,
+ "cache_read_input_tokens": 20,
+ "output_tokens": 40,
+ ],
+ ],
+ ]
+ let nestedAssistant: [String: Any] = [
+ "type": "assistant",
+ "timestamp": iso0,
+ "message": [
+ "model": "claude-test-model",
+ "usage": [
+ "input_tokens": 10,
+ "output_tokens": 5,
+ ],
+ ],
+ ]
+ let currentAssistant: [String: Any] = [
+ "type": "assistant",
+ "timestamp": iso0,
+ "message": [
+ "model": "claude-test-model",
+ "usage": [
+ "input_tokens": 7,
+ "output_tokens": 3,
+ ],
+ ],
+ ]
+ let decoyAssistant: [String: Any] = [
+ "type": "assistant",
+ "timestamp": iso0,
+ "message": [
+ "model": "claude-test-model",
+ "usage": [
+ "input_tokens": 999,
+ "output_tokens": 999,
+ ],
+ ],
+ ]
+ let projectsRoot = try env.writeClaudeDesktopLocalAgentProjectFile(
+ relativePath: "project-a/session-a.jsonl",
+ contents: env.jsonl([assistant]))
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let nestedProjectsRoot = try env.writeNestedClaudeDesktopLocalAgentProjectFile(
+ relativePath: "project-b/session-b.jsonl",
+ contents: env.jsonl([nestedAssistant]))
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let currentProjectsRoot = try env.writeClaudeDesktopCodeSessionProjectFile(
+ relativePath: "project-c/session-c.jsonl",
+ contents: env.jsonl([currentAssistant]))
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let decoyProjectsRoot = try env.writeClaudeDesktopLocalAgentFile(
+ relativePath: "outputs/node_modules/package/.claude/projects/project-decoy/session-decoy.jsonl",
+ contents: env.jsonl([decoyAssistant]))
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+
+ let discovered = CostUsageScanner.defaultClaudeProjectsRoots(
+ options: CostUsageScanner.Options(cacheRoot: env.cacheRoot),
+ environment: [:],
+ homeDirectory: env.root)
+ #expect(discovered.contains(projectsRoot.standardizedFileURL))
+ #expect(discovered.contains(nestedProjectsRoot.standardizedFileURL))
+ #expect(discovered.contains(currentProjectsRoot.standardizedFileURL))
+ #expect(!discovered.contains(decoyProjectsRoot.standardizedFileURL))
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: nil,
+ claudeProjectsRoots: discovered,
+ cacheRoot: env.cacheRoot)
+ options.refreshMinIntervalSeconds = 0
+
+ let report = CostUsageScanner.loadDailyReport(
+ provider: .claude,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+
+ #expect(report.data.count == 1)
+ #expect(report.data[0].inputTokens == 137)
+ #expect(report.data[0].cacheCreationTokens == 30)
+ #expect(report.data[0].cacheReadTokens == 20)
+ #expect(report.data[0].outputTokens == 48)
+ #expect(report.data[0].totalTokens == 235)
+ }
+
+ @Test
+ func `current desktop shared claude projects root remains discovered`() throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+
+ let day = try env.makeLocalNoon(year: 2026, month: 7, day: 5)
+ let sessionID = "desktop-cli-session"
+ let assistant: [String: Any] = [
+ "type": "assistant",
+ "timestamp": env.isoString(for: day),
+ "message": [
+ "model": "claude-test-model",
+ "usage": [
+ "input_tokens": 11,
+ "cache_read_input_tokens": 13,
+ "output_tokens": 4,
+ ],
+ ],
+ ]
+ // Current Desktop's cliSessionId points to the matching JSONL in this shared root.
+ let sharedProjectsRoot = try env.writeClaudeDesktopSharedProjectFile(
+ relativePath: "desktop-project/\(sessionID).jsonl",
+ contents: env.jsonl([assistant]))
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+
+ let discovered = CostUsageScanner.defaultClaudeProjectsRoots(
+ options: CostUsageScanner.Options(cacheRoot: env.cacheRoot),
+ environment: [:],
+ homeDirectory: env.root)
+ #expect(discovered.contains(sharedProjectsRoot.standardizedFileURL))
+
+ var options = CostUsageScanner.Options(
+ codexSessionsRoot: nil,
+ claudeProjectsRoots: discovered,
+ cacheRoot: env.cacheRoot)
+ options.refreshMinIntervalSeconds = 0
+
+ let report = CostUsageScanner.loadDailyReport(
+ provider: .claude,
+ since: day,
+ until: day,
+ now: day,
+ options: options)
+
+ #expect(report.data.count == 1)
+ #expect(report.data[0].inputTokens == 11)
+ #expect(report.data[0].cacheReadTokens == 13)
+ #expect(report.data[0].outputTokens == 4)
+ #expect(report.data[0].totalTokens == 28)
+ }
+}
diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift
index c94ce42a2a..3798910945 100644
--- a/Tests/CodexBarTests/CostUsageScannerTests.swift
+++ b/Tests/CodexBarTests/CostUsageScannerTests.swift
@@ -940,6 +940,71 @@ struct CostUsageTestEnvironment {
return url
}
+ func writeClaudeDesktopLocalAgentFile(relativePath: String, contents: String) throws -> URL {
+ let localAgentRoot = self.root
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("Claude", isDirectory: true)
+ .appendingPathComponent("local-agent-mode-sessions", isDirectory: true)
+ .appendingPathComponent("workspace-id", isDirectory: true)
+ .appendingPathComponent("session-id", isDirectory: true)
+ .appendingPathComponent("local_agent", isDirectory: true)
+ let url = localAgentRoot.appendingPathComponent(relativePath, isDirectory: false)
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try contents.write(to: url, atomically: true, encoding: .utf8)
+ return url
+ }
+
+ func writeClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL {
+ try self.writeClaudeDesktopLocalAgentFile(
+ relativePath: ".claude/projects/\(relativePath)",
+ contents: contents)
+ }
+
+ func writeClaudeDesktopCodeSessionProjectFile(relativePath: String, contents: String) throws -> URL {
+ let projectsRoot = self.root
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("Claude", isDirectory: true)
+ .appendingPathComponent("claude-code-sessions", isDirectory: true)
+ .appendingPathComponent("account-id", isDirectory: true)
+ .appendingPathComponent("org-id", isDirectory: true)
+ .appendingPathComponent(".claude", isDirectory: true)
+ .appendingPathComponent("projects", isDirectory: true)
+ let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try contents.write(to: url, atomically: true, encoding: .utf8)
+ return url
+ }
+
+ func writeClaudeDesktopSharedProjectFile(relativePath: String, contents: String) throws -> URL {
+ let projectsRoot = self.root
+ .appendingPathComponent(".claude", isDirectory: true)
+ .appendingPathComponent("projects", isDirectory: true)
+ let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try contents.write(to: url, atomically: true, encoding: .utf8)
+ return url
+ }
+
+ func writeNestedClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL {
+ let projectsRoot = self.root
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("Claude", isDirectory: true)
+ .appendingPathComponent("local-agent-mode-sessions", isDirectory: true)
+ .appendingPathComponent("workspace-id", isDirectory: true)
+ .appendingPathComponent("session-id", isDirectory: true)
+ .appendingPathComponent("agent", isDirectory: true)
+ .appendingPathComponent("local_agent", isDirectory: true)
+ .appendingPathComponent(".claude", isDirectory: true)
+ .appendingPathComponent("projects", isDirectory: true)
+ let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try contents.write(to: url, atomically: true, encoding: .utf8)
+ return url
+ }
+
func writeCodexArchivedSessionFile(filename: String, contents: String) throws -> URL {
let url = self.codexArchivedSessionsRoot.appendingPathComponent(filename, isDirectory: false)
try contents.write(to: url, atomically: true, encoding: .utf8)
diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift
new file mode 100644
index 0000000000..94b640ef10
--- /dev/null
+++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift
@@ -0,0 +1,80 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct CostUsageWindowSummaryTests {
+ @Test
+ func `summaries use calendar windows instead of the last nonempty rows`() {
+ let snapshot = Self.snapshot(historyDays: 90)
+ let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar)
+
+ #expect(summary.days == 7)
+ #expect(summary.entryCount == 2)
+ #expect(summary.totalCostUSD == 9)
+ #expect(summary.totalTokens == 900)
+ #expect(summary.totalRequests == 9)
+ }
+
+ @Test
+ func `comparison periods are unique sorted and bounded by scanned history`() {
+ let snapshot = Self.snapshot(historyDays: 90)
+
+ #expect(snapshot.comparisonSummaries(periods: [30, 7, 90, 7], calendar: Self.utcCalendar).map(\.days) == [
+ 7,
+ 30,
+ ])
+ }
+
+ @Test
+ func `summary preserves unavailable totals as nil`() {
+ let snapshot = CostUsageTokenSnapshot(
+ sessionTokens: nil,
+ sessionCostUSD: nil,
+ last30DaysTokens: nil,
+ last30DaysCostUSD: nil,
+ historyDays: 30,
+ daily: [Self.entry(day: "2026-07-01", cost: nil, tokens: nil, requests: nil)],
+ updatedAt: Self.now)
+
+ let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar)
+ #expect(summary.totalCostUSD == nil)
+ #expect(summary.totalTokens == nil)
+ #expect(summary.totalRequests == nil)
+ }
+
+ private static func snapshot(historyDays: Int) -> CostUsageTokenSnapshot {
+ CostUsageTokenSnapshot(
+ sessionTokens: 500,
+ sessionCostUSD: 5,
+ last30DaysTokens: 1000,
+ last30DaysCostUSD: 10,
+ historyDays: historyDays,
+ daily: [
+ self.entry(day: "2026-06-01", cost: 1, tokens: 100, requests: 1),
+ self.entry(day: "2026-06-25", cost: 4, tokens: 400, requests: 4),
+ self.entry(day: "2026-07-01", cost: 5, tokens: 500, requests: 5),
+ ],
+ updatedAt: self.now)
+ }
+
+ private static func entry(day: String, cost: Double?, tokens: Int?, requests: Int?)
+ -> CostUsageDailyReport.Entry
+ {
+ CostUsageDailyReport.Entry(
+ date: day,
+ inputTokens: nil,
+ outputTokens: nil,
+ totalTokens: tokens,
+ requestCount: requests,
+ costUSD: cost,
+ modelsUsed: nil,
+ modelBreakdowns: nil)
+ }
+
+ private static let now = Date(timeIntervalSince1970: 1_782_864_000) // 2026-07-01 00:00:00 UTC
+ private static var utcCalendar: Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)!
+ return calendar
+ }
+}
diff --git a/Tests/CodexBarTests/DevinUsageFetcherTests.swift b/Tests/CodexBarTests/DevinUsageFetcherTests.swift
index 7590df20c0..437e0369a5 100644
--- a/Tests/CodexBarTests/DevinUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DevinUsageFetcherTests.swift
@@ -53,6 +53,80 @@ struct DevinUsageFetcherTests {
#expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_781_424_000)
}
+ @Test
+ func `parses overage balance into extra usage provider cost`() throws {
+ let response: [String: Any] = [
+ "daily_percentage": 12,
+ "weekly_percentage": 42,
+ "overage_balance": 70.87,
+ ]
+
+ let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now)
+
+ #expect(snapshot.overageBalance == 70.87)
+ let cost = try #require(snapshot.toUsageSnapshot().providerCost)
+ #expect(cost.used == 70.87)
+ #expect(cost.limit == 0)
+ #expect(cost.currencyCode == "USD")
+ #expect(cost.period == "Extra usage balance")
+ #expect(cost.updatedAt == Self.now)
+ }
+
+ @Test
+ func `parses overage balance cents into extra usage provider cost`() throws {
+ let response: [String: Any] = [
+ "daily_percentage": 12,
+ "weekly_percentage": 42,
+ "overage_balance_cents": 7087,
+ ]
+
+ let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now)
+
+ #expect(snapshot.overageBalance == 70.87)
+ #expect(snapshot.toUsageSnapshot().providerCost?.period == "Extra usage balance")
+ }
+
+ @Test
+ func `omits provider cost when overage balance is absent`() throws {
+ let response: [String: Any] = [
+ "daily_percentage": 12,
+ "weekly_percentage": 42,
+ ]
+
+ let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now)
+
+ #expect(snapshot.overageBalance == nil)
+ #expect(snapshot.toUsageSnapshot().providerCost == nil)
+ }
+
+ @Test(arguments: ["-1", "Infinity", "NaN"])
+ func `omits invalid overage balances`(_ balance: String) throws {
+ let response: [String: Any] = [
+ "daily_percentage": 12,
+ "weekly_percentage": 42,
+ "overage_balance": balance,
+ ]
+
+ let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now)
+
+ #expect(snapshot.overageBalance == nil)
+ #expect(snapshot.toUsageSnapshot().providerCost == nil)
+ }
+
+ @Test(arguments: ["-1", "Infinity", "NaN"])
+ func `omits invalid overage balance cents`(_ balance: String) throws {
+ let response: [String: Any] = [
+ "daily_percentage": 12,
+ "weekly_percentage": 42,
+ "overage_balance_cents": balance,
+ ]
+
+ let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now)
+
+ #expect(snapshot.overageBalance == nil)
+ #expect(snapshot.toUsageSnapshot().providerCost == nil)
+ }
+
@Test
func `keeps weekly quota when current plan hides daily quota`() throws {
let response: [String: Any] = [
@@ -67,6 +141,47 @@ struct DevinUsageFetcherTests {
#expect(usage.secondary?.usedPercent == 25)
}
+ @Test
+ func `normalizes mixed-scale current percentages at the one-percent boundary`() throws {
+ let cases: [(input: Double, expected: Double)] = [
+ (0.5, 50),
+ (1, 1),
+ (1.5, 1.5),
+ ]
+
+ for value in cases {
+ let response: [String: Any] = [
+ "daily_percentage": value.input,
+ "weekly_percentage": value.input,
+ ]
+ let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now)
+
+ #expect(snapshot.daily?.usedPercent == value.expected)
+ #expect(snapshot.weekly?.usedPercent == value.expected)
+ }
+ }
+
+ @Test
+ func `preserves fractional boundaries for fallback quota percentages`() throws {
+ let response: [String: Any] = [
+ "quota_usage": [
+ "daily_quota": [
+ "used_percent": 1,
+ "reset_at": "2026-06-01T08:00:00Z",
+ ],
+ "weekly_quota": [
+ "remaining_percent": 1,
+ "next_reset_at": 1_780_560_000,
+ ],
+ ],
+ ]
+
+ let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now)
+
+ #expect(snapshot.daily?.usedPercent == 100)
+ #expect(snapshot.weekly?.usedPercent == 0)
+ }
+
@Test
func `parses zero percentages from JSON response`() throws {
let data = Data("""
diff --git a/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl b/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl
new file mode 100644
index 0000000000..9f71dd8588
--- /dev/null
+++ b/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl
@@ -0,0 +1,2 @@
+{"timestamp":"2026-07-06T16:00:00Z","type":"session_meta","payload":{"session_id":"019f-session-fixture","cwd":"/Users/test/Projects/alpha","originator":"codex_exec","source":"exec"}}
+{"this":"second line must never be read by the session parser"}
diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt b/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt
new file mode 100644
index 0000000000..7b9e2b42a9
--- /dev/null
+++ b/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt
@@ -0,0 +1,4 @@
+p102
+n/Users/test/Projects/alpha
+p201
+n/Users/test/Projects/project with spaces
diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt
new file mode 100644
index 0000000000..53c2f31ee2
--- /dev/null
+++ b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt
@@ -0,0 +1,9 @@
+ 101 1 Mon Jul 6 09:00:00 2026 /Applications/Claude.app/Contents/Resources/disclaimer /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions
+ 102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions
+ 201 1 Mon Jul 6 09:01:00 2026 /opt/homebrew/bin/codex exec --full-auto strange argv here
+ 202 1 Mon Jul 6 09:02:00 2026 /Applications/Codex.app/Contents/Resources/codex app-server --listen stdio
+ 203 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/codex --help
+ 301 1 Mon Jul 6 09:04:00 2026 /Users/test/.local/bin/claude-code-acp --stdio
+ 401 1 Mon Jul 6 09:05:00 2026 /Applications/Codex.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer) --type=renderer
+ 402 1 Mon Jul 6 09:06:00 2026 /Applications/Claude.app/Contents/MacOS/Claude
+ 403 1 Mon Jul 6 09:07:00 2026 ./Codex Computer Use.app/Contents/MacOS/helper mcp
diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json b/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json
new file mode 100644
index 0000000000..63d612a7c8
--- /dev/null
+++ b/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json
@@ -0,0 +1,10 @@
+{
+ "Self": {"DNSName": "local-mac.example.ts.net.", "HostName": "local-mac"},
+ "Peer": {
+ "node-1": {"DNSName": "clawmac.example.ts.net.", "OS": "macOS", "Online": true},
+ "node-2": {"DNSName": "linuxbox.example.ts.net.", "OS": "linux", "Online": true},
+ "node-3": {"DNSName": "phone.example.ts.net.", "OS": "iOS", "Online": true},
+ "node-4": {"DNSName": "offline.example.ts.net.", "OS": "macOS", "Online": false},
+ "node-5": {"DNSName": "local-mac.example.ts.net.", "OS": "macOS", "Online": true}
+ }
+}
diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift
index b243f22ab7..20d0cab6a6 100644
--- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift
+++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift
@@ -76,7 +76,11 @@ enum GeminiAPITestHelpers {
return "header.\(encoded).sig"
}
- static func loadCodeAssistResponse(tierId: String, projectId: String? = nil) -> Data {
+ static func loadCodeAssistResponse(
+ tierId: String,
+ projectId: String? = nil,
+ paidTierName: String? = nil) -> Data
+ {
var payload: [String: Any] = [
"currentTier": [
"id": tierId,
@@ -86,9 +90,22 @@ enum GeminiAPITestHelpers {
if let projectId {
payload["cloudaicompanionProject"] = projectId
}
+ if let paidTierName {
+ payload["paidTier"] = [
+ "id": tierId,
+ "name": paidTierName,
+ ]
+ }
return self.jsonData(payload)
}
+ static func loadCodeAssistConsumerPlusResponse(projectId: String? = "cloudaicompanion-123") -> Data {
+ self.loadCodeAssistResponse(
+ tierId: "free-tier",
+ projectId: projectId,
+ paidTierName: "Plus")
+ }
+
static func loadCodeAssistFreeTierResponse() -> Data {
self.loadCodeAssistResponse(tierId: "free-tier")
}
@@ -100,4 +117,18 @@ enum GeminiAPITestHelpers {
static func loadCodeAssistLegacyTierResponse() -> Data {
self.loadCodeAssistResponse(tierId: "legacy-tier")
}
+
+ static func consumerTierDeprecationResponse() -> Data {
+ self.jsonData([
+ "error": [
+ "code": 403,
+ "message": """
+ IneligibleTierError / UNSUPPORTED_CLIENT: This client is no longer supported for \
+ Gemini Code Assist for individuals. To continue using Gemini, please migrate to the \
+ Antigravity suite of products.
+ """,
+ "status": "PERMISSION_DENIED",
+ ],
+ ])
+ }
}
diff --git a/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift
new file mode 100644
index 0000000000..a223f49ee8
--- /dev/null
+++ b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift
@@ -0,0 +1,163 @@
+import CodexBarCore
+import Foundation
+import Testing
+#if canImport(Darwin)
+import Darwin
+#else
+import Glibc
+#endif
+
+@Suite(.serialized)
+struct GeminiConsumerTierMigrationTests {
+ @Test(arguments: [
+ "UNSUPPORTED_CLIENT",
+ "IneligibleTierError",
+ "no longer supported for Gemini Code Assist for individuals",
+ "please migrate Gemini to the Antigravity suite",
+ ])
+ func `detects consumer tier deprecation signals`(signal: String) {
+ #expect(GeminiStatusProbeError.isConsumerTierDeprecationSignal(signal))
+ }
+
+ @Test(arguments: [
+ "UNAUTHENTICATED",
+ "HTTP 500",
+ "quota bucket missing",
+ ])
+ func `ignores unrelated api errors`(signal: String) {
+ #expect(!GeminiStatusProbeError.isConsumerTierDeprecationSignal(signal))
+ }
+
+ @Test
+ func `reports consumer tier deprecation from loadCodeAssist`() async throws {
+ let env = try GeminiTestEnvironment()
+ defer { env.cleanup() }
+ try env.writeCredentials(
+ accessToken: "token",
+ refreshToken: nil,
+ expiry: Date().addingTimeInterval(3600),
+ idToken: nil)
+
+ let dataLoader = GeminiAPITestHelpers.dataLoader { request in
+ guard let url = request.url, let host = url.host else {
+ throw URLError(.badURL)
+ }
+ switch host {
+ case "cloudcode-pa.googleapis.com":
+ if url.path == "/v1internal:loadCodeAssist" {
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 403,
+ body: GeminiAPITestHelpers.consumerTierDeprecationResponse())
+ }
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ default:
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ }
+ }
+
+ let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader)
+ await Self.expectError(.consumerTierDeprecated) {
+ _ = try await probe.fetch()
+ }
+ }
+
+ @Test
+ func `reports consumer tier deprecation from quota api`() async throws {
+ let env = try GeminiTestEnvironment()
+ defer { env.cleanup() }
+ try env.writeCredentials(
+ accessToken: "token",
+ refreshToken: nil,
+ expiry: Date().addingTimeInterval(3600),
+ idToken: nil)
+
+ let dataLoader = GeminiAPITestHelpers.dataLoader { request in
+ guard let url = request.url, let host = url.host else {
+ throw URLError(.badURL)
+ }
+ switch host {
+ case "cloudresourcemanager.googleapis.com":
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 200,
+ body: GeminiAPITestHelpers.jsonData(["projects": []]))
+ case "cloudcode-pa.googleapis.com":
+ if url.path == "/v1internal:loadCodeAssist" {
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 200,
+ body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse())
+ }
+ if url.path == "/v1internal:retrieveUserQuota" {
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 403,
+ body: GeminiAPITestHelpers.consumerTierDeprecationResponse())
+ }
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ default:
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ }
+ }
+
+ let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader)
+ await Self.expectError(.consumerTierDeprecated) {
+ _ = try await probe.fetch()
+ }
+ }
+
+ @Test
+ func `reports consumer tier deprecation from token refresh`() async throws {
+ let env = try GeminiTestEnvironment()
+ defer { env.cleanup() }
+ try env.writeCredentials(
+ accessToken: "old-token",
+ refreshToken: "refresh",
+ expiry: Date().addingTimeInterval(-3600),
+ idToken: nil)
+
+ let binURL = try env.writeFakeGeminiCLI()
+ let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"]
+ setenv("GEMINI_CLI_PATH", binURL.path, 1)
+ defer {
+ if let previousValue {
+ setenv("GEMINI_CLI_PATH", previousValue, 1)
+ } else {
+ unsetenv("GEMINI_CLI_PATH")
+ }
+ }
+
+ let dataLoader = GeminiAPITestHelpers.dataLoader { request in
+ guard let url = request.url, let host = url.host else {
+ throw URLError(.badURL)
+ }
+ switch host {
+ case "oauth2.googleapis.com":
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 400,
+ body: GeminiAPITestHelpers.consumerTierDeprecationResponse())
+ default:
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ }
+ }
+
+ let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader)
+ await Self.expectError(.consumerTierDeprecated) {
+ _ = try await probe.fetch()
+ }
+ }
+
+ private static func expectError(
+ _ expected: GeminiStatusProbeError,
+ operation: () async throws -> Void) async
+ {
+ do {
+ try await operation()
+ #expect(Bool(false))
+ } catch {
+ #expect(error as? GeminiStatusProbeError == expected)
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift b/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift
new file mode 100644
index 0000000000..105d15438e
--- /dev/null
+++ b/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift
@@ -0,0 +1,41 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct GeminiPrimaryWindowTests {
+ @Test
+ func `flash-only account does not fabricate a phantom 0% primary window`() {
+ let snapshot = GeminiStatusSnapshot(
+ modelQuotas: [
+ GeminiModelQuota(modelId: "gemini-2.5-flash", percentLeft: 5, resetTime: nil, resetDescription: nil),
+ GeminiModelQuota(
+ modelId: "gemini-2.5-flash-lite", percentLeft: 60, resetTime: nil, resetDescription: nil),
+ ],
+ rawText: "",
+ accountEmail: nil,
+ accountPlan: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary == nil)
+ #expect(usage.secondary?.usedPercent == 95)
+ #expect(usage.tertiary?.usedPercent == 40)
+ }
+
+ @Test
+ func `pro quota still populates the primary window`() {
+ let snapshot = GeminiStatusSnapshot(
+ modelQuotas: [
+ GeminiModelQuota(modelId: "gemini-2.5-pro", percentLeft: 30, resetTime: nil, resetDescription: nil),
+ GeminiModelQuota(modelId: "gemini-2.5-flash", percentLeft: 70, resetTime: nil, resetDescription: nil),
+ ],
+ rawText: "",
+ accountEmail: nil,
+ accountPlan: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.usedPercent == 70)
+ #expect(usage.secondary?.usedPercent == 30)
+ }
+}
diff --git a/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift
new file mode 100644
index 0000000000..c7f84b12bb
--- /dev/null
+++ b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift
@@ -0,0 +1,134 @@
+import CodexBarCore
+import SwiftUI
+import Testing
+@testable import CodexBar
+
+@MainActor
+@Suite(.serialized)
+struct GeminiProviderMigrationSettingsTests {
+ private func makeSettings() -> SettingsStore {
+ let suite = "GeminiProviderMigrationSettingsTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.providerDetectionCompleted = true
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ return settings
+ }
+
+ private func makeStore(settings: SettingsStore) -> UsageStore {
+ UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ }
+
+ private func makeContext(settings: SettingsStore, store: UsageStore) -> ProviderSettingsContext {
+ ProviderSettingsContext(
+ provider: .gemini,
+ settings: settings,
+ store: store,
+ boolBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ stringBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ statusText: { _ in nil },
+ setStatusText: { _, _ in },
+ lastAppActiveRunAt: { _ in nil },
+ setLastAppActiveRunAt: { _, _ in },
+ requestConfirmation: { _ in },
+ runLoginFlow: {})
+ }
+
+ @Test
+ func `typed predicate accepts consumer tier deprecated only`() {
+ #expect(UsageStore.isGeminiConsumerTierDeprecationError(GeminiStatusProbeError.consumerTierDeprecated))
+ #expect(!UsageStore.isGeminiConsumerTierDeprecationError(GeminiStatusProbeError.notLoggedIn))
+ #expect(!UsageStore.isGeminiConsumerTierDeprecationError(nil))
+ }
+
+ @Test
+ func `ordinary auth errors do not set migration observation`() {
+ let settings = self.makeSettings()
+ let store = self.makeStore(settings: settings)
+
+ store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn)
+
+ #expect(!store.geminiObservedConsumerTierDeprecation)
+ }
+
+ @Test
+ func `settings action appears when deprecation was observed`() {
+ let settings = self.makeSettings()
+ let store = self.makeStore(settings: settings)
+ store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated)
+
+ let impl = GeminiProviderImplementation()
+ let antigravity = ProviderDescriptorRegistry.descriptor(for: .antigravity).metadata
+ let wasEnabled = settings.isProviderEnabled(provider: .antigravity, metadata: antigravity)
+ let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store))
+
+ #expect(actions.map(\.id) == ["gemini-antigravity-migration"])
+ #expect(settings.isProviderEnabled(provider: .antigravity, metadata: antigravity) == wasEnabled)
+ }
+
+ @Test
+ func `settings action hidden for ordinary not logged in errors`() {
+ let settings = self.makeSettings()
+ let store = self.makeStore(settings: settings)
+ store.errors[.gemini] = GeminiStatusProbeError.notLoggedIn.errorDescription
+ store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn)
+
+ let impl = GeminiProviderImplementation()
+ let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store))
+
+ #expect(actions.isEmpty)
+ #expect(!store.geminiObservedConsumerTierDeprecation)
+ }
+
+ @Test
+ func `settings action hidden for unauthenticated401 style failures`() {
+ let unauthenticatedBody = """
+ {"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}
+ """
+ #expect(!GeminiStatusProbeError.isConsumerTierDeprecationSignal(unauthenticatedBody))
+
+ let settings = self.makeSettings()
+ let store = self.makeStore(settings: settings)
+ store.errors[.gemini] = GeminiStatusProbeError.notLoggedIn.errorDescription
+ store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn)
+
+ let impl = GeminiProviderImplementation()
+ let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store))
+
+ #expect(actions.isEmpty)
+ }
+
+ @Test
+ func `migration observation is store scoped and survives unrelated failures`() {
+ let firstSettings = self.makeSettings()
+ let firstStore = self.makeStore(settings: firstSettings)
+ let secondSettings = self.makeSettings()
+ let secondStore = self.makeStore(settings: secondSettings)
+
+ firstStore.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated)
+ firstStore.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn)
+
+ #expect(firstStore.geminiObservedConsumerTierDeprecation)
+ #expect(!secondStore.geminiObservedConsumerTierDeprecation)
+
+ firstStore.clearGeminiConsumerTierDeprecationObservation()
+ #expect(!firstStore.geminiObservedConsumerTierDeprecation)
+ }
+}
diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift
index 57c8b003eb..8ea2650e96 100644
--- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift
+++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift
@@ -392,11 +392,11 @@ struct GeminiStatusProbeAPITests {
}
let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader)
- let start = Date()
let snapshot = try await probe.fetch()
- let elapsed = Date().timeIntervalSince(start)
#expect(snapshot.accountPlan == "Paid")
- #expect(elapsed < 3, "fnm package discovery should not wait for inherited stdout EOF, took \(elapsed)s")
+ let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8)
+ let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines)))
+ #expect(kill(childPID, 0) == 0, "package discovery should return while the stdout-holding child is alive")
let updated = try env.readCredentials()
#expect(updated["access_token"] as? String == "new-token")
@@ -416,24 +416,25 @@ struct GeminiStatusProbeAPITests {
""".write(to: helper, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path)
- let start = Date()
+ let clock = ContinuousClock()
+ let start = clock.now
let result = GeminiStatusProbe.runProcess(
executable: helper.path,
arguments: [pidFile.path],
environment: [:],
- timeout: 0.5)
- let elapsed = Date().timeIntervalSince(start)
+ timeout: 5)
+ let elapsed = start.duration(to: clock.now)
let text = try String(contentsOf: pidFile, encoding: .utf8)
let processID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)))
defer { _ = kill(processID, SIGKILL) }
#expect(result == nil)
#expect(kill(processID, 0) == -1)
- #expect(elapsed < 2, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s")
+ #expect(elapsed < .seconds(7.5), "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)")
}
@Test
- func `fnm helper completed no-output failure returns promptly`() throws {
+ func `fnm helper completed no-output failure returns before deadline`() throws {
let env = try GeminiTestEnvironment()
defer { env.cleanup() }
let helper = env.homeURL.appendingPathComponent("fnm-failure")
@@ -443,15 +444,38 @@ struct GeminiStatusProbeAPITests {
""".write(to: helper, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path)
- let start = Date()
+ let clock = ContinuousClock()
+ let start = clock.now
let result = GeminiStatusProbe.runProcess(
executable: helper.path,
arguments: [],
environment: [:],
- timeout: 2)
+ timeout: 10)
#expect(result == nil)
- #expect(Date().timeIntervalSince(start) < 1)
+ #expect(start.duration(to: clock.now) < .seconds(5))
+ }
+
+ @Test
+ func `fnm helper successful output returns first line`() throws {
+ let env = try GeminiTestEnvironment()
+ defer { env.cleanup() }
+ let helper = env.homeURL.appendingPathComponent("fnm-success")
+ try """
+ #!/bin/sh
+ sleep 0.05
+ printf '%s\n' '/tmp/gemini-package'
+ printf '%s\n' 'ignored trailing output'
+ """.write(to: helper, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path)
+
+ let result = GeminiStatusProbe.runProcess(
+ executable: helper.path,
+ arguments: [],
+ environment: [:],
+ timeout: 2)
+
+ #expect(result == "/tmp/gemini-package")
}
@Test
diff --git a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift
index 9e38fb8534..8b7002277e 100644
--- a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift
+++ b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift
@@ -252,6 +252,51 @@ struct GeminiStatusProbePlanTests {
#expect(snapshot.accountPlan == "Workspace")
}
+ @Test
+ func `detects consumer plus from free tier with paid tier name`() async throws {
+ let env = try GeminiTestEnvironment()
+ defer { env.cleanup() }
+ let idToken = GeminiAPITestHelpers.makeIDToken(email: "user@gmail.com")
+ try env.writeCredentials(
+ accessToken: "token",
+ refreshToken: nil,
+ expiry: Date().addingTimeInterval(3600),
+ idToken: idToken)
+
+ let dataLoader = GeminiAPITestHelpers.dataLoader { request in
+ guard let url = request.url, let host = url.host else {
+ throw URLError(.badURL)
+ }
+ switch host {
+ case "cloudresourcemanager.googleapis.com":
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 200,
+ body: GeminiAPITestHelpers.jsonData(["projects": []]))
+ case "cloudcode-pa.googleapis.com":
+ if url.path == "/v1internal:loadCodeAssist" {
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 200,
+ body: GeminiAPITestHelpers.loadCodeAssistConsumerPlusResponse())
+ }
+ if url.path != "/v1internal:retrieveUserQuota" {
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ }
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 200,
+ body: GeminiAPITestHelpers.sampleQuotaResponse())
+ default:
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ }
+ }
+
+ let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader)
+ let snapshot = try await probe.fetch()
+ #expect(snapshot.accountPlan == "Plus")
+ }
+
@Test
func `detects free from free tier without hosted domain`() async throws {
let env = try GeminiTestEnvironment()
diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift
index 1d2a427644..b22cc6e610 100644
--- a/Tests/CodexBarTests/GeminiTestEnvironment.swift
+++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift
@@ -315,7 +315,7 @@ struct GeminiTestEnvironment {
import sys
child = subprocess.Popen(
- [sys.executable, "-c", "import time; time.sleep(5)"],
+ [sys.executable, "-c", "import time; time.sleep(30)"],
stdin=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift
index ab0385c909..08869b079d 100644
--- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift
+++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift
@@ -170,4 +170,102 @@ struct InlineCostHistoryDashboardLabelTests {
#expect(model.inlineUsageDashboard?.kpis[1].title == "This month")
#expect(model.inlineUsageDashboard?.kpis[2].title == "This month tokens")
}
+
+ @Test
+ func `costHistoryInlineDashboard sets currencyCode from snapshot`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_179_200)
+ let metadata = try #require(ProviderDefaults.metadata[.claude])
+ let tokenSnapshot = CostUsageTokenSnapshot(
+ sessionTokens: 275,
+ sessionCostUSD: 0.25,
+ last30DaysTokens: 425,
+ last30DaysCostUSD: 0.37,
+ currencyCode: "USD",
+ 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))
+
+ let dashboard = try #require(model.inlineUsageDashboard)
+ #expect(dashboard.currencyCode == "USD")
+ }
+
+ @Test
+ func `token-only inline dashboard leaves currencyCode nil`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_179_200)
+ let metadata = try #require(ProviderDefaults.metadata[.zai])
+ let modelUsage = ZaiModelUsageData(
+ xTime: ["2023-11-17 00:00"],
+ modelDataList: [
+ ZaiModelDataItem(modelName: "glm-test", tokensUsage: [123]),
+ ])
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ zaiUsage: ZaiUsageSnapshot(
+ tokenLimit: nil,
+ timeLimit: nil,
+ planName: nil,
+ modelUsage: modelUsage,
+ updatedAt: now),
+ updatedAt: now,
+ identity: ProviderIdentitySnapshot(
+ providerID: .zai,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil))
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .zai,
+ metadata: metadata,
+ snapshot: snapshot,
+ 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: true,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+ let dashboard = try #require(model.inlineUsageDashboard)
+ #expect(dashboard.currencyCode == nil)
+ }
}
diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift
index 58591543f0..9c527c5313 100644
--- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift
+++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift
@@ -58,5 +58,40 @@ struct KeychainNoUIQueryTests {
let status = SecItemCopyMatching(query as CFDictionary, &result)
#expect(status == errSecItemNotFound || status == errSecInteractionNotAllowed)
}
+
+ @Test
+ func `processes block every Security item operation before system access`() {
+ guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else {
+ return
+ }
+
+ #expect(KeychainTestSafety.shouldBlockRealKeychainAccess())
+
+ let empty = [:] as CFDictionary
+ var result: CFTypeRef?
+ #expect(KeychainSecurity.copyMatching(empty, &result) == errSecInteractionNotAllowed)
+ #expect(KeychainSecurity.update(empty, empty) == errSecInteractionNotAllowed)
+ #expect(KeychainSecurity.add(empty, nil) == errSecInteractionNotAllowed)
+ #expect(KeychainSecurity.delete(empty) == errSecInteractionNotAllowed)
+ }
+
+ @Test
+ func `safety recognizes runner variants and explicit controls`() {
+ #expect(KeychainTestSafety.shouldBlockRealKeychainAccess(
+ processName: "swiftpm-testing-helper",
+ environment: [:]))
+ #expect(KeychainTestSafety.shouldBlockRealKeychainAccess(
+ processName: "CodexBarPackageTests.xctest",
+ environment: [:]))
+ #expect(KeychainTestSafety.shouldBlockRealKeychainAccess(
+ processName: "future-test-runner",
+ environment: [KeychainTestSafety.suppressAccessEnvironmentKey: "1"]))
+ #expect(KeychainTestSafety.shouldBlockRealKeychainAccess(
+ processName: "CodexBar",
+ environment: [:]) == false)
+ #expect(KeychainTestSafety.shouldBlockRealKeychainAccess(
+ processName: "swiftpm-testing-helper",
+ environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"]) == false)
+ }
}
#endif
diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift
index fb00d1ae64..5ee1024b02 100644
--- a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift
+++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift
@@ -10,6 +10,14 @@ struct KeychainPromptSafetyAuditTests {
#expect(agents.contains("use parser tests, stubs, test stores, or `KeychainNoUIQuery`"))
}
+ @Test
+ func `default test runner explicitly suppresses real keychain access`() throws {
+ let script = try Self.readRepoFile("Scripts/test.sh")
+
+ #expect(script.contains("CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"))
+ #expect(script.contains("export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1"))
+ }
+
@Test
func `live TTY integration tests are opt in`() throws {
let ttyTests = try Self.readRepoFile("Tests/CodexBarTests/TTYIntegrationTests.swift")
@@ -42,15 +50,84 @@ struct KeychainPromptSafetyAuditTests {
}
@Test
- func `tests do not call SecItemCopyMatching except no UI query coverage`() throws {
+ func `claude availability tests with keychain enabled use test doubles`() throws {
+ let file = Self.repoRoot().appendingPathComponent(
+ "Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift")
+ let lines = try Self.lines(in: file)
+ let callSites = lines.enumerated().compactMap { lineNumber, line -> PromptCallSite? in
+ guard line.contains("strategy.isAvailable(context)") else { return nil }
+ let oneBasedLineNumber = lineNumber + 1
+ guard Self.hasOpenScope(
+ containing: "KeychainAccessGate.withTaskOverrideForTesting(false)",
+ lines: lines,
+ before: oneBasedLineNumber)
+ else {
+ return nil
+ }
+ return PromptCallSite(file: file, lineNumber: oneBasedLineNumber)
+ }
+
+ #expect(callSites.isEmpty == false)
+ for callSite in callSites {
+ let failureMessage = "\(callSite.file.path):\(callSite.lineNumber) calls strategy.isAvailable(context) "
+ + "with test keychain access enabled and incomplete scoped keychain isolation"
+ #expect(
+ Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: callSite.lineNumber),
+ "\(failureMessage)")
+ }
+ }
+
+ @Test
+ func `availability audit rejects a Claude-only keychain override`() {
+ let lines: [Substring] = [
+ "KeychainAccessGate.withTaskOverrideForTesting(false) {",
+ "ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {",
+ "strategy.isAvailable(context)",
+ "}",
+ "}",
+ ]
+
+ #expect(Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: 3) == false)
+ }
+
+ @Test
+ func `availability audit accepts combined cache and Claude keychain doubles`() {
+ let lines: [Substring] = [
+ "KeychainAccessGate.withTaskOverrideForTesting(false) {",
+ "self.withAvailabilityKeychainDoubles {",
+ "strategy.isAvailable(context)",
+ "}",
+ "}",
+ ]
+
+ #expect(Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: 3))
+ }
+
+ @Test
+ func `tests do not call Security item APIs except no UI query coverage`() throws {
+ let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"]
let offenders = try Self.swiftTestFiles().filter { file in
let text = try Self.readFile(file)
- return text.contains("SecItemCopyMatching")
+ return securityItemCalls.contains(where: text.contains)
&& !file.path.hasSuffix("Tests/CodexBarTests/KeychainNoUIQueryTests.swift")
&& !file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift")
}
- #expect(offenders.isEmpty, "Unexpected direct SecItemCopyMatching in tests: \(offenders.map(\.path))")
+ #expect(offenders.isEmpty, "Unexpected direct Security item access in tests: \(offenders.map(\.path))")
+ }
+
+ @Test
+ func `production source routes Security item APIs through the test safety gateway`() throws {
+ let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"]
+ let offenders = try Self.swiftFiles(
+ under: Self.repoRoot().appendingPathComponent("Sources", isDirectory: true))
+ .filter { file in
+ guard !file.path.hasSuffix("Sources/CodexBarCore/KeychainSecurity.swift") else { return false }
+ let text = try Self.readFile(file)
+ return securityItemCalls.contains(where: text.contains)
+ }
+
+ #expect(offenders.isEmpty, "Security item access bypasses KeychainSecurity: \(offenders.map(\.path))")
}
private static func repoRoot() -> URL {
@@ -74,8 +151,14 @@ struct KeychainPromptSafetyAuditTests {
private static func swiftTestFiles(excludingSelf: Bool = false) throws -> [URL] {
let testsRoot = self.repoRoot().appendingPathComponent("Tests/CodexBarTests", isDirectory: true)
+ return try self.swiftFiles(under: testsRoot).filter { file in
+ !(excludingSelf && file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift"))
+ }
+ }
+
+ private static func swiftFiles(under root: URL) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
- at: testsRoot,
+ at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles])
else { return [] }
@@ -84,9 +167,6 @@ struct KeychainPromptSafetyAuditTests {
for case let file as URL in enumerator where file.pathExtension == "swift" {
let values = try file.resourceValues(forKeys: [.isRegularFileKey])
if values.isRegularFile == true {
- if excludingSelf, file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") {
- continue
- }
files.append(file)
}
}
@@ -96,13 +176,44 @@ struct KeychainPromptSafetyAuditTests {
private static func hasOpenKeychainTestDouble(lines: [Substring], before oneBasedLineNumber: Int) -> Bool {
let helperNames = [
"withClaudeKeychainOverridesForTesting",
+ "withKeychainAccessOverrideForTesting(true)",
"withSecurityCLIReadOverrideForTesting",
"KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting",
]
+ return helperNames.contains { helperName in
+ self.hasOpenScope(containing: helperName, lines: lines, before: oneBasedLineNumber)
+ }
+ }
+
+ private static func hasOpenAvailabilityKeychainIsolation(
+ lines: [Substring],
+ before oneBasedLineNumber: Int) -> Bool
+ {
+ if self.hasOpenScope(
+ containing: "withAvailabilityKeychainDoubles",
+ lines: lines,
+ before: oneBasedLineNumber)
+ {
+ return true
+ }
+
+ let bypassesCacheKeychain = self.hasOpenScope(
+ containing: "nonInteractiveCredentialRecordOverride",
+ lines: lines,
+ before: oneBasedLineNumber)
+ return bypassesCacheKeychain
+ && self.hasOpenKeychainTestDouble(lines: lines, before: oneBasedLineNumber)
+ }
+
+ private static func hasOpenScope(
+ containing needle: String,
+ lines: [Substring],
+ before oneBasedLineNumber: Int) -> Bool
+ {
let targetIndex = oneBasedLineNumber - 1
let lineRange = lines.indices.prefix(through: targetIndex)
return lineRange.contains { index in
- helperNames.contains { lines[index].contains($0) }
+ lines[index].contains(needle)
&& self.hasOpenBraceScope(lines: lines, from: index, through: targetIndex)
}
}
@@ -110,7 +221,8 @@ struct KeychainPromptSafetyAuditTests {
private static func hasOpenBraceScope(lines: [Substring], from startIndex: Int, through endIndex: Int) -> Bool {
var balance = 0
var sawOpeningBrace = false
- for line in lines[startIndex...endIndex] {
+ for index in startIndex...endIndex {
+ let line = lines[index]
for character in line {
switch character {
case "{":
@@ -122,6 +234,9 @@ struct KeychainPromptSafetyAuditTests {
continue
}
}
+ if index < endIndex, sawOpeningBrace, balance <= 0 {
+ return false
+ }
}
return sawOpeningBrace && balance > 0
}
diff --git a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift
index 9b549bfbf7..9cbd375a26 100644
--- a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift
+++ b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift
@@ -40,6 +40,34 @@ struct KimiK2UsageFetcherTests {
#expect(summary.remaining == 25)
}
+ @Test
+ func `fetch ignores non-finite usage values`() async throws {
+ let json = """
+ {
+ "total_credits_consumed": "NaN",
+ "credits_remaining": "Infinity",
+ "average_tokens": "1e309"
+ }
+ """
+ let transport = ProviderHTTPTransportHandler { request in
+ let url = try #require(request.url)
+ #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-key")
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Credits-Remaining": "-Infinity"]))
+ return (Data(json.utf8), response)
+ }
+
+ let snapshot = try await KimiK2UsageFetcher.fetchUsage(apiKey: "test-key", transport: transport)
+ let summary = snapshot.summary
+
+ #expect(summary.consumed == 0)
+ #expect(summary.remaining == 0)
+ #expect(summary.averageTokens == nil)
+ }
+
@Test
func `parses numeric timestamp seconds`() throws {
let json = """
@@ -72,6 +100,54 @@ struct KimiK2UsageFetcherTests {
#expect(abs(summary.updatedAt.timeIntervalSince1970 - expected.timeIntervalSince1970) < 0.5)
}
+ @Test
+ func `treats exact millisecond cutoff as milliseconds`() throws {
+ let json = """
+ {
+ "timestamp": 1000000000000,
+ "credits_remaining": 10,
+ "total_credits_consumed": 5
+ }
+ """
+
+ let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8))
+
+ #expect(summary.updatedAt == Date(timeIntervalSince1970: 1_000_000_000))
+ }
+
+ @Test(arguments: ["NaN", "Infinity", "1e308", "0", "-1"])
+ func `ignores invalid numeric timestamps`(timestamp: String) throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let json = """
+ {
+ "timestamp": "\(timestamp)",
+ "credits_remaining": 10,
+ "total_credits_consumed": 5
+ }
+ """
+
+ let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8), now: now)
+
+ #expect(summary.updatedAt == now)
+ }
+
+ @Test
+ func `ignores timestamps beyond distant future`() throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let timestamp = Date.distantFuture.timeIntervalSince1970 + 1
+ let json = """
+ {
+ "timestamp": "\(timestamp)",
+ "credits_remaining": 10,
+ "total_credits_consumed": 5
+ }
+ """
+
+ let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8), now: now)
+
+ #expect(summary.updatedAt == now)
+ }
+
@Test
func `invalid root returns parse error`() {
let json = """
diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift
index 2d2bd20014..77b3598735 100644
--- a/Tests/CodexBarTests/KimiProviderTests.swift
+++ b/Tests/CodexBarTests/KimiProviderTests.swift
@@ -324,6 +324,31 @@ struct KimiUsageResponseParsingTests {
#expect(snapshot.weekly.used == "375")
#expect(snapshot.rateLimit?.limit == "200")
#expect(snapshot.rateLimit?.used == "19")
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(abs((usage.primary?.usedPercent ?? -1) - 18.3105) < 0.001)
+ #expect(usage.primary?.resetDescription == "375/2048 requests")
+ #expect(abs((usage.secondary?.usedPercent ?? -1) - 9.5) < 0.001)
+ #expect(usage.secondary?.windowMinutes == 300)
+ #expect(usage.secondary?.resetDescription == "Rate: 19/200 per 5 hours")
+ }
+
+ @Test
+ func `converts weekly-only usage into primary quota lane`() {
+ let snapshot = KimiUsageSnapshot(
+ weekly: KimiUsageDetail(
+ limit: "2048",
+ used: "512",
+ remaining: "1536",
+ resetTime: "2026-01-09T15:23:13Z"),
+ rateLimit: nil,
+ updatedAt: Date(timeIntervalSince1970: 1_800_000_000))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 25)
+ #expect(usage.primary?.resetDescription == "512/2048 requests")
+ #expect(usage.secondary == nil)
}
@Test
@@ -364,6 +389,168 @@ struct KimiUsageResponseParsingTests {
#expect(snapshot.rateLimit?.resetTime == "2026-01-06T13:33:02Z")
}
+ @Test
+ func `parses subscription stat response`() throws {
+ let json = """
+ {
+ "ratelimitCode5h": {
+ "ratio": 0.4689,
+ "enabled": true,
+ "resetTime": "2026-07-02T11:56:36.876796734Z"
+ },
+ "ratelimitCode7d": {
+ "ratio": 0.0946,
+ "enabled": true,
+ "resetTime": "2026-07-09T06:56:36.876796734Z"
+ },
+ "subscriptionBalance": {
+ "id": "19eee1de-9092-8315-8000-0000e4e34d79",
+ "feature": "FEATURE_OMNI",
+ "type": "SUBSCRIPTION",
+ "unit": "UNIT_CREDIT",
+ "amountUsedRatio": 1,
+ "kimiCodeUsedRatio": 0.2854,
+ "expireTime": "2026-07-23T00:00:00Z"
+ },
+ "giftBalances": [
+ {
+ "id": "19efdb95-e082-804c-9ecd-978b7ab37d36",
+ "feature": "FEATURE_OMNI",
+ "type": "GIFT",
+ "unit": "UNIT_CREDIT",
+ "amountUsedRatio": 1,
+ "kimiCodeUsedRatio": 1,
+ "expireTime": "2026-07-31T15:59:59Z"
+ }
+ ]
+ }
+ """
+
+ let response = try JSONDecoder().decode(KimiSubscriptionStatsResponse.self, from: Data(json.utf8))
+
+ #expect(response.subscriptionBalance?.feature == "FEATURE_OMNI")
+ #expect(response.subscriptionBalance?.type == "SUBSCRIPTION")
+ #expect(response.subscriptionBalance?.amountUsedRatio == 1)
+ #expect(response.subscriptionBalance?.expireTime == "2026-07-23T00:00:00Z")
+ #expect(response.ratelimitCode7d?.ratio == 0.0946)
+ #expect(response.ratelimitCode7d?.enabled == true)
+ #expect(response.ratelimitCode7d?.resetTime == "2026-07-09T06:56:36.876796734Z")
+ }
+
+ @Test
+ func `subscription grace is a total budget for existing usage windows`() async throws {
+ let usageJSON = """
+ {
+ "usages": [
+ {
+ "scope": "FEATURE_CODING",
+ "detail": { "limit": "100", "used": "25", "remaining": "75" },
+ "limits": [
+ {
+ "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
+ "detail": { "limit": "20", "used": "5", "remaining": "15" }
+ }
+ ]
+ }
+ ]
+ }
+ """
+ let transport = ProviderHTTPTransportHandler { request in
+ let url = try #require(request.url)
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil))
+ if url.path.hasSuffix("/GetUsages") {
+ return await withCheckedContinuation { continuation in
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
+ continuation.resume(returning: (Data(usageJSON.utf8), response))
+ }
+ }
+ }
+
+ return await withCheckedContinuation { continuation in
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
+ continuation.resume(returning: (Data("{}".utf8), response))
+ }
+ }
+ }
+
+ let startedAt = ContinuousClock.now
+ let snapshot = try await KimiUsageFetcher._fetchUsageForTesting(
+ authToken: "test-token",
+ transport: transport,
+ subscriptionGrace: .milliseconds(20))
+ let elapsed = startedAt.duration(to: .now)
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 25)
+ #expect(usage.secondary?.usedPercent == 25)
+ #expect(usage.extraRateWindows == nil)
+ #expect(elapsed < .milliseconds(250), "Subscription enrichment outlived its total budget: \(elapsed)")
+
+ // Drain the deliberately cancellation-ignoring test request before the test exits.
+ try await Task.sleep(for: .milliseconds(550))
+ }
+
+ @Test
+ func `subscription stat enriches usage when it finishes within the total budget`() async throws {
+ let usageJSON = """
+ {
+ "usages": [
+ {
+ "scope": "FEATURE_CODING",
+ "detail": { "limit": "100", "used": "25", "remaining": "75" },
+ "limits": []
+ }
+ ]
+ }
+ """
+ let subscriptionJSON = """
+ {
+ "subscriptionBalance": {
+ "feature": "FEATURE_OMNI",
+ "type": "SUBSCRIPTION",
+ "amountUsedRatio": 0.42,
+ "expireTime": "2026-07-23T00:00:00Z"
+ },
+ "ratelimitCode7d": {
+ "ratio": 0.17,
+ "enabled": true,
+ "resetTime": "2026-07-13T15:28:00Z"
+ }
+ }
+ """
+ let transport = ProviderHTTPTransportHandler { request in
+ let url = try #require(request.url)
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil))
+ if url.path.hasSuffix("/GetUsages") {
+ return (Data(usageJSON.utf8), response)
+ }
+ #expect(url.path.hasSuffix("/GetSubscriptionStats"))
+ return (Data(subscriptionJSON.utf8), response)
+ }
+
+ let snapshot = try await KimiUsageFetcher._fetchUsageForTesting(
+ authToken: "test-token",
+ transport: transport,
+ subscriptionGrace: .seconds(1))
+ let windows = try #require(snapshot.toUsageSnapshot().extraRateWindows)
+ let monthly = try #require(windows.first { $0.id == "kimi-monthly" })
+ let weeklyCode = try #require(windows.first { $0.id == "kimi-code-7d" })
+
+ #expect(monthly.id == "kimi-monthly")
+ #expect(monthly.window.usedPercent == 42)
+ #expect(weeklyCode.title == "Code 7-day")
+ #expect(weeklyCode.window.usedPercent == 17)
+ #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60)
+ }
+
@Test
func `builds default code API usage endpoint`() throws {
let baseURL = try #require(URL(string: "https://api.kimi.com"))
@@ -494,6 +681,120 @@ struct KimiUsageSnapshotConversionTests {
#expect(usageSnapshot.updatedAt == now)
}
+ @Test
+ func `converts subscription balance to monthly extra window`() throws {
+ let now = Date()
+ let weeklyDetail = KimiUsageDetail(
+ limit: "2048",
+ used: "375",
+ remaining: "1673",
+ resetTime: "2026-01-09T15:23:13.373329235Z")
+ let subscriptionBalance = KimiSubscriptionBalance(
+ feature: "FEATURE_OMNI",
+ type: "SUBSCRIPTION",
+ amountUsedRatio: 1,
+ expireTime: "2026-07-23T00:00:00Z")
+
+ let snapshot = KimiUsageSnapshot(
+ weekly: weeklyDetail,
+ rateLimit: nil,
+ subscriptionBalance: subscriptionBalance,
+ updatedAt: now)
+ let usageSnapshot = snapshot.toUsageSnapshot()
+
+ let monthly = try #require(usageSnapshot.extraRateWindows?.first)
+ #expect(monthly.id == "kimi-monthly")
+ #expect(monthly.title == "Monthly")
+ #expect(monthly.window.usedPercent == 100)
+ #expect(monthly.window.resetsAt == Self.date("2026-07-23T00:00:00Z"))
+ }
+
+ @Test
+ func `reflects partial subscription usage in monthly window`() throws {
+ let now = Date()
+ let weeklyDetail = KimiUsageDetail(
+ limit: "2048",
+ used: "375",
+ remaining: "1673",
+ resetTime: "2026-01-09T15:23:13.373329235Z")
+ // A live, partially-used balance (not the fully-exhausted 1.0 fixture): amountUsedRatio is a
+ // real consumption ratio, so the Monthly window must track it rather than pin to 100%.
+ let subscriptionBalance = KimiSubscriptionBalance(
+ feature: "FEATURE_OMNI",
+ type: "SUBSCRIPTION",
+ amountUsedRatio: 0.7716,
+ expireTime: "2026-07-23T00:00:00Z")
+
+ let snapshot = KimiUsageSnapshot(
+ weekly: weeklyDetail,
+ rateLimit: nil,
+ subscriptionBalance: subscriptionBalance,
+ updatedAt: now)
+ let usageSnapshot = snapshot.toUsageSnapshot()
+
+ let monthly = try #require(usageSnapshot.extraRateWindows?.first)
+ #expect(monthly.id == "kimi-monthly")
+ #expect(abs(monthly.window.usedPercent - 77.16) < 0.0001)
+ }
+
+ @Test
+ func `converts subscription code weekly limit to extra window`() throws {
+ let now = Date()
+ let weeklyDetail = KimiUsageDetail(
+ limit: "2048",
+ used: "375",
+ remaining: "1673",
+ resetTime: "2026-01-09T15:23:13.373329235Z")
+ let subscriptionCodeWeeklyLimit = KimiSubscriptionRateLimit(
+ ratio: 0.0946,
+ enabled: true,
+ resetTime: "2026-07-13T15:28:00Z")
+
+ let snapshot = KimiUsageSnapshot(
+ weekly: weeklyDetail,
+ rateLimit: nil,
+ subscriptionBalance: nil,
+ subscriptionCodeWeeklyLimit: subscriptionCodeWeeklyLimit,
+ updatedAt: now)
+ let usageSnapshot = snapshot.toUsageSnapshot()
+
+ let weeklyCode = try #require(usageSnapshot.extraRateWindows?.first)
+ #expect(weeklyCode.id == "kimi-code-7d")
+ #expect(weeklyCode.title == "Code 7-day")
+ #expect(abs(weeklyCode.window.usedPercent - 9.46) < 0.0001)
+ #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60)
+ #expect(weeklyCode.window.resetsAt == Self.date("2026-07-13T15:28:00Z"))
+ }
+
+ @Test
+ func `omits disabled and nonfinite subscription quota ratios`() {
+ let weeklyDetail = KimiUsageDetail(
+ limit: "2048",
+ used: "375",
+ remaining: "1673",
+ resetTime: "2026-01-09T15:23:13.373329235Z")
+ let invalidLimits = [
+ KimiSubscriptionRateLimit(ratio: 0.25, enabled: false, resetTime: nil),
+ KimiSubscriptionRateLimit(ratio: .nan, enabled: true, resetTime: nil),
+ KimiSubscriptionRateLimit(ratio: .infinity, enabled: true, resetTime: nil),
+ ]
+
+ for limit in invalidLimits {
+ let snapshot = KimiUsageSnapshot(
+ weekly: weeklyDetail,
+ rateLimit: nil,
+ subscriptionBalance: KimiSubscriptionBalance(
+ feature: "FEATURE_OMNI",
+ type: "SUBSCRIPTION",
+ amountUsedRatio: .nan,
+ expireTime: nil),
+ subscriptionCodeWeeklyLimit: limit,
+ updatedAt: Date())
+
+ #expect(snapshot.toUsageSnapshot().extraRateWindows == nil)
+ }
+ }
+
@Test
func `converts to usage snapshot without rate limit`() {
let now = Date()
@@ -517,6 +818,31 @@ struct KimiUsageSnapshotConversionTests {
#expect(usageSnapshot.tertiary == nil)
}
+ @Test
+ func `converts invalid rate limit as unavailable`() {
+ let now = Date()
+ let weeklyDetail = KimiUsageDetail(
+ limit: "2048",
+ used: "375",
+ remaining: "1673",
+ resetTime: "2026-01-09T15:23:13.373329235Z")
+ let invalidRateLimit = KimiUsageDetail(
+ limit: "0",
+ used: "0",
+ remaining: "0",
+ resetTime: "2026-01-06T15:05:24.374187075Z")
+
+ let snapshot = KimiUsageSnapshot(
+ weekly: weeklyDetail,
+ rateLimit: invalidRateLimit,
+ updatedAt: now)
+
+ let usageSnapshot = snapshot.toUsageSnapshot()
+
+ #expect(usageSnapshot.primary?.resetDescription == "375/2048 requests")
+ #expect(usageSnapshot.secondary == nil)
+ }
+
@Test
func `handles zero values correctly`() {
let now = Date()
@@ -533,6 +859,7 @@ struct KimiUsageSnapshotConversionTests {
let usageSnapshot = snapshot.toUsageSnapshot()
#expect(usageSnapshot.primary?.usedPercent == 0.0)
+ #expect(usageSnapshot.secondary == nil)
}
@Test
@@ -551,6 +878,13 @@ struct KimiUsageSnapshotConversionTests {
let usageSnapshot = snapshot.toUsageSnapshot()
#expect(usageSnapshot.primary?.usedPercent == 100.0)
+ #expect(usageSnapshot.secondary == nil)
+ }
+
+ private static func date(_ text: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: text)
}
}
diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift
index 7d6964aa7b..24aa785646 100644
--- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift
+++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift
@@ -16,6 +16,7 @@ struct LocalizationLanguageCatalogTests {
"language_french",
"language_dutch",
"language_ukrainian",
+ "language_russian",
"language_italian",
"language_vietnamese",
"language_japanese",
@@ -26,6 +27,7 @@ struct LocalizationLanguageCatalogTests {
"language_arabic",
"language_persian",
"language_thai",
+ "language_galician",
]
@Test
@@ -34,6 +36,12 @@ struct LocalizationLanguageCatalogTests {
#expect(AppLanguage.ukrainian.rawValue == "uk")
}
+ @Test
+ func `app language catalog includes Russian`() {
+ #expect(AppLanguage.allCases.contains(.russian))
+ #expect(AppLanguage.russian.rawValue == "ru")
+ }
+
@Test
func `app language catalog includes Korean`() {
#expect(AppLanguage.allCases.contains(.korean))
@@ -71,6 +79,93 @@ struct LocalizationLanguageCatalogTests {
#expect(AppLanguage.thai.rawValue == "th")
}
+ @Test
+ func `app language catalog includes Galician`() {
+ #expect(AppLanguage.allCases.contains(.galician))
+ #expect(AppLanguage.galician.rawValue == "gl")
+ }
+
+ @Test
+ func `language picker labels use stable native names`() {
+ let expected: [AppLanguage: String] = [
+ .system: "System",
+ .english: "English",
+ .chineseSimplified: "简体中文",
+ .chineseTraditional: "繁體中文",
+ .japanese: "日本語",
+ .spanish: "Español",
+ .portugueseBrazilian: "Português (Brasil)",
+ .korean: "한국어",
+ .german: "Deutsch",
+ .french: "Français",
+ .arabic: "العربية",
+ .italian: "Italiano",
+ .vietnamese: "Tiếng Việt",
+ .dutch: "Nederlands",
+ .turkish: "Türkçe",
+ .ukrainian: "Українська",
+ .russian: "Русский",
+ .indonesian: "Bahasa Indonesia",
+ .polish: "Polski",
+ .persian: "فارسی",
+ .thai: "ไทย",
+ .galician: "Galego",
+ .catalan: "Català",
+ .swedish: "Svenska",
+ ]
+
+ #expect(expected.count == AppLanguage.allCases.count)
+
+ let japaneseLabels = CodexBarLocalizationOverride.$appLanguage.withValue("ja") {
+ Dictionary(uniqueKeysWithValues: AppLanguage.allCases.map { ($0, $0.label) })
+ }
+ let arabicLabels = CodexBarLocalizationOverride.$appLanguage.withValue("ar") {
+ Dictionary(uniqueKeysWithValues: AppLanguage.allCases.map { ($0, $0.label) })
+ }
+
+ #expect(japaneseLabels == expected)
+ #expect(arabicLabels == expected)
+ }
+
+ @Test
+ func `system language preserves an external Apple Languages override`() {
+ Self.withTemporaryDefaults(for: #function) { defaults, _ in
+ defaults.set(["de"], forKey: "AppleLanguages")
+
+ AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned(
+ storedAppLanguage: "",
+ defaults: defaults)
+
+ #expect(defaults.stringArray(forKey: "AppleLanguages") == ["de"])
+ }
+ }
+
+ @Test
+ func `matching legacy language override is cleared`() {
+ Self.withTemporaryDefaults(for: #function) { defaults, suiteName in
+ defaults.set(["ja"], forKey: "AppleLanguages")
+
+ AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned(
+ storedAppLanguage: "ja",
+ defaults: defaults)
+
+ #expect(defaults.persistentDomain(forName: suiteName)?["AppleLanguages"] == nil)
+ }
+ }
+
+ @Test
+ func `unrelated external language override is preserved`() {
+ Self.withTemporaryDefaults(for: #function) { defaults, _ in
+ defaults.set(["de"], forKey: "AppleLanguages")
+
+ AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned(
+ storedAppLanguage: "ja",
+ defaults: defaults)
+
+ #expect(defaults.stringArray(forKey: "AppleLanguages") == ["de"])
+ }
+ }
+
@Test
func `new language bundles include representative native labels`() throws {
let root = URL(fileURLWithPath: #filePath)
@@ -97,6 +192,19 @@ struct LocalizationLanguageCatalogTests {
"quit_app": "ออกจาก CodexBar",
"usage_percent_suffix_left": "คงเหลือ",
],
+ "ru": [
+ "language_russian": "Русский",
+ "tab_general": "Общие",
+ "quit_app": "Выйти из CodexBar",
+ "usage_percent_suffix_left": "осталось",
+ ],
+ "gl": [
+ "language_galician": "Galego",
+ "tab_general": "Xeral",
+ "quit_app": "Saír de CodexBar",
+ "terminal_app_title": "Terminal predeterminado",
+ "terminal_app_subtitle": "Terminal usado pola acción Abrir terminal",
+ ],
]
for (locale, expectedValues) in expectations {
@@ -108,6 +216,21 @@ struct LocalizationLanguageCatalogTests {
}
}
+ @Test
+ func `galician localization matches the English catalog`() throws {
+ let root = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources")
+ let englishURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings")
+ let galicianURL = resourcesURL.appendingPathComponent("gl.lproj/Localizable.strings")
+ let english = try #require(NSDictionary(contentsOf: englishURL) as? [String: String])
+ let galician = try #require(NSDictionary(contentsOf: galicianURL) as? [String: String])
+
+ #expect(Set(galician.keys) == Set(english.keys))
+ }
+
@Test
func `localized catalogs include every app language label`() throws {
#expect(self.languageKeys.count == AppLanguage.allCases.count)
@@ -303,8 +426,10 @@ struct LocalizationLanguageCatalogTests {
"byte_unit_kilobyte",
"byte_unit_megabyte",
"language_arabic",
+ "language_galician",
"language_italian",
"language_persian",
+ "language_russian",
"language_thai",
"link_email",
"link_github",
@@ -428,4 +553,15 @@ struct LocalizationLanguageCatalogTests {
#expect(rendered.contains("7일간"))
#expect(rendered.contains("3개 서비스"))
}
+
+ private static func withTemporaryDefaults(
+ for testName: String,
+ _ body: (UserDefaults, String) -> Void)
+ {
+ let suiteName = "LocalizationLanguageCatalogTests.\(testName).\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ body(defaults, suiteName)
+ }
}
diff --git a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift
index e17b9508db..8596778c13 100644
--- a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift
+++ b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift
@@ -33,7 +33,7 @@ struct MenuBarCountdownRefreshTests {
}
@Test
- func `status item schedules countdown refresh only for countdown reset dates`() {
+ func `status item schedules countdown and exhausted lane refreshes`() {
let settings = SettingsStore(
configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-scheduling"),
zaiTokenStore: NoopZaiTokenStore(),
@@ -78,6 +78,58 @@ struct MenuBarCountdownRefreshTests {
controller.updateIcons()
#expect(!controller._test_isMenuBarCountdownRefreshScheduled())
+ let now = Date()
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(60),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(90),
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+ controller.updateIcons()
+ #expect(controller._test_isMenuBarCountdownRefreshScheduled())
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(60),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(-1),
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+ controller.updateIcons()
+ #expect(!controller._test_isMenuBarCountdownRefreshScheduled())
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(90),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 40,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+ controller.updateIcons()
+ #expect(controller._test_isMenuBarCountdownRefreshScheduled())
+
settings.resetTimesShowAbsolute = false
store._setSnapshotForTesting(nil, provider: .codex)
controller.updateIcons()
@@ -99,4 +151,69 @@ struct MenuBarCountdownRefreshTests {
controller.prepareForAppShutdown()
#expect(!controller._test_isMenuBarCountdownRefreshScheduled())
}
+
+ @Test
+ func `merged highest usage observes reset for noncurrent Codex candidate`() throws {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-merged-highest"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.menuBarShowsHighestUsage = true
+ settings.menuBarShowsBrandIconWithPercent = true
+ settings.menuBarDisplayMode = .percent
+ settings.resetTimesShowAbsolute = true
+
+ let registry = ProviderRegistry.shared
+ try settings.setProviderEnabled(
+ provider: .codex,
+ metadata: #require(registry.metadata[.codex]),
+ enabled: true)
+ try settings.setProviderEnabled(
+ provider: .claude,
+ metadata: #require(registry.metadata[.claude]),
+ enabled: true)
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(
+ fetcher: fetcher,
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ let now = Date()
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(60),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(90),
+ resetDescription: nil),
+ updatedAt: now),
+ provider: .codex)
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: now),
+ provider: .claude)
+
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ defer { controller.releaseStatusItemsForTesting() }
+
+ controller.updateIcons()
+ #expect(controller.primaryProviderForUnifiedIcon() == .claude)
+ #expect(controller._test_isMenuBarCountdownRefreshScheduled())
+ }
}
diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift
index 564c7dd4e3..e6a499fd2e 100644
--- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift
+++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift
@@ -4,6 +4,25 @@ import Testing
@testable import CodexBar
struct MenuBarMetricWindowResolverTests {
+ @Test
+ func `gemini metrics fall back to Flash when Pro is unavailable`() {
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: RateWindow(usedPercent: 95, windowMinutes: 1440, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 40, windowMinutes: 1440, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ for preference in [MenuBarMetricPreference.automatic, .primary, .average] {
+ let window = MenuBarMetricWindowResolver.rateWindow(
+ preference: preference,
+ provider: .gemini,
+ snapshot: snapshot,
+ supportsAverage: true)
+
+ #expect(window?.usedPercent == 95, "Failed preference: \(preference)")
+ }
+ }
+
@Test
func `automatic metric uses zai 5-hour token lane when it is most constrained`() {
let snapshot = UsageSnapshot(
diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift
index d61069995a..cd100a2b49 100644
--- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift
+++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift
@@ -244,7 +244,147 @@ struct MenuBarVisibilityWatcherTests {
now: launchedAt.addingTimeInterval(2),
snapshots: [detachedProxy],
windowSnapshots: [blockedWindow],
- detectTahoeBlockedProxy: true))
+ detectTahoeBlockedStatusItem: true))
+ }
+
+ @Test
+ func `startup recovery retries expected hidden Tahoe item with enabled default and no window`() {
+ let launchedAt = Date(timeIntervalSince1970: 1000)
+ let hidden = StatusItemVisibilitySnapshot(
+ isVisible: false,
+ hasButton: true,
+ hasWindow: false,
+ hasScreen: false,
+ buttonWidth: 76)
+ let evidence = StatusItemStartupVisibilityEvidence(
+ autosaveName: "codexbar-merged",
+ expectsVisibility: true,
+ visibilityDefault: true,
+ snapshot: hidden)
+
+ #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery(
+ appLaunchedAt: launchedAt,
+ now: launchedAt.addingTimeInterval(2),
+ snapshots: [hidden],
+ evidence: [evidence],
+ detectTahoeBlockedStatusItem: true))
+ }
+
+ @Test
+ func `startup recovery ignores hidden Tahoe item without app and defaults visibility agreement`() {
+ let launchedAt = Date(timeIntervalSince1970: 1000)
+ let hidden = StatusItemVisibilitySnapshot(
+ isVisible: false,
+ hasButton: true,
+ hasWindow: false,
+ hasScreen: false,
+ buttonWidth: 76)
+ let intentionallyHidden = StatusItemStartupVisibilityEvidence(
+ autosaveName: "codexbar-merged",
+ expectsVisibility: false,
+ visibilityDefault: true,
+ snapshot: hidden)
+ let disabledByUser = StatusItemStartupVisibilityEvidence(
+ autosaveName: "codexbar-merged",
+ expectsVisibility: true,
+ visibilityDefault: false,
+ snapshot: hidden)
+ let unknownDefault = StatusItemStartupVisibilityEvidence(
+ autosaveName: "codexbar-merged",
+ expectsVisibility: true,
+ visibilityDefault: nil,
+ snapshot: hidden)
+
+ for evidence in [intentionallyHidden, disabledByUser, unknownDefault] {
+ #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery(
+ appLaunchedAt: launchedAt,
+ now: launchedAt.addingTimeInterval(2),
+ snapshots: [hidden],
+ evidence: [evidence],
+ detectTahoeBlockedStatusItem: true))
+ }
+ }
+
+ @Test
+ func `startup recovery ignores hidden item when matching window still exists`() {
+ let launchedAt = Date(timeIntervalSince1970: 1000)
+ let hidden = StatusItemVisibilitySnapshot(
+ isVisible: false,
+ hasButton: true,
+ hasWindow: false,
+ hasScreen: false,
+ buttonWidth: 76)
+ let evidence = StatusItemStartupVisibilityEvidence(
+ autosaveName: "codexbar-merged",
+ expectsVisibility: true,
+ visibilityDefault: true,
+ snapshot: hidden)
+ let existingWindow = MenuBarStatusItemWindowSnapshot(
+ name: "codexbar-merged",
+ ownerName: "Control Center",
+ bounds: CGRect(x: 1500, y: 0, width: 76, height: 24),
+ isOnscreen: true,
+ displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329))
+
+ #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery(
+ appLaunchedAt: launchedAt,
+ now: launchedAt.addingTimeInterval(2),
+ snapshots: [hidden],
+ evidence: [evidence],
+ windowSnapshots: [existingWindow],
+ detectTahoeBlockedStatusItem: true))
+ }
+
+ @Test
+ func `startup recovery ignores stale hidden matching window record`() {
+ let launchedAt = Date(timeIntervalSince1970: 1000)
+ let hidden = StatusItemVisibilitySnapshot(
+ isVisible: false,
+ hasButton: true,
+ hasWindow: false,
+ hasScreen: false,
+ buttonWidth: 76)
+ let evidence = StatusItemStartupVisibilityEvidence(
+ autosaveName: "codexbar-merged",
+ expectsVisibility: true,
+ visibilityDefault: true,
+ snapshot: hidden)
+ let staleWindow = MenuBarStatusItemWindowSnapshot(
+ name: "codexbar-merged",
+ ownerName: "Control Center",
+ bounds: CGRect(x: 1500, y: 0, width: 76, height: 24),
+ isOnscreen: false,
+ displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329))
+
+ #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery(
+ appLaunchedAt: launchedAt,
+ now: launchedAt.addingTimeInterval(2),
+ snapshots: [hidden],
+ evidence: [evidence],
+ windowSnapshots: [staleWindow],
+ detectTahoeBlockedStatusItem: true))
+ }
+
+ @Test
+ func `startup recovery keeps hidden no-window detection Tahoe only`() {
+ let launchedAt = Date(timeIntervalSince1970: 1000)
+ let hidden = StatusItemVisibilitySnapshot(
+ isVisible: false,
+ hasButton: true,
+ hasWindow: false,
+ hasScreen: false,
+ buttonWidth: 76)
+ let evidence = StatusItemStartupVisibilityEvidence(
+ autosaveName: "codexbar-merged",
+ expectsVisibility: true,
+ visibilityDefault: true,
+ snapshot: hidden)
+
+ #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery(
+ appLaunchedAt: launchedAt,
+ now: launchedAt.addingTimeInterval(2),
+ snapshots: [hidden],
+ evidence: [evidence]))
}
@Test
@@ -262,7 +402,7 @@ struct MenuBarVisibilityWatcherTests {
appLaunchedAt: launchedAt,
now: launchedAt.addingTimeInterval(2),
snapshots: [managed],
- detectTahoeBlockedProxy: true))
+ detectTahoeBlockedStatusItem: true))
}
@Test
diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift
index cd48764d91..b07ef767c6 100644
--- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift
+++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift
@@ -91,6 +91,53 @@ struct MenuCardAntigravityTests {
#expect(model.metrics[0].percentLabel == "95% left")
}
+ @Test
+ func `legacy antigravity family row renders session pace without mutating duration`() throws {
+ let now = Date(timeIntervalSince1970: 0)
+ let window = RateWindow(
+ usedPercent: 80,
+ windowMinutes: nil,
+ resetsAt: now.addingTimeInterval(2 * 3600),
+ resetDescription: nil)
+ let snapshot = UsageSnapshot(
+ primary: window,
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now,
+ identity: ProviderIdentitySnapshot(
+ providerID: .antigravity,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Pro"))
+ let metadata = try #require(ProviderDefaults.metadata[.antigravity])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .antigravity,
+ metadata: metadata,
+ snapshot: snapshot,
+ 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(snapshot.primary?.windowMinutes == nil)
+ #expect(model.metrics.map(\.detailLeftText) == ["20% in deficit"])
+ #expect(model.metrics.map(\.detailRightText) == ["Projected empty in 45m"])
+ #expect(model.metrics[0].pacePercent == 40)
+ #expect(model.metrics[0].paceOnTop == false)
+ }
+
@Test
func `antigravity untracked known row does not duplicate grouped summary`() throws {
let now = Date(timeIntervalSince1970: 1_735_000_000)
@@ -363,6 +410,72 @@ struct MenuCardAntigravityTests {
#expect(model.metrics[2].resetText == "Resets in 3 hours")
}
+ @Test
+ func `antigravity quota summary rows render pace details`() throws {
+ let now = Date(timeIntervalSince1970: 0)
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ extraRateWindows: [
+ NamedRateWindow(
+ id: "antigravity-quota-summary-gemini-5h",
+ title: "Gemini Models Five Hour Limit",
+ window: RateWindow(
+ usedPercent: 80,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(2 * 3600),
+ resetDescription: nil)),
+ NamedRateWindow(
+ id: "antigravity-quota-summary-gemini-weekly",
+ title: "Gemini Models Weekly Limit",
+ window: RateWindow(
+ usedPercent: 50,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(4 * 24 * 3600),
+ resetDescription: nil)),
+ ],
+ updatedAt: now,
+ identity: ProviderIdentitySnapshot(
+ providerID: .antigravity,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Pro"))
+ let metadata = try #require(ProviderDefaults.metadata[.antigravity])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .antigravity,
+ metadata: metadata,
+ snapshot: snapshot,
+ 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: false,
+ hidePersonalInfo: false,
+ now: now))
+
+ #expect(model.metrics.map(\.detailLeftText) == [
+ "20% in deficit",
+ "7% in deficit",
+ ])
+ #expect(model.metrics.map(\.detailRightText) == [
+ "Projected empty in 45m",
+ "Runs out in 3d",
+ ])
+ #expect(model.metrics[0].pacePercent == 40)
+ #expect(abs((model.metrics[1].pacePercent ?? 0) - (400.0 / 7.0)) < 0.01)
+ #expect(model.metrics.map(\.paceOnTop) == [false, false])
+ }
+
@Test
func `antigravity missing groups are omitted in used mode`() throws {
let now = Date()
diff --git a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift
new file mode 100644
index 0000000000..9848354b77
--- /dev/null
+++ b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift
@@ -0,0 +1,76 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+/// Menu-model coverage for claude-swap account cards: the provider-neutral
+/// projection renders as a regular Claude usage card with session/weekly
+/// windows, account identity, and Hide Personal Info redaction.
+struct MenuCardClaudeSwapAccountTests {
+ private func makeModel(
+ hidePersonalInfo: Bool,
+ planOverride: String? = nil) throws -> UsageMenuCardView.Model
+ {
+ let now = Date(timeIntervalSince1970: 1_782_000_000)
+ let metadata = try #require(ProviderDefaults.metadata[.claude])
+ let list = ClaudeSwapAccountList(
+ activeAccountNumber: 2,
+ accounts: [
+ ClaudeSwapAccountRow(
+ number: 2,
+ email: "personal@example.com",
+ isActive: true,
+ usageStatus: .ok,
+ fiveHour: ClaudeSwapUsageWindow(usedPercent: 25, resetsAt: now.addingTimeInterval(3600)),
+ sevenDay: ClaudeSwapUsageWindow(usedPercent: 60, resetsAt: now.addingTimeInterval(86400))),
+ ])
+ let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).first)
+
+ return UsageMenuCardView.Model.make(.init(
+ provider: .claude,
+ metadata: metadata,
+ snapshot: account.snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: account.displayLabel, plan: nil),
+ planOverride: planOverride,
+ isRefreshing: false,
+ lastError: account.error,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: false,
+ hidePersonalInfo: hidePersonalInfo,
+ now: now))
+ }
+
+ @Test
+ func `claude swap action overrides adapter login method`() throws {
+ let model = try self.makeModel(hidePersonalInfo: false, planOverride: "Switch Account...")
+
+ #expect(model.planText == "Switch Account...")
+ }
+
+ @Test
+ func `claude swap account snapshot renders session and weekly metrics with identity`() throws {
+ let model = try self.makeModel(hidePersonalInfo: false)
+
+ #expect(model.email == "personal@example.com")
+ let primary = try #require(model.metrics.first(where: { $0.id == "primary" }))
+ #expect(primary.percent == 25)
+ let secondary = try #require(model.metrics.first(where: { $0.id == "secondary" }))
+ #expect(secondary.percent == 60)
+ }
+
+ @Test
+ func `claude swap account card respects hide personal info`() throws {
+ let model = try self.makeModel(hidePersonalInfo: true)
+
+ #expect(!model.email.contains("personal@example.com"))
+ #expect(!model.email.contains("example.com"))
+ }
+}
diff --git a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift
new file mode 100644
index 0000000000..48ce6c996d
--- /dev/null
+++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift
@@ -0,0 +1,113 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+struct MenuCardCostComparisonTests {
+ @Test
+ func `cost section adds shorter periods from the same history snapshot`() throws {
+ let snapshot = CostUsageTokenSnapshot(
+ sessionTokens: 400,
+ sessionCostUSD: 4,
+ last30DaysTokens: 1000,
+ last30DaysCostUSD: 10,
+ historyDays: 90,
+ daily: [
+ Self.entry(day: "2026-06-01", cost: 1, tokens: 100),
+ Self.entry(day: "2026-06-25", cost: 2, tokens: 200),
+ Self.entry(day: "2026-07-01", cost: 4, tokens: 400),
+ ],
+ updatedAt: Self.localNoon(year: 2026, month: 7, day: 1))
+
+ let section = try #require(UsageMenuCardView.Model.tokenUsageSection(
+ provider: .claude,
+ enabled: true,
+ comparisonPeriodsEnabled: true,
+ snapshot: snapshot,
+ error: nil))
+
+ #expect(section.comparisonLines == [
+ "Last 7 days: $6.00 · 600 tokens",
+ "Last 30 days: $6.00 · 600 tokens",
+ ])
+ }
+
+ @Test
+ func `comparison periods remain opt in`() throws {
+ let snapshot = CostUsageTokenSnapshot(
+ sessionTokens: 1,
+ sessionCostUSD: 1,
+ last30DaysTokens: 1,
+ last30DaysCostUSD: 1,
+ historyDays: 90,
+ daily: [],
+ updatedAt: Date())
+
+ let section = try #require(UsageMenuCardView.Model.tokenUsageSection(
+ provider: .claude,
+ enabled: true,
+ comparisonPeriodsEnabled: false,
+ snapshot: snapshot,
+ error: nil))
+ #expect(section.comparisonLines.isEmpty)
+ }
+
+ @Test
+ func `inline dashboard shows enabled comparison periods`() throws {
+ let now = Date(timeIntervalSince1970: 1_783_123_200)
+ let snapshot = CostUsageTokenSnapshot(
+ sessionTokens: 400,
+ sessionCostUSD: 4,
+ last30DaysTokens: 1000,
+ last30DaysCostUSD: 10,
+ historyDays: 90,
+ daily: [
+ Self.entry(day: "2026-06-01", cost: 1, tokens: 100),
+ Self.entry(day: "2026-06-25", cost: 2, tokens: 200),
+ Self.entry(day: "2026-07-01", cost: 4, tokens: 400),
+ ],
+ updatedAt: now)
+ let metadata = try #require(ProviderDefaults.metadata[.codex])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .codex,
+ metadata: metadata,
+ snapshot: nil,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: snapshot,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: true,
+ costComparisonPeriodsEnabled: true,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ #expect(model.inlineUsageDashboard?.detailLines.prefix(2) == [
+ "Last 7 days: $4.00 · 400 tokens",
+ "Last 30 days: $6.00 · 600 tokens",
+ ])
+ }
+
+ private static func entry(day: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry {
+ CostUsageDailyReport.Entry(
+ date: day,
+ inputTokens: nil,
+ outputTokens: nil,
+ totalTokens: tokens,
+ costUSD: cost,
+ modelsUsed: nil,
+ modelBreakdowns: nil)
+ }
+
+ private static func localNoon(year: Int, month: Int, day: Int) -> Date {
+ Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))!
+ }
+}
diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift
index b54e852e7f..7e2ec20c12 100644
--- a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift
+++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift
@@ -33,9 +33,25 @@ struct MenuCardHeightFingerprintTests {
#expect(left != changedPercent)
}
+ @Test
+ func `height fingerprint tracks reset-credit inventory shape`() {
+ let one = Self.model(resetCredits: CodexResetCreditsPresentation(
+ text: "1 available",
+ items: [.init(expiryText: "Expires in 1d", compactExpiryText: "1d")]))
+ let two = Self.model(resetCredits: CodexResetCreditsPresentation(
+ text: "2 available",
+ items: [
+ .init(expiryText: "Expires in 1d", compactExpiryText: "1d"),
+ .init(expiryText: "No expiry", compactExpiryText: "No expiry"),
+ ]))
+
+ #expect(one.heightFingerprint(section: "card") != two.heightFingerprint(section: "card"))
+ }
+
private static func model(
percent: Double = 42,
- percentStyle: UsageMenuCardView.Model.PercentStyle = .left) -> UsageMenuCardView.Model
+ percentStyle: UsageMenuCardView.Model.PercentStyle = .left,
+ resetCredits: CodexResetCreditsPresentation? = nil) -> UsageMenuCardView.Model
{
UsageMenuCardView.Model(
provider: .codex,
@@ -67,6 +83,7 @@ struct MenuCardHeightFingerprintTests {
creditsScaleText: nil,
creditsHintText: nil,
creditsHintCopyText: nil,
+ codexResetCredits: resetCredits,
providerCost: nil,
tokenUsage: nil,
placeholder: nil,
diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift
index 506f4b23e2..58fa3abeda 100644
--- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift
+++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift
@@ -127,14 +127,15 @@ struct MenuCardModelCodexProjectionTests {
now: now))
let weekly = try #require(model.metrics.first { $0.id == "secondary" })
- #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0])
+ #expect(weekly.warningMarkerPercents.isEmpty)
+ #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0])
let session = try #require(model.metrics.first { $0.id == "primary" })
#expect(session.warningMarkerPercents.isEmpty)
}
@Test
- func `codex weekly lane workday markers merge with quota warning markers`() throws {
+ func `codex weekly lane keeps workday and quota warning markers separate`() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let metadata = try #require(ProviderDefaults.metadata[.codex])
let identity = ProviderIdentitySnapshot(
@@ -193,7 +194,8 @@ struct MenuCardModelCodexProjectionTests {
now: now))
let weekly = try #require(model.metrics.first { $0.id == "secondary" })
- #expect(weekly.warningMarkerPercents == [20.0, 40.0, 50.0, 60.0, 80.0])
+ #expect(weekly.warningMarkerPercents == [50.0])
+ #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0])
}
@Test
@@ -256,7 +258,8 @@ struct MenuCardModelCodexProjectionTests {
now: now))
let weekly = try #require(model.metrics.first { $0.id == "secondary" })
- #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0])
+ #expect(weekly.warningMarkerPercents.isEmpty)
+ #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0])
}
@Test
diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift
index c0552cee2d..ce5f01de36 100644
--- a/Tests/CodexBarTests/MenuCardModelTests.swift
+++ b/Tests/CodexBarTests/MenuCardModelTests.swift
@@ -101,6 +101,47 @@ struct OverviewMenuCardVisibilityTests {
}
struct ProviderInlineDashboardModelTests {
+ @Test
+ func `kimi model orders rate limit before weekly quota`() throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let metadata = try #require(ProviderDefaults.metadata[.kimi])
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 18.3,
+ windowMinutes: nil,
+ resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60),
+ resetDescription: "375/2048 requests"),
+ secondary: RateWindow(
+ usedPercent: 9.5,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(4 * 60 * 60),
+ resetDescription: "Rate: 19/200 per 5 hours"),
+ updatedAt: now)
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .kimi,
+ metadata: metadata,
+ snapshot: snapshot,
+ 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.map(\.id) == ["secondary", "primary"])
+ #expect(model.metrics.map(\.title) == ["Rate Limit", "Weekly"])
+ }
+
@Test
func `openrouter period usage gets inline dashboard`() throws {
let now = Date(timeIntervalSince1970: 1_700_179_200)
@@ -1527,50 +1568,4 @@ struct MenuCardModelTests {
#expect(primary.resetText == nil)
#expect(primary.detailText == "10/100 credits")
}
-
- @Test
- func `mistral model surfaces monthly cost as primary detail text`() throws {
- let now = Date()
- let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60)
- let identity = ProviderIdentitySnapshot(
- providerID: .mistral,
- accountEmail: nil,
- accountOrganization: nil,
- loginMethod: nil)
- let snapshot = UsageSnapshot(
- primary: RateWindow(
- usedPercent: 0,
- windowMinutes: nil,
- resetsAt: resetsAt,
- resetDescription: "€1.2345 this month"),
- secondary: nil,
- tertiary: nil,
- updatedAt: now,
- identity: identity)
- let metadata = try #require(ProviderDefaults.metadata[.mistral])
-
- let model = UsageMenuCardView.Model.make(.init(
- provider: .mistral,
- metadata: metadata,
- snapshot: snapshot,
- credits: nil,
- creditsError: nil,
- dashboard: nil,
- dashboardError: nil,
- tokenSnapshot: nil,
- tokenError: nil,
- account: AccountInfo(email: nil, plan: nil),
- isRefreshing: false,
- lastError: nil,
- usageBarsShowUsed: true,
- resetTimeDisplayStyle: .countdown,
- tokenCostUsageEnabled: false,
- showOptionalCreditsAndExtraUsage: true,
- hidePersonalInfo: false,
- now: now))
-
- let primary = try #require(model.metrics.first)
- #expect(primary.detailText == "€1.2345 this month")
- #expect(primary.resetText?.hasPrefix("Resets") == true)
- }
}
diff --git a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift
new file mode 100644
index 0000000000..b6a061bfd0
--- /dev/null
+++ b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift
@@ -0,0 +1,91 @@
+import AppKit
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+@MainActor
+struct MenuCardOverrideIsolationTests {
+ @Test
+ func `nil snapshot account card does not inherit ambient Claude costs`() throws {
+ let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.costUsageEnabled = true
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ store._setTokenSnapshotForTesting(
+ CostUsageTokenSnapshot(
+ sessionTokens: 123,
+ sessionCostUSD: 0.12,
+ last30DaysTokens: 456,
+ last30DaysCostUSD: 1.23,
+ daily: [],
+ updatedAt: Date()),
+ provider: .claude)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+
+ let model = try #require(controller.menuCardModel(
+ for: .claude,
+ errorOverride: "Token expired",
+ forceOverrideCard: true,
+ accountOverride: AccountInfo(email: "account@example.com", plan: nil)))
+
+ #expect(model.tokenUsage == nil)
+ #expect(model.email == "account@example.com")
+ }
+
+ @Test
+ func `account card without its own error does not inherit the ambient Claude error`() throws {
+ let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ store._setErrorForTesting("Claude OAuth credentials unavailable", provider: .claude)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ let accountSnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date(),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: "account@example.com",
+ accountOrganization: nil,
+ loginMethod: "claude-swap"))
+
+ let model = try #require(controller.menuCardModel(
+ for: .claude,
+ snapshotOverride: accountSnapshot,
+ accountOverride: AccountInfo(email: "account@example.com", plan: nil)))
+
+ #expect(model.subtitleStyle != .error)
+ #expect(!model.subtitleText.contains("Claude OAuth credentials unavailable"))
+
+ let liveModel = try #require(controller.menuCardModel(for: .claude))
+ #expect(liveModel.subtitleStyle == .error)
+ #expect(liveModel.subtitleText == "Claude OAuth credentials unavailable")
+ }
+}
diff --git a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift
index 89c8ca9e16..744f6fa7c0 100644
--- a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift
+++ b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift
@@ -13,18 +13,90 @@ struct MenuCardQuotaWarningMarkerTests {
}
@Test
- func `quota warning marker geometry is inset and hairline`() {
+ func `quota warning marker geometry matches pace stripe edges`() {
let rect = UsageProgressBar.warningMarkerRect(
x: 50,
size: CGSize(width: 100, height: 6),
scale: 2)
+ let stripe = UsageProgressBar.warningMarkerStripeRect(
+ rect,
+ scale: 2)
+
+ #expect(rect.width == 5)
+ #expect(rect.height == 6)
+ #expect(rect.minY == 0)
+ #expect(rect.maxY == 6)
+ #expect(abs(rect.midX - 50) <= 0.5)
+ #expect(stripe.width == 1)
+ #expect(stripe.height == rect.height)
+ #expect(abs(stripe.midX - rect.midX) <= 0.001)
+ #expect(stripe.minX > rect.minX)
+ #expect(stripe.maxX < rect.maxX)
+ }
+
+ @Test
+ func `quota warning marker geometry stays centered across display scales`() {
+ let scales: [CGFloat] = [1, 2, 3]
+
+ for scale in scales {
+ let rect = UsageProgressBar.warningMarkerRect(
+ x: 33,
+ size: CGSize(width: 100, height: 6),
+ scale: scale)
+ let stripe = UsageProgressBar.warningMarkerStripeRect(
+ rect,
+ scale: scale)
+
+ #expect(rect.minY == 0)
+ #expect(rect.height == 6)
+ #expect(rect.width == 5)
+ #expect(stripe.width == 1)
+ #expect(stripe.height == rect.height)
+ #expect(abs(stripe.midX - rect.midX) <= 1 / scale)
+ #expect(stripe.minX > rect.minX)
+ #expect(stripe.maxX < rect.maxX)
+ }
+ }
+
+ @Test
+ func `workday boundary is a subtle lower tick`() {
+ let rect = UsageProgressBar.workdayMarkerRect(
+ x: 50,
+ size: CGSize(width: 100, height: 6),
+ scale: 2)
- #expect(rect.width == 1)
- #expect(rect.height < 6)
- #expect(rect.minY > 0)
+ #expect(rect.width == 0.5)
+ #expect(rect.height == 3)
+ #expect(rect.minY == 3)
#expect(abs(rect.midX - 50) <= 0.5)
}
+ @Test
+ func `quota warning wins when marker kinds overlap`() {
+ let markers = UsageProgressBar.resolvedMarkers(
+ warningPercents: [50, 80],
+ workdayPercents: [20, 50, 60])
+
+ #expect(markers == [
+ .init(percent: 20, kind: .workdayBoundary),
+ .init(percent: 50, kind: .quotaWarning),
+ .init(percent: 60, kind: .workdayBoundary),
+ .init(percent: 80, kind: .quotaWarning),
+ ])
+ }
+
+ @Test
+ func `marker resolver removes edges duplicates and invalid values`() {
+ let markers = UsageProgressBar.resolvedMarkers(
+ warningPercents: [-10, 0, 50, 50, 100, 120],
+ workdayPercents: [Double.nan, 25, 25])
+
+ #expect(markers == [
+ .init(percent: 25, kind: .workdayBoundary),
+ .init(percent: 50, kind: .quotaWarning),
+ ])
+ }
+
@Test
func `omits quota warning markers for disabled windows`() throws {
let now = Date()
diff --git a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift
index 392c114d36..8660018b2a 100644
--- a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift
+++ b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift
@@ -104,4 +104,55 @@ struct MenuDescriptorAntigravityTests {
#expect(!lines.contains("Gemini Flash unavailable."))
#expect(!lines.contains("Limits not available"))
}
+
+ @Test
+ func `antigravity descriptor does not render session pace for weekly primary window`() throws {
+ let suite = "MenuDescriptorAntigravityTests-weekly-primary-pace"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 80,
+ windowMinutes: 10080,
+ resetsAt: Date().addingTimeInterval(2 * 24 * 3600),
+ resetDescription: nil),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: Date(),
+ identity: ProviderIdentitySnapshot(
+ providerID: .antigravity,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Pro"))
+ store._setSnapshotForTesting(snapshot, provider: .antigravity)
+
+ let descriptor = MenuDescriptor.build(
+ provider: .antigravity,
+ store: store,
+ settings: settings,
+ account: AccountInfo(email: nil, plan: nil),
+ updateReady: false,
+ includeContextualActions: false)
+
+ let lines = descriptor.sections
+ .flatMap(\.entries)
+ .compactMap { entry -> String? in
+ guard case let .text(text, _) = entry else { return nil }
+ return text
+ }
+
+ #expect(!lines.contains { $0.hasPrefix("Pace:") })
+ }
}
diff --git a/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift b/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift
new file mode 100644
index 0000000000..62a9304ea4
--- /dev/null
+++ b/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift
@@ -0,0 +1,128 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+@MainActor
+struct MenuDescriptorSakanaTests {
+ @Test
+ func `sakana pay as you go rows render when optional usage is enabled`() throws {
+ let lines = try Self.menuLines(showOptionalUsage: true)
+
+ #expect(lines.contains("Balance: $12.34"))
+ #expect(lines.contains("Usage: $5.67"))
+ }
+
+ @Test
+ func `sakana pay as you go rows are hidden when optional usage is disabled`() throws {
+ // Regression for the render-path staleness gap: toggling "Show optional credits and extra
+ // usage" off only rebuilds the menu, it does not immediately refetch, so a
+ // previously-populated sakanaPayAsYouGo lingers in the cached snapshot. The rows must be
+ // gated on the setting, not only on the presence of the (possibly stale) snapshot field.
+ let lines = try Self.menuLines(showOptionalUsage: false)
+
+ #expect(!lines.contains(where: { $0.hasPrefix("Balance:") }))
+ #expect(!lines.contains(where: { $0.hasPrefix("Usage:") }))
+ // The required quota windows must still render regardless of the optional-usage setting.
+ #expect(lines.contains(where: { $0.hasPrefix("5-hour") }))
+ #expect(lines.contains(where: { $0.hasPrefix("Weekly") }))
+ }
+
+ private static func menuLines(showOptionalUsage: Bool) throws -> [String] {
+ let suite = "MenuDescriptorSakanaTests-\(showOptionalUsage)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+ settings.showOptionalCreditsAndExtraUsage = showOptionalUsage
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ let snapshot = SakanaUsageSnapshot(
+ planName: "Standard",
+ priceLabel: "$20/mo",
+ fiveHour: .init(usedPercent: 10, resetsAt: nil),
+ weekly: .init(usedPercent: 20, resetsAt: nil),
+ payAsYouGo: SakanaPayAsYouGoSnapshot(
+ creditBalance: 12.34,
+ periodUsageTotal: 5.67,
+ periodLabel: "Jun 02, 2026 - Jul 01, 2026"))
+ store._setSnapshotForTesting(snapshot.toUsageSnapshot(), provider: .sakana)
+
+ let descriptor = MenuDescriptor.build(
+ provider: .sakana,
+ store: store,
+ settings: settings,
+ account: AccountInfo(email: nil, plan: nil),
+ updateReady: false,
+ includeContextualActions: false)
+ return descriptor.sections
+ .flatMap(\.entries)
+ .compactMap { entry -> String? in
+ guard case let .text(text, _) = entry else { return nil }
+ return text
+ }
+ }
+}
+
+struct SakanaMenuCardModelTests {
+ @Test
+ func `pay as you go renders in the live menu card`() throws {
+ let model = try Self.model(showOptionalUsage: true)
+
+ #expect(model.providerCost?.title == "Extra usage")
+ #expect(model.providerCost?.spendLine == "Balance: $12.34")
+ #expect(model.providerCost?.percentLine == "Usage: $5.67")
+ #expect(model.providerCost?.percentUsed == nil)
+ }
+
+ @Test
+ func `pay as you go hides immediately when optional usage is disabled`() throws {
+ let model = try Self.model(showOptionalUsage: false)
+
+ #expect(model.providerCost == nil)
+ #expect(model.metrics.map(\.title) == ["5-hour", "Weekly"])
+ }
+
+ private static func model(showOptionalUsage: Bool) throws -> UsageMenuCardView.Model {
+ let now = Date(timeIntervalSince1970: 0)
+ let snapshot = SakanaUsageSnapshot(
+ planName: "Standard",
+ priceLabel: "$20/mo",
+ fiveHour: .init(usedPercent: 10, resetsAt: nil),
+ weekly: .init(usedPercent: 20, resetsAt: nil),
+ payAsYouGo: SakanaPayAsYouGoSnapshot(
+ creditBalance: 12.34,
+ periodUsageTotal: 5.67,
+ periodLabel: "Jun 02, 2026 - Jul 01, 2026"),
+ updatedAt: now)
+ let metadata = try #require(ProviderDefaults.metadata[.sakana])
+
+ return UsageMenuCardView.Model.make(.init(
+ provider: .sakana,
+ metadata: metadata,
+ snapshot: snapshot.toUsageSnapshot(),
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: showOptionalUsage,
+ hidePersonalInfo: false,
+ now: now))
+ }
+}
diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift
index 23740cb502..11a80863d4 100644
--- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift
+++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift
@@ -151,9 +151,11 @@ struct MiniMaxMenuCardModelPlanTests {
showOptionalCreditsAndExtraUsage: true,
hidePersonalInfo: false,
quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]],
+ workDaysPerWeek: 5,
now: now))
#expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]])
+ #expect(model.metrics.map(\.workdayMarkerPercents) == [[], [20, 40, 60, 80]])
}
@Test
diff --git a/Tests/CodexBarTests/MistralMenuCardModelTests.swift b/Tests/CodexBarTests/MistralMenuCardModelTests.swift
new file mode 100644
index 0000000000..5726839e69
--- /dev/null
+++ b/Tests/CodexBarTests/MistralMenuCardModelTests.swift
@@ -0,0 +1,168 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+struct MistralMenuCardModelTests {
+ @Test
+ func `mistral credit balance renders like deepseek balance`() throws {
+ let now = Date()
+ let credits = MistralCreditsSnapshot(
+ walletAmount: 0,
+ creditNotesAmount: 0,
+ ongoingUsageBalance: 0,
+ currency: "USD")
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 0,
+ currency: "USD",
+ currencySymbol: "$",
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCachedTokens: 0,
+ modelCount: 0,
+ credits: credits,
+ startDate: nil,
+ endDate: nil,
+ updatedAt: now)
+ .toUsageSnapshot()
+ let metadata = try #require(ProviderDefaults.metadata[.mistral])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .mistral,
+ metadata: metadata,
+ snapshot: snapshot,
+ 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))
+
+ let primary = try #require(model.metrics.first)
+ #expect(primary.title == "Balance")
+ #expect(primary.statusText == "$0.00")
+ #expect(primary.resetText == nil)
+ #expect(primary.detailText == nil)
+ }
+
+ @Test
+ func `mistral credit balance renders separately from primary percent lane`() throws {
+ let now = Date()
+ let credits = MistralCreditsSnapshot(
+ walletAmount: 10,
+ creditNotesAmount: 2.5,
+ ongoingUsageBalance: 0,
+ currency: "USD")
+ let usage = MistralUsageSnapshot(
+ totalCost: 0,
+ currency: "USD",
+ currencySymbol: "$",
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCachedTokens: 0,
+ modelCount: 0,
+ credits: credits,
+ startDate: nil,
+ endDate: nil,
+ updatedAt: now)
+ .toUsageSnapshot()
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 73,
+ windowMinutes: nil,
+ resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60),
+ resetDescription: "API spend this month"),
+ secondary: nil,
+ tertiary: nil,
+ mistralUsage: usage.mistralUsage,
+ updatedAt: now,
+ identity: usage.identity)
+ let metadata = try #require(ProviderDefaults.metadata[.mistral])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .mistral,
+ metadata: metadata,
+ snapshot: snapshot,
+ 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))
+
+ let primary = try #require(model.metrics.first)
+ #expect(primary.id == "mistral-balance")
+ #expect(primary.statusText == "$12.50")
+ #expect(primary.detailText == nil)
+ #expect(primary.resetText == nil)
+
+ let percentMetric = try #require(model.metrics.dropFirst().first)
+ #expect(percentMetric.id == "primary")
+ #expect(percentMetric.percent == 27)
+ #expect(percentMetric.detailText == "API spend this month")
+ }
+
+ @Test
+ func `mistral model surfaces monthly cost as primary detail text`() throws {
+ let now = Date()
+ let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60)
+ let identity = ProviderIdentitySnapshot(
+ providerID: .mistral,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: resetsAt,
+ resetDescription: "€1.2345 this month"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now,
+ identity: identity)
+ let metadata = try #require(ProviderDefaults.metadata[.mistral])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .mistral,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let primary = try #require(model.metrics.first)
+ #expect(primary.detailText == "€1.2345 this month")
+ #expect(primary.resetText?.hasPrefix("Resets") == true)
+ }
+}
diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift
index b380175365..3858d56cde 100644
--- a/Tests/CodexBarTests/MistralUsageParserTests.swift
+++ b/Tests/CodexBarTests/MistralUsageParserTests.swift
@@ -47,6 +47,101 @@ struct MistralUsageParserTests {
#expect(snapshot.totalCost > 0)
}
+ @Test(arguments: ["NaN", "Infinity", "1e308"])
+ func `ignores prices that produce nonfinite costs`(price: String) async throws {
+ let json = """
+ {
+ "completion": {
+ "models": {
+ "mistral-small": {
+ "input": [{
+ "billing_metric": "tokens",
+ "billing_group": "input",
+ "timestamp": "2026-07-04",
+ "value": 2
+ }]
+ }
+ }
+ },
+ "prices": [{
+ "billing_metric": "tokens",
+ "billing_group": "input",
+ "price": "\(price)"
+ }]
+ }
+ """
+ let transport = ProviderHTTPTransportHandler { request in
+ #expect(request.url?.path == "/api/billing/v2/usage")
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "ory_session_test=abc")
+ let requestURL = try #require(request.url)
+ let response = try #require(HTTPURLResponse(
+ url: requestURL,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil))
+ return (Data(json.utf8), response)
+ }
+
+ let snapshot = try await MistralUsageFetcher.fetchUsage(
+ cookieHeader: "ory_session_test=abc",
+ csrfToken: nil,
+ transport: transport)
+
+ #expect(snapshot.totalCost == 0)
+ #expect(snapshot.totalCost.isFinite)
+ #expect(snapshot.daily.first?.cost == 0)
+ #expect(snapshot.daily.first?.models.first?.cost == 0)
+ }
+
+ @Test
+ func `keeps cost totals finite when individually valid costs overflow their sum`() throws {
+ let json = """
+ {
+ "completion": {
+ "models": {
+ "mistral-small": {
+ "input": [
+ {
+ "billing_metric": "tokens",
+ "billing_group": "input",
+ "timestamp": "2026-07-04",
+ "value": 1
+ },
+ {
+ "billing_metric": "tokens",
+ "billing_group": "input",
+ "timestamp": "2026-07-04",
+ "value": 1
+ }
+ ]
+ },
+ "mistral-large": {
+ "input": [{
+ "billing_metric": "tokens",
+ "billing_group": "input",
+ "timestamp": "2026-07-04",
+ "value": 1
+ }]
+ }
+ }
+ },
+ "prices": [{
+ "billing_metric": "tokens",
+ "billing_group": "input",
+ "price": "1e308"
+ }]
+ }
+ """
+
+ let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date())
+
+ #expect(snapshot.totalCost == 1e308)
+ #expect(snapshot.totalCost.isFinite)
+ #expect(snapshot.daily.first?.cost == 1e308)
+ #expect(snapshot.daily.first?.models.count == 2)
+ #expect(snapshot.daily.first?.models.allSatisfy { $0.cost == 1e308 } == true)
+ }
+
@Test
func `parses empty response with no usage`() throws {
let data = try #require(Self.emptyResponseJSON.data(using: .utf8))
@@ -59,6 +154,98 @@ struct MistralUsageParserTests {
#expect(snapshot.currency == "EUR")
}
+ @Test
+ func `parses credits response`() throws {
+ let json = """
+ {
+ "wallet_amount": 12.5,
+ "credit_notes_amount": 2.25,
+ "ongoing_usage_balance": 1.5,
+ "currency": "USD",
+ "minimum_credits_purchase": 10,
+ "maximum_credits_purchase": 1000
+ }
+ """
+
+ let credits = try MistralUsageFetcher.parseCredits(data: Data(json.utf8))
+
+ #expect(credits.walletAmount == 12.5)
+ #expect(credits.creditNotesAmount == 2.25)
+ #expect(credits.ongoingUsageBalance == 1.5)
+ #expect(credits.currency == "USD")
+ #expect(credits.availableAmount == 13.25)
+ #expect(credits.formattedAvailableAmount == "$13.25")
+ }
+
+ @Test
+ func `credits available amount floors after ongoing usage`() {
+ let credits = MistralCreditsSnapshot(
+ walletAmount: 1,
+ creditNotesAmount: 0.5,
+ ongoingUsageBalance: 3,
+ currency: "USD")
+
+ #expect(credits.availableAmount == 0)
+ #expect(credits.formattedAvailableAmount == "$0.00")
+ }
+
+ @Test
+ func `rejects credit amounts whose sum overflows`() throws {
+ let json = """
+ {
+ "wallet_amount": 1e308,
+ "credit_notes_amount": 1e308,
+ "ongoing_usage_balance": 0,
+ "currency": "USD"
+ }
+ """
+
+ #expect(throws: MistralUsageError.self) {
+ try MistralUsageFetcher.parseCredits(data: Data(json.utf8))
+ }
+
+ let credits = MistralCreditsSnapshot(
+ walletAmount: 1e308,
+ creditNotesAmount: 1e308,
+ ongoingUsageBalance: 0,
+ currency: "USD")
+ #expect(credits.availableAmount == 0)
+ #expect(credits.formattedAvailableAmount == "$0.00")
+ }
+
+ @Test
+ func `fetches credits from dashboard endpoint with existing web session`() async throws {
+ let json = """
+ {
+ "wallet_amount": 3,
+ "credit_notes_amount": 4,
+ "ongoing_usage_balance": 0,
+ "currency": "EUR"
+ }
+ """
+ let transport = ProviderHTTPTransportHandler { request in
+ #expect(request.url?.absoluteString == "https://admin.mistral.ai/api/billing/credits")
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "ory_session_test=abc; csrftoken=csrf")
+ #expect(request.value(forHTTPHeaderField: "X-CSRFTOKEN") == "csrf")
+ #expect(request.value(forHTTPHeaderField: "Referer") == "https://admin.mistral.ai/organization/billing")
+ let requestURL = try #require(request.url)
+ let response = try #require(HTTPURLResponse(
+ url: requestURL,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil))
+ return (Data(json.utf8), response)
+ }
+
+ let credits = try await MistralUsageFetcher.fetchCredits(
+ cookieHeader: "ory_session_test=abc; csrftoken=csrf",
+ csrfToken: "csrf",
+ transport: transport)
+
+ #expect(credits.availableAmount == 7)
+ #expect(credits.formattedAvailableAmount == "€7.00")
+ }
+
@Test
func `daily spend keeps non token Mistral units out of token totals`() throws {
let json = """
@@ -149,6 +336,33 @@ struct MistralUsageSnapshotConversionTests {
#expect(usage.providerCost == nil)
}
+ @Test
+ func `converts credits into balance data without replacing api spend or primary percent`() {
+ let credits = MistralCreditsSnapshot(
+ walletAmount: 10,
+ creditNotesAmount: 2.5,
+ ongoingUsageBalance: 1,
+ currency: "USD")
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 1.2345,
+ currency: "USD",
+ currencySymbol: "$",
+ totalInputTokens: 10000,
+ totalOutputTokens: 5000,
+ totalCachedTokens: 0,
+ modelCount: 2,
+ credits: credits,
+ startDate: nil,
+ endDate: Date(),
+ updatedAt: Date())
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary == nil)
+ #expect(usage.identity?.loginMethod == "API spend: $1.2345 this month")
+ #expect(usage.mistralUsage?.credits == credits)
+ #expect(usage.mistralUsage?.credits?.formattedAvailableAmount == "$11.50")
+ }
+
@Test
func `converts zero cost into zero spend text`() {
let snapshot = MistralUsageSnapshot(
diff --git a/Tests/CodexBarTests/MistralVibeUsageTests.swift b/Tests/CodexBarTests/MistralVibeUsageTests.swift
index f98cdcdc15..97787940f2 100644
--- a/Tests/CodexBarTests/MistralVibeUsageTests.swift
+++ b/Tests/CodexBarTests/MistralVibeUsageTests.swift
@@ -18,6 +18,23 @@ private final class MistralRequestCapture: @unchecked Sendable {
}
}
+private final class MistralRequestPathLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var storedPaths: [String] = []
+
+ var paths: [String] {
+ self.lock.withLock { self.storedPaths }
+ }
+
+ func record(_ request: URLRequest) {
+ let host = request.url?.host ?? ""
+ let path = request.url?.path ?? ""
+ self.lock.withLock {
+ self.storedPaths.append("\(host)\(path)")
+ }
+ }
+}
+
struct MistralVibeUsageTests {
#if os(macOS)
@Test
@@ -132,6 +149,45 @@ struct MistralVibeUsageTests {
#expect(result == nil)
}
+ @Test
+ func `combined fetch preserves monthly plan when optional credits time out`() async throws {
+ let requestLog = MistralRequestPathLog()
+ let usageData = Data(Self.billingUsageResponseJSON.utf8)
+ let vibeData = Data(Self.responseJSON(usagePercentage: 37).utf8)
+ let transport = ProviderHTTPTransportHandler { request in
+ requestLog.record(request)
+ guard let url = request.url else { throw URLError(.badURL) }
+ if url.host == "admin.mistral.ai", url.path == "/api/billing/v2/usage" {
+ let response = try Self.response(url: url, statusCode: 200)
+ return (usageData, response)
+ }
+ if url.host == "console.mistral.ai" {
+ let response = try Self.response(url: url, statusCode: 200)
+ return (vibeData, response)
+ }
+ if url.host == "admin.mistral.ai", url.path == "/api/billing/credits" {
+ try await Task.sleep(for: .milliseconds(25))
+ throw URLError(.timedOut)
+ }
+ throw URLError(.badURL)
+ }
+
+ let snapshot = try await MistralWebFetchStrategy.fetchUsageWithVibe(
+ cookieHeader: "ory_session_test=abc; csrftoken=csrf",
+ csrfToken: "csrf",
+ timeout: 1,
+ transport: transport)
+
+ let monthlyPlan = snapshot.extraRateWindows?.first { $0.id == "mistral-monthly-plan" }
+ #expect(monthlyPlan?.window.usedPercent == 37)
+ #expect(snapshot.mistralUsage?.credits == nil)
+ #expect(requestLog.paths == [
+ "admin.mistral.ai/api/billing/v2/usage",
+ "console.mistral.ai/api-ui/trpc/billing.vibeUsage",
+ "admin.mistral.ai/api/billing/credits",
+ ])
+ }
+
@Test
func `monthly plan window preserves existing extras`() {
let existing = NamedRateWindow(
@@ -196,4 +252,34 @@ struct MistralVibeUsageTests {
}}}}]
"""
}
+
+ private static var billingUsageResponseJSON: String {
+ """
+ {
+ "completion": {"models": {}},
+ "ocr": {"models": {}},
+ "connectors": {"models": {}},
+ "libraries_api": {"pages": {"models": {}}, "tokens": {"models": {}}},
+ "fine_tuning": {"training": {}, "storage": {}},
+ "audio": {"models": {}},
+ "vibe_usage": 0.0,
+ "date": "2026-02-01T00:00:00Z",
+ "previous_month": "2026-01",
+ "next_month": "2026-03",
+ "start_date": "2026-02-01T00:00:00Z",
+ "end_date": "2026-02-28T23:59:59.999Z",
+ "currency": "USD",
+ "currency_symbol": "$",
+ "prices": []
+ }
+ """
+ }
+
+ private static func response(url: URL, statusCode: Int) throws -> HTTPURLResponse {
+ try #require(HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: nil,
+ headerFields: nil))
+ }
}
diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift
index cb42bccce0..749fd4662e 100644
--- a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift
+++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift
@@ -84,15 +84,15 @@ struct OllamaUsageFetcherRetryMappingTests {
#expect(strategy.shouldFallback(on: OllamaUsageError.parseFailed("missing"), context: context))
}
- @Test
- func `api fetch sends bearer token and rejects unauthorized key`() async throws {
+ @Test(arguments: [401, 403])
+ func `api fetch describes rejected key as invalid or revoked`(statusCode: Int) async throws {
let url = try #require(URL(string: "https://ollama.com/api/tags"))
let transport = ProviderHTTPTransportHandler { request in
#expect(request.url == url)
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer ollama-test")
let response = HTTPURLResponse(
url: url,
- statusCode: 401,
+ statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: nil)!
return (Data("{}".utf8), response)
@@ -106,6 +106,7 @@ struct OllamaUsageFetcherRetryMappingTests {
Issue.record("Expected apiUnauthorized, got \(error)")
return
}
+ #expect(error.localizedDescription == "Ollama API key is invalid or revoked.")
} catch {
Issue.record("Expected OllamaUsageError.apiUnauthorized, got \(error)")
}
@@ -121,13 +122,7 @@ struct OllamaUsageFetcherRetryMappingTests {
return Self.makeResponse(url: url, body: body, statusCode: 200)
}
- let fetcher = OllamaUsageFetcher(
- browserDetection: BrowserDetection(cacheTTL: 0),
- makeURLSession: { delegate in
- let config = URLSessionConfiguration.ephemeral
- config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self]
- return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
- })
+ let fetcher = self.makeCookieFetcher()
do {
_ = try await fetcher.fetch(
cookieHeaderOverride: "session=test-cookie",
@@ -144,6 +139,70 @@ struct OllamaUsageFetcherRetryMappingTests {
}
}
+ @Test
+ func `workos sign in landing surfaces invalid credentials before parsing`() async throws {
+ defer { OllamaRetryMappingStubURLProtocol.handler = nil }
+
+ let landingURL = try #require(URL(
+ string: "https://signin.ollama.com/?client_id=test&authorization_session_id=expired"))
+ OllamaRetryMappingStubURLProtocol.handler = { request in
+ #expect(request.url == URL(string: "https://ollama.com/settings"))
+ let body = "Sign in to Ollama"
+ return Self.makeResponse(url: landingURL, body: body, statusCode: 200)
+ }
+
+ let fetcher = self.makeCookieFetcher()
+ do {
+ _ = try await fetcher.fetch(
+ cookieHeaderOverride: "session=expired-cookie",
+ manualCookieMode: true)
+ Issue.record("Expected OllamaUsageError.invalidCredentials")
+ } catch let error as OllamaUsageError {
+ guard case .invalidCredentials = error else {
+ Issue.record("Expected invalidCredentials, got \(error)")
+ return
+ }
+ } catch {
+ Issue.record("Expected OllamaUsageError.invalidCredentials, got \(error)")
+ }
+ }
+
+ @Test
+ func `workos sign in service failure remains a network error`() async throws {
+ defer { OllamaRetryMappingStubURLProtocol.handler = nil }
+
+ let landingURL = try #require(URL(string: "https://signin.ollama.com/"))
+ OllamaRetryMappingStubURLProtocol.handler = { _ in
+ Self.makeResponse(url: landingURL, body: "Service unavailable", statusCode: 503)
+ }
+
+ let fetcher = self.makeCookieFetcher()
+ do {
+ _ = try await fetcher.fetch(
+ cookieHeaderOverride: "session=expired-cookie",
+ manualCookieMode: true)
+ Issue.record("Expected OllamaUsageError.networkError")
+ } catch let error as OllamaUsageError {
+ guard case let .networkError(message) = error else {
+ Issue.record("Expected networkError, got \(error)")
+ return
+ }
+ #expect(message == "HTTP 503")
+ } catch {
+ Issue.record("Expected OllamaUsageError.networkError, got \(error)")
+ }
+ }
+
+ private func makeCookieFetcher() -> OllamaUsageFetcher {
+ OllamaUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ makeURLSession: { delegate in
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self]
+ return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
+ })
+ }
+
private static func makeResponse(
url: URL,
body: String,
diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift
index e25283f5b0..c2f141b39e 100644
--- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift
@@ -3,6 +3,13 @@ import Testing
@testable import CodexBarCore
struct OllamaUsageFetcherTests {
+ @Test
+ func `session authentication errors point to current recovery page`() {
+ #expect(OllamaUsageError.notLoggedIn.errorDescription?.contains("https://ollama.com/signin") == true)
+ #expect(OllamaUsageError.invalidCredentials.errorDescription?.contains("https://ollama.com/signin") == true)
+ #expect(OllamaUsageError.noSessionCookie.errorDescription?.contains("https://ollama.com/signin") == true)
+ }
+
@Test
func `attaches cookie for ollama hosts`() {
#expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com/settings")))
@@ -24,6 +31,27 @@ struct OllamaUsageFetcherTests {
#expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ollama.com/path")))
}
+ @Test
+ func `recognizes current ollama sign in redirects`() {
+ #expect(OllamaUsageFetcher.isSignInRedirect(URL(string: "https://ollama.com/signin")))
+ #expect(OllamaUsageFetcher.isSignInRedirect(URL(
+ string: "https://api.workos.com/user_management/authorize?client_id=test")))
+ #expect(OllamaUsageFetcher.isSignInRedirect(URL(
+ string: "https://auth.workos.com/user_management/authorize?client_id=test")))
+ // The real unauthenticated chain lands on the WorkOS-hosted Ollama sign-in
+ // page on the `signin.ollama.com` subdomain (verified live); that terminal
+ // landing must also classify as a sign-in redirect.
+ #expect(OllamaUsageFetcher.isSignInRedirect(URL(
+ string: "https://signin.ollama.com/?client_id=test&authorization_session_id=x")))
+ #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "https://ollama.com/settings")))
+ #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "https://api.workos.com/other")))
+ #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "http://ollama.com/signin")))
+ #expect(!OllamaUsageFetcher.isSignInRedirect(URL(
+ string: "http://auth.workos.com/user_management/authorize?client_id=test")))
+ #expect(!OllamaUsageFetcher.isSignInRedirect(URL(
+ string: "https://example.com/user_management/authorize?client_id=test")))
+ }
+
@Test
func `manual mode without valid header throws no session cookie`() {
do {
@@ -76,6 +104,14 @@ struct OllamaUsageFetcherTests {
#expect(resolved?.contains("__Secure-session=abc") == true)
}
+ @Test
+ func `manual mode accepts workos session cookie header`() throws {
+ let resolved = try OllamaUsageFetcher.resolveManualCookieHeader(
+ override: "wos-session=abc; theme=dark",
+ manualCookieMode: true)
+ #expect(resolved?.contains("wos-session=abc") == true)
+ }
+
@Test
func `retry policy retries only for auth errors`() {
#expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials))
@@ -150,6 +186,16 @@ struct OllamaUsageFetcherTests {
#expect(selected.sourceLabel == "Profile D")
}
+ @Test
+ func `cookie selector accepts workos session cookie`() throws {
+ let candidate = OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "wos-session", value: "auth")],
+ sourceLabel: "WorkOS Profile")
+
+ let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate])
+ #expect(selected.sourceLabel == "WorkOS Profile")
+ }
+
@Test
func `cookie selector keeps recognized candidates in order`() throws {
let first = OllamaCookieImporter.SessionInfo(
diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift
index fbf298bdf4..eb3527efb6 100644
--- a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift
@@ -111,6 +111,38 @@ struct OpenAIAPIUsageFetcherTests {
#expect(snapshot.topModels.first?.totalTokens == 1800)
}
+ @Test(arguments: ["NaN", "Infinity", "-Infinity", "1e309", "-1e309"])
+ func `rejects nonfinite cost strings`(value: String) {
+ let costs = """
+ {
+ "data": [{
+ "start_time": 1700000000,
+ "end_time": 1700086400,
+ "results": [{ "amount": { "value": "\(value)", "currency": "usd" } }]
+ }],
+ "has_more": false,
+ "next_page": null
+ }
+ """
+ let completions = #"{"data":[],"has_more":false,"next_page":null}"#
+
+ do {
+ _ = try OpenAIAPIUsageFetcher._parseSnapshotForTesting(
+ costs: Data(costs.utf8),
+ completions: Data(completions.utf8),
+ now: Date(timeIntervalSince1970: 1_700_179_200))
+ Issue.record("Expected a costs parse failure.")
+ } catch let error as OpenAIAPIUsageError {
+ guard case let .parseFailed(endpoint, _) = error else {
+ Issue.record("Expected a costs parse failure, got \(error).")
+ return
+ }
+ #expect(endpoint == "costs")
+ } catch {
+ Issue.record("Expected OpenAIAPIUsageError, got \(error).")
+ }
+ }
+
@Test
func `admin usage fetch pages long history within endpoint bucket limit`() async throws {
let now = Date(timeIntervalSince1970: 1_700_179_200)
diff --git a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift
index ec67914aa1..f581f01e55 100644
--- a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift
+++ b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift
@@ -188,6 +188,30 @@ struct OpenAIDashboardBrowserCookieImporterTests {
#expect(timeoutProbe.firedAt == nil)
}
+ @Test
+ func `bounded cookie loads preserve explicit retry context`() async throws {
+ BrowserCookieAccessGate.resetForTesting()
+ defer { BrowserCookieAccessGate.resetForTesting() }
+ let start = Date()
+
+ for deadline in [nil, Date().addingTimeInterval(1)] {
+ BrowserCookieAccessGate.resetForTesting()
+ BrowserCookieAccessGate.recordDenied(for: .arc, now: start)
+
+ let allowed = try await BrowserCookieAccessGate.withExplicitRetry {
+ try await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieLoad(deadline: deadline) {
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ ProviderInteractionContext.current == .userInitiated &&
+ BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1))
+ }
+ }
+ }
+ }
+ #expect(allowed)
+ }
+ }
+
@Test
func `timed out cookie cache work stays ordered before retry`() async throws {
let log = CookieOperationLog()
diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift
index b6547e14fe..037d4c8cbe 100644
--- a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift
+++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift
@@ -3,6 +3,16 @@ import Foundation
import Testing
struct OpenAIDashboardModelsTests {
+ private static let utcCalendar: Calendar = {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)!
+ return calendar
+ }()
+
+ private static func utcDate(year: Int, month: Int, day: Int) -> Date {
+ self.utcCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))!
+ }
+
@Test
func `removes skill usage services from usage breakdown`() {
let breakdown = [
@@ -91,4 +101,117 @@ struct OpenAIDashboardModelsTests {
totalCreditsUsed: 4),
])
}
+
+ @Test
+ func `recent credit totals use calendar days and exclude future rows`() {
+ let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary(
+ from: [
+ .init(day: "2026-05-31", services: [], totalCreditsUsed: 100),
+ .init(day: "2026-06-01", services: [], totalCreditsUsed: 1),
+ .init(day: "2026-06-29", services: [], totalCreditsUsed: 2),
+ .init(day: "2026-06-30", services: [], totalCreditsUsed: 3),
+ .init(day: "2026-07-01", services: [], totalCreditsUsed: 200),
+ ],
+ historyDays: 30,
+ now: Self.utcDate(year: 2026, month: 6, day: 30),
+ calendar: Self.utcCalendar)
+
+ #expect(summary.historyDays == 30)
+ #expect(summary.todayCredits == 3)
+ #expect(summary.totalCredits == 6)
+ #expect(summary.daily.map(\.day) == ["2026-06-01", "2026-06-29", "2026-06-30"])
+ }
+
+ @Test
+ func `recent credit totals preserve gaps and sanitize invalid values`() throws {
+ let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary(
+ from: [
+ .init(
+ day: "2026-06-20",
+ services: [
+ .init(service: "CLI", creditsUsed: 4),
+ .init(service: "bad", creditsUsed: .nan),
+ .init(service: "negative", creditsUsed: -2),
+ ],
+ totalCreditsUsed: 999),
+ .init(day: "2026-06-31", services: [], totalCreditsUsed: 9),
+ .init(day: "2026-06-30", services: [], totalCreditsUsed: .infinity),
+ ],
+ now: Self.utcDate(year: 2026, month: 6, day: 30),
+ calendar: Self.utcCalendar)
+
+ #expect(summary.todayCredits == 0)
+ #expect(summary.totalCredits == 4)
+ let day = try #require(summary.daily.first)
+ #expect(day.day == "2026-06-20")
+ #expect(day.totalCreditsUsed == 4)
+ #expect(day.services.map(\.service) == ["CLI"])
+ }
+
+ @Test
+ func `recent credit totals report zero when history has no row for today`() {
+ let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary(
+ from: [
+ .init(day: "2026-06-29", services: [], totalCreditsUsed: 4),
+ ],
+ now: Self.utcDate(year: 2026, month: 6, day: 30),
+ calendar: Self.utcCalendar)
+
+ #expect(summary.todayCredits == 0)
+ #expect(summary.totalCredits == 4)
+ #expect(summary.daily.map(\.day) == ["2026-06-29"])
+ }
+
+ @Test
+ func `recent credit totals fail closed on overflow`() {
+ let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary(
+ from: [
+ .init(
+ day: "2026-06-30",
+ services: [
+ .init(service: "CLI", creditsUsed: Double.greatestFiniteMagnitude),
+ .init(service: "Desktop App", creditsUsed: Double.greatestFiniteMagnitude),
+ ],
+ totalCreditsUsed: 1),
+ ],
+ now: Self.utcDate(year: 2026, month: 6, day: 30),
+ calendar: Self.utcCalendar)
+
+ #expect(summary.daily.isEmpty)
+ #expect(summary.todayCredits == nil)
+ #expect(summary.totalCredits == nil)
+ }
+
+ @Test
+ func `recent credit totals respect the selected timezone`() throws {
+ var pacific = Calendar(identifier: .gregorian)
+ pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles"))
+ let now = try #require(ISO8601DateFormatter().date(from: "2026-07-01T06:30:00Z"))
+
+ let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary(
+ from: [
+ .init(day: "2026-06-30", services: [], totalCreditsUsed: 7),
+ .init(day: "2026-07-01", services: [], totalCreditsUsed: 11),
+ ],
+ now: now,
+ calendar: pacific)
+
+ #expect(summary.todayCredits == 7)
+ #expect(summary.totalCredits == 7)
+ #expect(summary.daily.map(\.day) == ["2026-06-30"])
+ }
+
+ @Test
+ func `recent credit totals keep Gregorian dashboard keys with a non Gregorian system calendar`() throws {
+ var buddhist = Calendar(identifier: .buddhist)
+ buddhist.timeZone = try #require(TimeZone(secondsFromGMT: 0))
+
+ let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary(
+ from: [.init(day: "2026-06-30", services: [], totalCreditsUsed: 7)],
+ now: Self.utcDate(year: 2026, month: 6, day: 30),
+ calendar: buddhist)
+
+ #expect(summary.todayCredits == 7)
+ #expect(summary.totalCredits == 7)
+ }
}
diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift
index 5f4447b5e9..6b5b6269fa 100644
--- a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift
+++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift
@@ -232,6 +232,25 @@ struct OpenCodeGoUsageParserTests {
#expect(snapshot.monthlyResetInSec == 86400)
}
+ @Test(arguments: ["1e309", "1e308"])
+ func `ignores reset timestamps outside integer range`(resetAt: String) throws {
+ let text = """
+ {
+ "rollingUsage": { "usagePercent": 17, "resetAt": "\(resetAt)" },
+ "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 }
+ }
+ """
+
+ let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(
+ text: text,
+ now: Date(timeIntervalSince1970: 1_700_000_000))
+
+ #expect(snapshot.rollingUsagePercent == 17)
+ #expect(snapshot.weeklyUsagePercent == 75)
+ #expect(snapshot.rollingResetInSec == 0)
+ #expect(snapshot.weeklyResetInSec == 7200)
+ }
+
@Test
func `computes usage percent from totals and treats monthly as optional`() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift
index ab7359cb19..291fc8734b 100644
--- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift
+++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift
@@ -53,6 +53,25 @@ struct OpenCodeUsageParserTests {
#expect(snapshot.weeklyResetInSec == 7200)
}
+ @Test(arguments: ["1e309", "1e308"])
+ func `ignores reset timestamps outside integer range`(resetAt: String) throws {
+ let text = """
+ {
+ "rollingUsage": { "usagePercent": 17, "resetAt": "\(resetAt)" },
+ "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 }
+ }
+ """
+
+ let snapshot = try OpenCodeUsageFetcher.parseSubscription(
+ text: text,
+ now: Date(timeIntervalSince1970: 1_700_000_000))
+
+ #expect(snapshot.rollingUsagePercent == 17)
+ #expect(snapshot.weeklyUsagePercent == 75)
+ #expect(snapshot.rollingResetInSec == 0)
+ #expect(snapshot.weeklyResetInSec == 7200)
+ }
+
@Test
func `parses subscription from candidate windows`() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
diff --git a/Tests/CodexBarTests/PoeProviderDescriptorTests.swift b/Tests/CodexBarTests/PoeProviderDescriptorTests.swift
new file mode 100644
index 0000000000..7d7e629a6a
--- /dev/null
+++ b/Tests/CodexBarTests/PoeProviderDescriptorTests.swift
@@ -0,0 +1,12 @@
+import CodexBarCore
+import Testing
+
+struct PoeProviderDescriptorTests {
+ @Test
+ func `Poe uses the official brand color and icon`() {
+ let branding = PoeProviderDescriptor.descriptor.branding
+
+ #expect(branding.iconResourceName == "ProviderIcon-poe")
+ #expect(branding.color == ProviderColor(red: 93 / 255, green: 92 / 255, blue: 222 / 255))
+ }
+}
diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift
index 19b9568c36..e0a3d0c9d4 100644
--- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift
+++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift
@@ -30,6 +30,8 @@ struct PreferencesPaneSmokeTests {
settings.multiAccountMenuLayout = .stacked
settings.hidePersonalInfo = true
settings.resetTimesShowAbsolute = true
+ settings.costUsageEnabled = true
+ settings.costComparisonPeriodsEnabled = true
settings.debugDisableKeychainAccess = true
settings.claudeOAuthKeychainPromptMode = .always
settings.refreshFrequency = .manual
@@ -46,6 +48,40 @@ struct PreferencesPaneSmokeTests {
_ = SettingsSidebarView(settings: settings, store: store, selection: .constant(.provider(.codex))).body
}
+ @Test
+ func `general menu options cover persisted settings`() {
+ let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage")
+ let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages")
+ defer {
+ if let previousLanguage {
+ UserDefaults.standard.set(previousLanguage, forKey: "appLanguage")
+ } else {
+ UserDefaults.standard.removeObject(forKey: "appLanguage")
+ }
+ if let previousAppleLanguages {
+ UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages")
+ } else {
+ UserDefaults.standard.removeObject(forKey: "AppleLanguages")
+ }
+ }
+
+ #expect(GeneralSettingsMenuOptions.languages == AppLanguage.allCases.map(\.rawValue))
+ #expect(GeneralSettingsMenuOptions.refreshFrequencies == RefreshFrequency.allCases)
+ #expect(GeneralSettingsMenuOptions.terminalApps(selected: .terminal) { _ in nil } == [.terminal])
+ #expect(GeneralSettingsMenuOptions.terminalApps(selected: .iTerm) { _ in nil } == [.terminal, .iTerm])
+
+ let suite = "PreferencesPaneSmokeTests-general-menu-persistence"
+ let settings = Self.makeSettingsStore(suite: suite)
+ settings.appLanguage = "ja"
+ settings.terminalApp = .iTerm
+ settings.refreshFrequency = .fiveMinutes
+
+ let reloaded = Self.makeSettingsStore(suite: suite, reset: false)
+ #expect(reloaded.appLanguage == "ja")
+ #expect(reloaded.terminalApp == .iTerm)
+ #expect(reloaded.refreshFrequency == .fiveMinutes)
+ }
+
@Test
func `overview provider limit text formats numeric limit as object argument`() {
let text = DisplayPane.overviewProviderLimitText(limit: 3)
@@ -113,6 +149,38 @@ struct PreferencesPaneSmokeTests {
}
}
+ @Test
+ func `language preference clears stale app level AppleLanguages override`() {
+ let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage")
+ let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages")
+ defer {
+ if let previousLanguage {
+ UserDefaults.standard.set(previousLanguage, forKey: "appLanguage")
+ } else {
+ UserDefaults.standard.removeObject(forKey: "appLanguage")
+ }
+ if let previousAppleLanguages {
+ UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages")
+ } else {
+ UserDefaults.standard.removeObject(forKey: "AppleLanguages")
+ }
+ }
+
+ let staleOverride = ["zz-StaleLanguageOverride"]
+ UserDefaults.standard.set(staleOverride, forKey: "AppleLanguages")
+
+ let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-system")
+ settings.appLanguage = "ko"
+
+ #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ko")
+ #expect(UserDefaults.standard.object(forKey: "AppleLanguages") as? [String] != staleOverride)
+
+ settings.appLanguage = ""
+
+ #expect(UserDefaults.standard.object(forKey: "appLanguage") == nil)
+ #expect(UserDefaults.standard.object(forKey: "AppleLanguages") as? [String] != staleOverride)
+ }
+
@Test
func `german app language resolves localized labels`() {
let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-de")
@@ -146,10 +214,12 @@ struct PreferencesPaneSmokeTests {
}
}
- private static func makeSettingsStore(suite: String) -> SettingsStore {
+ private static func makeSettingsStore(suite: String, reset: Bool = true) -> SettingsStore {
let defaults = UserDefaults(suiteName: suite)!
- defaults.removePersistentDomain(forName: suite)
- let configStore = testConfigStore(suiteName: suite)
+ if reset {
+ defaults.removePersistentDomain(forName: suite)
+ }
+ let configStore = testConfigStore(suiteName: suite, reset: reset)
return SettingsStore(
userDefaults: defaults,
diff --git a/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift b/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift
new file mode 100644
index 0000000000..a3ad4216ac
--- /dev/null
+++ b/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift
@@ -0,0 +1,44 @@
+import Testing
+@testable import CodexBar
+@testable import CodexBarCore
+
+struct ProviderDetectionPolicyTests {
+ @Test
+ func `fresh install detects Codex and Claude Desktop without unconfigured Gemini`() {
+ let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init(
+ codexCLIInstalled: true,
+ claudeCLIInstalled: false,
+ claudeDesktopInstalled: true,
+ geminiCLIInstalled: true,
+ geminiConfigured: false,
+ antigravityAvailable: false))
+
+ #expect(enabled == [.codex, .claude])
+ }
+
+ @Test
+ func `configured Gemini CLI is detected`() {
+ let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init(
+ codexCLIInstalled: false,
+ claudeCLIInstalled: false,
+ claudeDesktopInstalled: false,
+ geminiCLIInstalled: true,
+ geminiConfigured: true,
+ antigravityAvailable: false))
+
+ #expect(enabled == [.gemini])
+ }
+
+ @Test
+ func `Codex remains the fallback when no provider source is available`() {
+ let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init(
+ codexCLIInstalled: false,
+ claudeCLIInstalled: false,
+ claudeDesktopInstalled: false,
+ geminiCLIInstalled: false,
+ geminiConfigured: false,
+ antigravityAvailable: false))
+
+ #expect(enabled == [.codex])
+ }
+}
diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
index 2d00a9ca44..8b3a4c1543 100644
--- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift
+++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
@@ -36,6 +36,7 @@ struct ProviderIconResourcesTests {
"litellm",
"deepgram",
"ollama",
+ "clawrouter",
]
for slug in slugs {
let url = resources.appending(path: "ProviderIcon-\(slug).svg")
diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
index c115258d6f..e3bebb60e4 100644
--- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
+++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
@@ -114,16 +114,17 @@ struct ProviderSettingsDescriptorTests {
}
@Test
- func `claude prompt policy picker hidden when experimental reader selected`() throws {
+ func `claude prompt policy picker remains visible for prompt free toggle`() throws {
let fixture = try self.makeSettingsFixture(
- suite: "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental")
+ suite: "ProviderSettingsDescriptorTests-claude-prompt-visible-prompt-free")
fixture.settings.debugDisableKeychainAccess = false
- fixture.settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental
+ fixture.settings.claudeOAuthPromptFreeCredentialsEnabled = true
let context = fixture.settingsContext(provider: .claude)
let pickers = ClaudeProviderImplementation().settingsPickers(context: context)
let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" }))
- #expect(keychainPicker.isVisible?() == false)
+ #expect(keychainPicker.isVisible?() ?? true)
+ #expect(keychainPicker.binding.wrappedValue == ClaudeOAuthKeychainPromptMode.never.rawValue)
}
@Test
diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
index 7a04c047a4..f503af1fb2 100644
--- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
+++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
@@ -245,7 +245,7 @@ struct ProvidersPaneCoverageTests {
])
#expect(picker?.options.first?.title == "Pay-as-you-go")
#expect(picker?.options.last?.title == "Monthly Plan")
- #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.")
+ #expect(picker?.subtitle == "Choose Mistral API spend or Monthly Plan usage for the menu bar.")
}
}
@@ -264,6 +264,27 @@ struct ProvidersPaneCoverageTests {
}
}
+ @Test
+ func `kimi menu bar metric picker preserves stored lane labels`() {
+ Self.withEnglishLocalization {
+ let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimi-picker")
+ let store = Self.makeUsageStore(settings: settings)
+ let pane = ProvidersPane(settings: settings, store: store)
+
+ let picker = pane._test_menuBarMetricPicker(for: .kimi)
+ #expect(picker?.options.map(\.id) == [
+ MenuBarMetricPreference.automatic.rawValue,
+ MenuBarMetricPreference.primary.rawValue,
+ MenuBarMetricPreference.secondary.rawValue,
+ ])
+ #expect(picker?.options.map(\.title) == [
+ "Automatic",
+ "Primary (Weekly)",
+ "Secondary (Rate Limit)",
+ ])
+ }
+ }
+
@Test
func `cursor menu bar metric picker omits tertiary api lane when snapshot has no api metric`() {
let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-tertiary-picker")
diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift
index b0bd32c7f1..ba62c44176 100644
--- a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift
+++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift
@@ -46,6 +46,42 @@ struct QuotaWarningNotificationLogicTests {
}
}
+ @Test
+ func `quota warning copy uses the extra-window display label when provided`() {
+ Self.withAppLanguage("en") {
+ let copy = QuotaWarningNotificationLogic.notificationCopy(
+ providerName: "Claude",
+ window: .weekly,
+ threshold: 50,
+ currentRemaining: 45,
+ windowDisplayLabel: "Fable only")
+
+ #expect(copy.title == "Claude Fable only quota low")
+ #expect(copy.body == "45% left. Reached your 50% Fable only warning threshold.")
+ }
+ }
+
+ @Test
+ func `extra-window notification identifiers are independent`() {
+ let fable = QuotaWarningEvent(
+ window: .weekly,
+ threshold: 50,
+ currentRemaining: 45,
+ windowID: "claude-weekly-scoped-fable")
+ let routines = QuotaWarningEvent(
+ window: .weekly,
+ threshold: 50,
+ currentRemaining: 45,
+ windowID: "claude-routines")
+
+ let ids = [fable, routines].map {
+ QuotaWarningNotificationLogic.notificationIDPrefix(provider: .claude, event: $0)
+ }
+ #expect(Set(ids).count == 2)
+ #expect(ids[0].contains("claude-weekly-scoped-fable"))
+ #expect(ids[1].contains("claude-routines"))
+ }
+
@Test
func `quota warning copy follows Traditional Chinese app language`() {
Self.withAppLanguage("zh-Hant") {
diff --git a/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift b/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift
index dcf0ab6a29..e2bfe04986 100644
--- a/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift
+++ b/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift
@@ -101,6 +101,25 @@ struct RateWindowSyntheticPlaceholderTests {
#expect(primary.usedPercent == 11)
}
+ @Test
+ func `web mapping keeps fractional session and weekly utilization`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 45.5, "resets_at": "2025-12-29T20:00:00.000Z" },
+ "seven_day": { "utilization": 12.25, "resets_at": "2025-12-29T23:00:00.000Z" }
+ }
+ """
+ let webData = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8))
+ #expect(webData.sessionPercentUsed == 45.5)
+ #expect(webData.weeklyPercentUsed == 12.25)
+ #expect(webData.hasLiveSessionWindow == true)
+
+ let primary = ClaudeUsageFetcher.webPrimaryWindow(from: webData)
+ #expect(primary.isSyntheticPlaceholder == false)
+ #expect(primary.usedPercent == 45.5)
+ #expect(primary.remainingPercent == 54.5)
+ }
+
@Test
func `web mapping keeps a real zero-usage session that omits a reset`() throws {
// A reported `five_hour` object at 0% with no `resets_at` is a real idle session, not the
diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift
index 761fbc39a3..9292635d83 100644
--- a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift
@@ -35,7 +35,8 @@ struct SakanaUsageFetcherTests {
cookieHeader: "Cookie: session=abc; theme=dark",
session: transport,
now: Date(timeIntervalSince1970: 0))
- let request = await transport.lastCapturedRequest()
+ let requests = await transport.capturedRequestsSnapshot()
+ let request = requests.first { $0.url == "https://console.sakana.ai/billing" }
#expect(snapshot.fiveHour?.usedPercent == 92)
#expect(request?.url == "https://console.sakana.ai/billing")
@@ -44,12 +45,165 @@ struct SakanaUsageFetcherTests {
#expect(request?.acceptLanguage == "en-US,en;q=0.9")
}
+ @Test
+ func `fetches pay as you go concurrently and merges the credit balance`() async throws {
+ let transport = SakanaScriptedTransport(
+ statusCode: 200,
+ body: Self.billingHTML,
+ overridesByURL: [
+ "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML),
+ ],
+ billingWaitsForPayAsYouGo: true)
+
+ let snapshot = try await SakanaUsageFetcher.fetchUsage(
+ cookieHeader: "session=abc",
+ session: transport,
+ now: Date(timeIntervalSince1970: 0))
+ let requests = await transport.capturedRequestsSnapshot()
+
+ #expect(snapshot.fiveHour?.usedPercent == 92)
+ #expect(snapshot.payAsYouGo?.creditBalance == 12.34)
+ #expect(snapshot.payAsYouGo?.periodUsageTotal == 5.67)
+ #expect(snapshot.payAsYouGo?.periodLabel == "Jun 02, 2026 - Jul 01, 2026")
+ #expect(requests.count == 2)
+ let payAsYouGoRequest = requests.first { $0.url == "https://console.sakana.ai/billing?tab=payAsYouGo" }
+ #expect(payAsYouGoRequest?.cookie == "session=abc")
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.sakanaPayAsYouGo?.balanceDetail == "$12.34")
+ }
+
+ @Test
+ func `quick pay as you go response can finish after primary within the shared budget`() async throws {
+ let transport = SakanaScriptedTransport(
+ statusCode: 200,
+ body: Self.billingHTML,
+ overridesByURL: [
+ "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML),
+ ],
+ payAsYouGoDelay: .milliseconds(20))
+
+ let snapshot = try await SakanaUsageFetcher.fetchUsage(
+ cookieHeader: "session=abc",
+ session: transport,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(snapshot.fiveHour?.usedPercent == 92)
+ #expect(snapshot.payAsYouGo?.creditBalance == 12.34)
+ }
+
+ @Test
+ func `fetch skips the pay as you go request entirely when optional usage is disabled`() async throws {
+ let transport = SakanaScriptedTransport(
+ statusCode: 200,
+ body: Self.billingHTML,
+ overridesByURL: [
+ "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML),
+ ])
+
+ let snapshot = try await SakanaUsageFetcher.fetchUsage(
+ cookieHeader: "session=abc",
+ session: transport,
+ now: Date(timeIntervalSince1970: 0),
+ includeOptionalUsage: false)
+ let requests = await transport.capturedRequestsSnapshot()
+
+ #expect(snapshot.fiveHour?.usedPercent == 92)
+ #expect(snapshot.payAsYouGo == nil)
+ // Only the required subscription-quota request is made; disabling optional usage must not
+ // just discard the PAYG result, it must skip the network request entirely.
+ #expect(requests.count == 1)
+ #expect(requests.first?.url == "https://console.sakana.ai/billing")
+ }
+
+ @Test
+ func `pay as you go bounded fetch does not wait for an operation that ignores cancellation`() async throws {
+ let startedAt = ContinuousClock.now
+
+ let fetched = await SakanaUsageFetcher._boundedFetchPayAsYouGoForTesting(timeout: .milliseconds(20)) {
+ await withCheckedContinuation { continuation in
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
+ continuation.resume(returning: SakanaPayAsYouGoSnapshot(creditBalance: 9))
+ }
+ }
+ }
+
+ let elapsed = startedAt.duration(to: .now)
+ #expect(fetched == nil)
+ #expect(elapsed < .milliseconds(300))
+
+ try await Task.sleep(for: .milliseconds(550))
+ }
+
+ @Test
+ func `fetch tolerates a failing pay as you go request without failing the primary fetch`() async throws {
+ // Default response is a 500; only the primary billing URL is overridden to succeed, so the
+ // pay-as-you-go request (not present in the override map) falls through to that failure.
+ let transport = SakanaScriptedTransport(
+ statusCode: 500,
+ body: "boom",
+ overridesByURL: [
+ "https://console.sakana.ai/billing": (200, Self.billingHTML),
+ ])
+
+ let snapshot = try await SakanaUsageFetcher.fetchUsage(
+ cookieHeader: "session=abc",
+ session: transport,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(snapshot.fiveHour?.usedPercent == 92)
+ #expect(snapshot.payAsYouGo == nil)
+ }
+
+ @Test
+ func `slow pay as you go request never delays the primary quota result`() async throws {
+ let transport = SakanaScriptedTransport(
+ statusCode: 200,
+ body: Self.billingHTML,
+ billingWaitsForPayAsYouGo: true,
+ payAsYouGoBlocksUntilCancelled: true)
+ let startedAt = ContinuousClock.now
+
+ let snapshot = try await SakanaUsageFetcher.fetchUsage(
+ cookieHeader: "session=abc",
+ session: transport,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(snapshot.fiveHour?.usedPercent == 92)
+ #expect(snapshot.payAsYouGo == nil)
+ #expect(startedAt.duration(to: .now) < .milliseconds(500))
+ for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) {
+ await Task.yield()
+ }
+ #expect(await transport.didCancelPayAsYouGo())
+ }
+
+ @Test
+ func `required fetch failure cancels the concurrent pay as you go request`() async throws {
+ let transport = SakanaScriptedTransport(
+ statusCode: 401,
+ body: "expired",
+ billingWaitsForPayAsYouGo: true,
+ payAsYouGoBlocksUntilCancelled: true)
+
+ await #expect(throws: SakanaUsageError.loginRequired) {
+ _ = try await SakanaUsageFetcher.fetchUsage(
+ cookieHeader: "session=expired",
+ session: transport)
+ }
+
+ for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) {
+ await Task.yield()
+ }
+ #expect(await transport.didCancelPayAsYouGo())
+ }
+
@Test
func `fetch rejects cross origin login redirect`() async throws {
let transport = try SakanaScriptedTransport(
statusCode: 200,
body: Self.billingHTML,
- responseURL: #require(URL(string: "https://auth.sakana.ai/login")))
+ responseURL: #require(URL(string: "https://auth.sakana.ai")?.appending(path: "login")))
await #expect(throws: SakanaUsageError.loginRequired) {
_ = try await SakanaUsageFetcher.fetchUsage(
@@ -165,6 +319,49 @@ struct SakanaUsageFetcherTests {
#expect(usage.primary?.resetsAt?.timeIntervalSince1970 == 1_782_226_380)
}
+ @Test
+ func `pay as you go html maps credit balance usage total and date range label`() {
+ let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(Self.payAsYouGoHTML)
+
+ #expect(usage?.creditBalance == 12.34)
+ #expect(usage?.periodUsageTotal == 5.67)
+ #expect(usage?.periodLabel == "Jun 02, 2026 - Jul 01, 2026")
+ #expect(usage?.balanceDetail == "$12.34")
+ }
+
+ @Test
+ func `pay as you go html without usage total still maps credit balance`() {
+ let html = Self.payAsYouGoHTML.replacing(
+ "Total: $5.67",
+ with: "")
+
+ let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(html)
+
+ #expect(usage?.creditBalance == 12.34)
+ #expect(usage?.periodUsageTotal == nil)
+ }
+
+ @Test
+ func `billing html without a pay as you go tab returns nil`() {
+ #expect(SakanaUsageFetcher.parsePayAsYouGoHTML(Self.billingHTML) == nil)
+ }
+
+ @Test
+ func `sakana usage snapshot carries pay as you go through to the usage snapshot mapping`() {
+ let payAsYouGo = SakanaPayAsYouGoSnapshot(creditBalance: 9, periodUsageTotal: 1.5, periodLabel: "Last 30 days")
+ let snapshot = SakanaUsageSnapshot(
+ planName: "Standard",
+ priceLabel: "$20/mo",
+ fiveHour: .init(usedPercent: 10, resetsAt: nil),
+ weekly: .init(usedPercent: 20, resetsAt: nil),
+ payAsYouGo: payAsYouGo)
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.sakanaPayAsYouGo?.creditBalance == 9)
+ #expect(usage.sakanaPayAsYouGo?.balanceDetail == "$9.00")
+ }
+
private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
@@ -191,6 +388,20 @@ struct SakanaUsageFetcherTests {
32% used
"""
+
+ /// Minimal reproduction of the "Pay as you go" tab, which the live console only server-renders
+ /// when the request includes `?tab=payAsYouGo`. The `` markers reproduce React's
+ /// hydration-boundary comments between separately interpolated JSX text nodes.
+ private static let payAsYouGoHTML = """
+
+ Credit balance
+
+ $12.34
+
+ Usage
+ Total: $5.67
+
+ """
}
private actor SakanaScriptedTransport: ProviderHTTPTransport {
@@ -205,35 +416,118 @@ private actor SakanaScriptedTransport: ProviderHTTPTransport {
private let body: String
private let responseURL: URL?
private let headers: [String: String]
- private var capturedRequest: CapturedRequest?
+ /// Per-URL response overrides (keyed by the full request URL string), used to stub the
+ /// subscription-tab and pay-as-you-go-tab requests independently. Falls back to
+ /// `(statusCode, body)` for any URL not present here.
+ private let overridesByURL: [String: (statusCode: Int, body: String)]
+ private let billingWaitsForPayAsYouGo: Bool
+ private let payAsYouGoBlocksUntilCancelled: Bool
+ private let payAsYouGoDelay: Duration?
+ private var capturedRequests: [CapturedRequest] = []
+ private var payAsYouGoStarted = false
+ private var payAsYouGoCompleted = false
+ private var payAsYouGoWasCancelled = false
+ private var payAsYouGoStartWaiters: [CheckedContinuation] = []
+ private var payAsYouGoCompletionWaiters: [CheckedContinuation] = []
init(
statusCode: Int,
body: String,
responseURL: URL? = nil,
- headers: [String: String] = [:])
+ headers: [String: String] = [:],
+ overridesByURL: [String: (statusCode: Int, body: String)] = [:],
+ billingWaitsForPayAsYouGo: Bool = false,
+ payAsYouGoBlocksUntilCancelled: Bool = false,
+ payAsYouGoDelay: Duration? = nil)
{
self.statusCode = statusCode
self.body = body
self.responseURL = responseURL
self.headers = headers
+ self.overridesByURL = overridesByURL
+ self.billingWaitsForPayAsYouGo = billingWaitsForPayAsYouGo
+ self.payAsYouGoBlocksUntilCancelled = payAsYouGoBlocksUntilCancelled
+ self.payAsYouGoDelay = payAsYouGoDelay
}
func lastCapturedRequest() -> CapturedRequest? {
- self.capturedRequest
+ self.capturedRequests.last
+ }
+
+ func capturedRequestsSnapshot() -> [CapturedRequest] {
+ self.capturedRequests
}
- func data(for request: URLRequest) throws -> (Data, URLResponse) {
- self.capturedRequest = CapturedRequest(
+ func didCancelPayAsYouGo() -> Bool {
+ self.payAsYouGoWasCancelled
+ }
+
+ func data(for request: URLRequest) async throws -> (Data, URLResponse) {
+ let isPayAsYouGo = request.url?.query == "tab=payAsYouGo"
+ if isPayAsYouGo {
+ self.markPayAsYouGoStarted()
+ if let payAsYouGoDelay {
+ try await Task.sleep(for: payAsYouGoDelay)
+ }
+ if self.payAsYouGoBlocksUntilCancelled {
+ do {
+ try await Task.sleep(for: .seconds(30))
+ } catch {
+ self.payAsYouGoWasCancelled = true
+ throw error
+ }
+ }
+ } else if self.billingWaitsForPayAsYouGo {
+ await self.waitForPayAsYouGoStart()
+ if !self.payAsYouGoBlocksUntilCancelled {
+ await self.waitForPayAsYouGoCompletion()
+ }
+ }
+
+ self.capturedRequests.append(CapturedRequest(
url: request.url?.absoluteString,
method: request.httpMethod,
cookie: request.value(forHTTPHeaderField: "Cookie"),
- acceptLanguage: request.value(forHTTPHeaderField: "Accept-Language"))
+ acceptLanguage: request.value(forHTTPHeaderField: "Accept-Language")))
+
+ let override = request.url.flatMap { self.overridesByURL[$0.absoluteString] }
+ let (responseStatusCode, responseBody) = override ?? (self.statusCode, self.body)
let response = HTTPURLResponse(
url: self.responseURL ?? request.url!,
- statusCode: self.statusCode,
+ statusCode: responseStatusCode,
httpVersion: "HTTP/1.1",
headerFields: self.headers)!
- return (Data(self.body.utf8), response)
+ if isPayAsYouGo {
+ self.markPayAsYouGoCompleted()
+ }
+ return (Data(responseBody.utf8), response)
+ }
+
+ private func waitForPayAsYouGoStart() async {
+ guard !self.payAsYouGoStarted else { return }
+ await withCheckedContinuation { continuation in
+ self.payAsYouGoStartWaiters.append(continuation)
+ }
+ }
+
+ private func waitForPayAsYouGoCompletion() async {
+ guard !self.payAsYouGoCompleted else { return }
+ await withCheckedContinuation { continuation in
+ self.payAsYouGoCompletionWaiters.append(continuation)
+ }
+ }
+
+ private func markPayAsYouGoStarted() {
+ self.payAsYouGoStarted = true
+ let waiters = self.payAsYouGoStartWaiters
+ self.payAsYouGoStartWaiters.removeAll()
+ waiters.forEach { $0.resume() }
+ }
+
+ private func markPayAsYouGoCompleted() {
+ self.payAsYouGoCompleted = true
+ let waiters = self.payAsYouGoCompletionWaiters
+ self.payAsYouGoCompletionWaiters.removeAll()
+ waiters.forEach { $0.resume() }
}
}
diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift
index 22e0724a2f..9b8f0d34df 100644
--- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift
@@ -390,6 +390,62 @@ struct SettingsStoreCoverageTests {
#expect(SettingsStore.hasAnyTokenCostUsageSources(
env: ["CLAUDE_CONFIG_DIR": claudeRoot.path],
fileManager: fileManager))
+
+ let metadataOnlyHome = fileManager.temporaryDirectory.appendingPathComponent(
+ "claude-desktop-metadata-\(UUID().uuidString)",
+ isDirectory: true)
+ let metadataFile = metadataOnlyHome
+ .appendingPathComponent("Library/Application Support/Claude/claude-code-sessions", isDirectory: true)
+ .appendingPathComponent("account-id/org-id/local_session.json", isDirectory: false)
+ try fileManager.createDirectory(at: metadataFile.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try Data(#"{"cliSessionId":"desktop-cli-session"}"#.utf8).write(to: metadataFile)
+
+ #expect(!SettingsStore.hasAnyTokenCostUsageSources(
+ env: [:],
+ fileManager: fileManager,
+ homeDirectory: metadataOnlyHome))
+
+ let desktopHome = fileManager.temporaryDirectory.appendingPathComponent(
+ "claude-desktop-\(UUID().uuidString)",
+ isDirectory: true)
+ let desktopProjects = desktopHome
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("Claude", isDirectory: true)
+ .appendingPathComponent("local-agent-mode-sessions", isDirectory: true)
+ .appendingPathComponent("workspace-id", isDirectory: true)
+ .appendingPathComponent("session-id", isDirectory: true)
+ .appendingPathComponent("local_agent", isDirectory: true)
+ .appendingPathComponent(".claude", isDirectory: true)
+ .appendingPathComponent("projects", isDirectory: true)
+ try fileManager.createDirectory(at: desktopProjects, withIntermediateDirectories: true)
+ let desktopFile = desktopProjects
+ .appendingPathComponent("project-a", isDirectory: true)
+ .appendingPathComponent("session-a.jsonl", isDirectory: false)
+ try fileManager.createDirectory(at: desktopFile.deletingLastPathComponent(), withIntermediateDirectories: true)
+ fileManager.createFile(atPath: desktopFile.path, contents: Data("{}".utf8))
+
+ #expect(SettingsStore.hasAnyTokenCostUsageSources(
+ env: [:],
+ fileManager: fileManager,
+ homeDirectory: desktopHome))
+
+ let desktopCodeHome = fileManager.temporaryDirectory.appendingPathComponent(
+ "claude-desktop-code-\(UUID().uuidString)",
+ isDirectory: true)
+ let desktopCodeFile = desktopCodeHome
+ .appendingPathComponent("Library/Application Support/Claude/claude-code-sessions", isDirectory: true)
+ .appendingPathComponent("account-id/org-id/.claude/projects/project-a", isDirectory: true)
+ .appendingPathComponent("session-a.jsonl", isDirectory: false)
+ try fileManager.createDirectory(
+ at: desktopCodeFile.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ fileManager.createFile(atPath: desktopCodeFile.path, contents: Data("{}".utf8))
+
+ #expect(SettingsStore.hasAnyTokenCostUsageSources(
+ env: [:],
+ fileManager: fileManager,
+ homeDirectory: desktopCodeHome))
}
@Test
@@ -471,9 +527,9 @@ struct SettingsStoreCoverageTests {
}
@Test
- func `claude keychain read strategy defaults to security CLI experimental`() {
+ func `claude keychain read strategy defaults to security framework`() {
let settings = Self.makeSettingsStore()
- #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental)
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
}
@Test
@@ -484,13 +540,56 @@ struct SettingsStoreCoverageTests {
let configStore = testConfigStore(suiteName: suite)
let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
- first.claudeOAuthKeychainReadStrategy = .securityCLIExperimental
+ first.claudeOAuthKeychainReadStrategy = .securityFramework
#expect(
defaults.string(forKey: "claudeOAuthKeychainReadStrategy")
- == ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue)
+ == ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue)
let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
- #expect(second.claudeOAuthKeychainReadStrategy == .securityCLIExperimental)
+ #expect(second.claudeOAuthKeychainReadStrategy == .securityFramework)
+ }
+
+ @Test
+ func `claude legacy security CLI read strategy preserves no prompt intent`() throws {
+ let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy-migration"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ defaults.set(
+ ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue,
+ forKey: "claudeOAuthKeychainReadStrategy")
+ let configStore = testConfigStore(suiteName: suite)
+
+ let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
+ #expect(settings.claudeOAuthKeychainPromptMode == .never)
+ #expect(settings.claudeOAuthPromptFreeCredentialsEnabled)
+ #expect(
+ defaults.string(forKey: "claudeOAuthKeychainReadStrategy")
+ == ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue)
+ #expect(
+ defaults.string(forKey: "claudeOAuthKeychainPromptMode")
+ == ClaudeOAuthKeychainPromptMode.never.rawValue)
+ }
+
+ @Test
+ func `claude legacy security CLI migration preserves explicit prompt policy`() throws {
+ let suite = "SettingsStoreCoverageTests-claude-keychain-explicit-prompt-migration"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ defaults.set(
+ ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue,
+ forKey: "claudeOAuthKeychainReadStrategy")
+ defaults.set(
+ ClaudeOAuthKeychainPromptMode.always.rawValue,
+ forKey: "claudeOAuthKeychainPromptMode")
+ let configStore = testConfigStore(suiteName: suite)
+
+ let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
+ #expect(settings.claudeOAuthKeychainPromptMode == .always)
+ #expect(!settings.claudeOAuthPromptFreeCredentialsEnabled)
}
@Test
@@ -506,15 +605,17 @@ struct SettingsStoreCoverageTests {
}
@Test
- func `claude prompt free credentials toggle maps to read strategy`() {
+ func `claude prompt free credentials toggle maps to never prompt policy`() {
let settings = Self.makeSettingsStore()
- #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == true)
+ #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == false)
- settings.claudeOAuthPromptFreeCredentialsEnabled = false
+ settings.claudeOAuthPromptFreeCredentialsEnabled = true
#expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
+ #expect(settings.claudeOAuthKeychainPromptMode == .never)
- settings.claudeOAuthPromptFreeCredentialsEnabled = true
- #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental)
+ settings.claudeOAuthPromptFreeCredentialsEnabled = false
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
+ #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction)
}
@Test
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 31db735e16..6ddc25d59a 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -1448,6 +1448,29 @@ struct SettingsStoreTests {
#expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata))
}
+ @Test
+ func `cost comparison periods default off and persist`() throws {
+ let suite = "SettingsStoreTests-cost-comparison-periods"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+ let storeA = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+
+ #expect(!storeA.costComparisonPeriodsEnabled)
+ storeA.costComparisonPeriodsEnabled = true
+
+ let storeB = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ #expect(storeB.costComparisonPeriodsEnabled)
+ }
+
@Test
func `cost summary display style defaults to both and persists`() throws {
let suite = "SettingsStoreTests-cost-summary-display-style"
diff --git a/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift
index 83c6974ee7..423c31f641 100644
--- a/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift
+++ b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift
@@ -6,10 +6,10 @@ import Testing
@MainActor
struct SettingsWindowAppearanceTests {
@Test
- func `settings always keeps both navigation columns visible`() {
- #expect(PreferencesView.visibleColumnVisibility(for: .detailOnly) == .doubleColumn)
- #expect(PreferencesView.visibleColumnVisibility(for: .automatic) == .doubleColumn)
- #expect(PreferencesView.visibleColumnVisibility(for: .doubleColumn) == .doubleColumn)
+ func `settings sidebar uses a fixed noncollapsible width`() {
+ #expect(SettingsPane.sidebarWidth == 260)
+ #expect(SettingsPane.windowMinWidth > SettingsPane.sidebarWidth)
+ #expect(SettingsPane.detailMaxWidth > SettingsPane.windowMinWidth - SettingsPane.sidebarWidth)
}
@Test
@@ -47,7 +47,7 @@ struct SettingsWindowAppearanceTests {
}
@Test
- func `settings window sizing restores a collapsed sidebar without private identifiers`() {
+ func `settings window sizing does not mutate content split views`() {
let window = NSWindow(
contentRect: NSRect(x: 120, y: 160, width: 180, height: 140),
styleMask: [.titled],
@@ -66,34 +66,7 @@ struct SettingsWindowAppearanceTests {
SettingsWindowSizing.enforceMinimumSize(window)
#expect(window.frame.width >= window.minSize.width)
- #expect(sidebar.frame.width >= SettingsPane.sidebarWidth)
- }
-
- @Test
- func `settings window sizing repairs the outer navigation split`() {
- let window = NSWindow(
- contentRect: NSRect(x: 120, y: 160, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight),
- styleMask: [.titled],
- backing: .buffered,
- defer: false)
- let outerSplit = NSSplitView(
- frame: NSRect(x: 0, y: 0, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight))
- outerSplit.isVertical = true
- let sidebar = NSView(frame: NSRect(x: 0, y: 0, width: 0, height: SettingsPane.windowHeight))
- let detail = NSView(
- frame: NSRect(x: 0, y: 0, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight))
- let nestedSplit = NSSplitView(frame: NSRect(x: 0, y: 0, width: 300, height: 300))
- nestedSplit.isVertical = true
- nestedSplit.addSubview(NSView(frame: .zero))
- nestedSplit.addSubview(NSView(frame: .zero))
- detail.addSubview(nestedSplit)
- outerSplit.addSubview(sidebar)
- outerSplit.addSubview(detail)
- window.contentView = outerSplit
-
- SettingsWindowSizing.enforceMinimumSize(window)
-
- #expect(sidebar.frame.width >= SettingsPane.sidebarWidth)
+ #expect(sidebar.frame.width == 0)
}
@Test
@@ -126,6 +99,41 @@ struct SettingsWindowAppearanceTests {
#expect(window.viewsNeedDisplay)
}
+ @Test
+ func `bridge updates window title without pulsing appearance on pane changes`() {
+ let resetCapture = ResetCapture()
+ let bridge = SettingsWindowAppearanceView { resetCapture.actions.append($0) }
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
+ styleMask: [.titled],
+ backing: .buffered,
+ defer: false)
+ window.contentView = bridge
+ resetCapture.actions.removeAll()
+
+ bridge.refreshWindowAppearance(for: .light, windowTitle: "Display")
+ #expect(resetCapture.actions.count == 1)
+
+ bridge.refreshWindowAppearance(for: .light, windowTitle: "General")
+
+ #expect(window.title == "General")
+ #expect(resetCapture.actions.count == 1)
+ }
+
+ @Test
+ func `settings window style remains resizable`() {
+ let bridge = SettingsWindowAppearanceView()
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
+ styleMask: [.titled],
+ backing: .buffered,
+ defer: false)
+
+ window.contentView = bridge
+
+ #expect(window.styleMask.contains(.resizable))
+ }
+
@Test
func `repeated theme updates cannot leave an explicit appearance`() {
let resetCapture = ResetCapture()
diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
index f614aabf38..a0320708ba 100644
--- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
+++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
@@ -142,6 +142,101 @@ struct StatusItemAnimationSignatureTests {
#expect(signature.contains("weekly=1.000"))
}
+ @Test
+ func `merged mistral icon uses monthly plan metric when selected`() throws {
+ let suite = "StatusItemAnimationSignatureTests-merged-mistral-monthly-plan"
+ let settings = testSettingsStore(suiteName: suite)
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .mistral
+ settings.menuBarShowsBrandIconWithPercent = false
+ settings.usageBarsShowUsed = true
+ settings.syntheticAPIToken = "synthetic-test-token"
+ settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral)
+
+ let registry = ProviderRegistry.shared
+ if let mistralMeta = registry.metadata[.mistral] {
+ settings.setProviderEnabled(provider: .mistral, metadata: mistralMeta, enabled: true)
+ }
+ if let syntheticMeta = registry.metadata[.synthetic] {
+ settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: testStatusBar())
+ defer { controller.releaseStatusItemsForTesting() }
+
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ extraRateWindows: [
+ NamedRateWindow(
+ id: "mistral-monthly-plan",
+ title: "Monthly Plan",
+ window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)),
+ ],
+ updatedAt: Date()),
+ provider: .mistral)
+
+ #expect(store.iconStyle == .combined)
+ #expect(controller.primaryProviderForUnifiedIcon() == .mistral)
+
+ controller.applyIcon(phase: nil)
+ let signature = try #require(controller.lastAppliedMergedIconRenderSignature)
+
+ #expect(signature.contains("provider=mistral"))
+ #expect(signature.contains("primary=42.000"))
+ #expect(signature.contains("weekly=nil"))
+ }
+
+ @Test
+ func `mistral pay as you go icon ignores balance primary percent`() {
+ let suite = "StatusItemAnimationSignatureTests-mistral-payg-balance-percent"
+ let settings = testSettingsStore(suiteName: suite)
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.usageBarsShowUsed = true
+ settings.setMenuBarMetricPreference(.automatic, for: .mistral)
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: testStatusBar())
+ defer { controller.releaseStatusItemsForTesting() }
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "$12.50"),
+ secondary: nil,
+ updatedAt: Date())
+
+ let percents = controller.resolvedMenuBarIconPercents(
+ provider: .mistral,
+ snapshot: snapshot,
+ style: .mistral,
+ showUsed: true)
+
+ #expect(percents?.primary == nil)
+ #expect(percents?.secondary == nil)
+ }
+
@Test
func `merged brand percent reapplies title when cached render is skipped`() throws {
let suite = "StatusItemAnimationSignatureTests-merged-brand-percent-title-restore"
diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift
index e834dd2423..3074f52701 100644
--- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift
+++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift
@@ -346,6 +346,76 @@ struct StatusItemBalanceDisplayTests {
#expect(displayText == "€1.2345")
}
+ @Test
+ func `menu bar display text uses mistral monthly plan when selected`() {
+ let settings = self.makeSettings(
+ suiteName: "StatusItemBalanceDisplayTests-mistral-monthly-plan",
+ provider: .mistral)
+ settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral)
+ let (store, controller) = self.makeStoreAndController(settings: settings)
+ defer { controller.releaseStatusItemsForTesting() }
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 1.2345,
+ currency: "EUR",
+ currencySymbol: "€",
+ totalInputTokens: 10000,
+ totalOutputTokens: 5000,
+ totalCachedTokens: 0,
+ modelCount: 2,
+ startDate: nil,
+ endDate: nil,
+ updatedAt: Date())
+ .toUsageSnapshot()
+ .with(extraRateWindows: [
+ NamedRateWindow(
+ id: "mistral-monthly-plan",
+ title: "Monthly Plan",
+ window: RateWindow(
+ usedPercent: 42,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: nil)),
+ ])
+
+ store._setSnapshotForTesting(snapshot, provider: .mistral)
+ store._setErrorForTesting(nil, provider: .mistral)
+
+ let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot)
+
+ #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month")
+ #expect(displayText == "42%")
+ }
+
+ @Test
+ func `menu bar display text falls back to mistral spend when monthly plan is missing`() {
+ let settings = self.makeSettings(
+ suiteName: "StatusItemBalanceDisplayTests-mistral-monthly-plan-missing",
+ provider: .mistral)
+ settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral)
+ let (store, controller) = self.makeStoreAndController(settings: settings)
+ defer { controller.releaseStatusItemsForTesting() }
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 1.2345,
+ currency: "EUR",
+ currencySymbol: "€",
+ totalInputTokens: 10000,
+ totalOutputTokens: 5000,
+ totalCachedTokens: 0,
+ modelCount: 2,
+ startDate: nil,
+ endDate: nil,
+ updatedAt: Date())
+ .toUsageSnapshot()
+
+ store._setSnapshotForTesting(snapshot, provider: .mistral)
+ store._setErrorForTesting(nil, provider: .mistral)
+
+ let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot)
+
+ #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month")
+ #expect(displayText == "€1.2345")
+ }
+
@Test
func `menu bar display text uses kimi k2 api key credits`() {
let settings = self.makeSettings(
@@ -560,7 +630,7 @@ struct StatusItemBalanceDisplayTests {
}
@Test
- func `mistral primary window is nil even when billing end date is set`() {
+ func `mistral primary window is nil without credits even when billing end date is set`() {
let endDate = Date(timeIntervalSinceNow: 3600)
let snapshot = MistralUsageSnapshot(
totalCost: 0.5,
@@ -574,7 +644,7 @@ struct StatusItemBalanceDisplayTests {
endDate: endDate,
updatedAt: Date()).toUsageSnapshot()
- // Mistral doesn't expose a reset time — primary is always nil.
+ // Billing end date alone is not a quota window; credits are what populate primary.
#expect(snapshot.primary == nil)
}
@@ -586,6 +656,22 @@ struct StatusItemBalanceDisplayTests {
#expect(StatusItemController.buttonTitle("", hasImage: true).isEmpty)
}
+ @Test
+ func `debug button title stays visible with or without a usage value`() {
+ #expect(StatusItemController.buttonTitle(nil, hasImage: true, isDebugApp: true) == " D")
+ #expect(StatusItemController.buttonTitle("42%", hasImage: true, isDebugApp: true) == " 42% D")
+ #expect(StatusItemController.buttonTitle("42%", hasImage: false, isDebugApp: true) == "42% D")
+ }
+
+ @Test
+ func `debug bundle identity updates status item accessibility`() {
+ #expect(StatusItemController.isDebugApp(bundleIdentifier: "com.steipete.codexbar.debug"))
+ #expect(!StatusItemController.isDebugApp(bundleIdentifier: "com.steipete.codexbar"))
+ #expect(!StatusItemController.isDebugApp(bundleIdentifier: nil))
+ #expect(StatusItemController.statusItemAccessibilityTitle(isDebugApp: true) == "CodexBar Debug")
+ #expect(StatusItemController.statusItemAccessibilityTitle(isDebugApp: false) == "CodexBar")
+ }
+
private func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore {
let settings = testSettingsStore(suiteName: suiteName)
settings.statusChecksEnabled = false
diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
index 9e3f2d3de8..e01de5583c 100644
--- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
+++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
@@ -116,6 +116,48 @@ struct StatusItemControllerMenuTests {
#expect(percent == 76)
}
+ @Test
+ func `mistral switcher uses monthly plan metric when selected`() {
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ extraRateWindows: [
+ NamedRateWindow(
+ id: "mistral-monthly-plan",
+ title: "Monthly Plan",
+ window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)),
+ ],
+ updatedAt: Date())
+
+ let percent = StatusItemController.switcherWeeklyMetricPercent(
+ for: .mistral,
+ snapshot: snapshot,
+ showUsed: true,
+ preference: .monthlyPlan)
+
+ #expect(percent == 42)
+ }
+
+ @Test
+ func `mistral switcher ignores pay as you go balance primary`() {
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "$12.50"),
+ secondary: nil,
+ updatedAt: Date())
+
+ let percent = StatusItemController.switcherWeeklyMetricPercent(
+ for: .mistral,
+ snapshot: snapshot,
+ showUsed: true,
+ preference: .automatic)
+
+ #expect(percent == nil)
+ }
+
@Test
@MainActor
func `menu card width stays at base width when menu accessories are present`() {
diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift
index 6d4f605390..6b6cca6ff6 100644
--- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift
+++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift
@@ -72,12 +72,14 @@ struct StatusItemControllerSplitLifecycleTests {
#expect(controller.statusItems[.codex] != nil)
#expect(controller.statusItems[.claude] != nil)
+ #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-codex", "codexbar-claude"])
settings.mergeIcons = true
controller.handleProviderConfigChange(reason: "test")
#expect(controller.statusItem.isVisible == true)
#expect(controller.statusItems.isEmpty)
+ #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-merged"])
}
@Test
@@ -413,6 +415,26 @@ struct StatusItemControllerSplitLifecycleTests {
#expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil)
}
+ @Test
+ func `status item visibility default distinguishes enabled disabled and unset`() throws {
+ let suite = "StatusItemControllerSplitLifecycleTests-visibility-default-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ defaults.set(true, forKey: "NSStatusItem VisibleCC codexbar-merged")
+ defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-claude")
+ defer { defaults.removePersistentDomain(forName: suite) }
+
+ #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault(
+ defaults: defaults,
+ autosaveName: "codexbar-merged") == true)
+ #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault(
+ defaults: defaults,
+ autosaveName: "codexbar-claude") == false)
+ #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault(
+ defaults: defaults,
+ autosaveName: "codexbar-codex") == nil)
+ }
+
@Test
func `non destructive visibility refresh preserves split provider status items`() throws {
let (_, controller) = try self.makeSplitController()
diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift
index d4210ac541..d6b7743578 100644
--- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift
+++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift
@@ -14,7 +14,10 @@ struct StatusItemIconObservationSignatureTests {
settings.statusChecksEnabled = true
settings.refreshFrequency = .manual
settings.menuBarShowsBrandIconWithPercent = false
+ settings.menuBarShowsHighestUsage = false
settings.mergeIcons = true
+ settings.mergedMenuLastSelectedWasOverview = false
+ settings.selectedMenuProvider = .codex
let registry = ProviderRegistry.shared
if let codexMeta = registry.metadata[.codex] {
@@ -88,6 +91,7 @@ struct StatusItemIconObservationSignatureTests {
let registry = ProviderRegistry.shared
let claudeMetadata = try #require(registry.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true)
+ settings.selectedMenuProvider = .codex
store._setSnapshotForTesting(
Self.makeSnapshot(provider: .claude, email: "claude@example.com"),
provider: .claude)
diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift
index a3b528adef..62628b01ef 100644
--- a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift
+++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift
@@ -185,7 +185,7 @@ extension StatusMenuTests {
controller._test_manualRefreshOperation = nil
}
controller.refreshNow()
- let task = try #require(controller.manualRefreshTask)
+ let task = try #require(controller.manualRefreshTasks[.global])
controller.invalidateMenus()
for _ in 0..<40 {
diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift
index 30db8bc2fe..cab2d718ba 100644
--- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift
+++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift
@@ -6,6 +6,30 @@ import Testing
@MainActor
@Suite(.serialized)
struct StatusMenuHostedSubmenuRefreshTests {
+ @Test
+ func `claude swap completion changes open menu readiness`() {
+ let settings = Self.makeSettings()
+ settings.setProviderEnabled(
+ provider: .claude,
+ metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata,
+ enabled: true)
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ defer { controller.releaseStatusItemsForTesting() }
+
+ let before = controller.menuAdjunctReadinessSignature()
+ store.claudeSwapRevision &+= 1
+
+ #expect(controller.menuAdjunctReadinessSignature() != before)
+ }
+
@Test
func `status components change open menu readiness`() {
let settings = Self.makeSettings()
@@ -37,6 +61,29 @@ struct StatusMenuHostedSubmenuRefreshTests {
#expect(controller.menuAdjunctReadinessSignature() != before)
}
+ @Test
+ func `project source changes open menu readiness`() {
+ let settings = Self.makeSettings()
+ settings.costUsageEnabled = true
+ Self.enableOnly(settings, provider: .codex)
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/main"), provider: .codex)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ defer { controller.releaseStatusItemsForTesting() }
+
+ let before = controller.menuAdjunctReadinessSignature()
+ store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/worktree"), provider: .codex)
+
+ #expect(controller.menuAdjunctReadinessSignature() != before)
+ }
+
@Test
func `status submenu link stays scoped to its provider`() throws {
let settings = Self.makeSettings()
@@ -608,8 +655,31 @@ struct StatusMenuHostedSubmenuRefreshTests {
store._setSnapshotForTesting(snapshot, provider: .zai)
}
- private static func makeTokenSnapshot(dailyCost: Double = 1.23) -> CostUsageTokenSnapshot {
- CostUsageTokenSnapshot(
+ private static func makeTokenSnapshot(
+ dailyCost: Double = 1.23,
+ projectSourcePath: String? = nil) -> CostUsageTokenSnapshot
+ {
+ let projects = projectSourcePath.map { sourcePath in
+ [
+ CostUsageProjectBreakdown(
+ name: "Project",
+ path: "/tmp/main",
+ totalTokens: 123,
+ totalCostUSD: dailyCost,
+ daily: [],
+ modelBreakdowns: nil,
+ sources: [
+ CostUsageProjectSourceBreakdown(
+ name: "Source",
+ path: sourcePath,
+ totalTokens: 123,
+ totalCostUSD: dailyCost,
+ daily: [],
+ modelBreakdowns: nil),
+ ]),
+ ]
+ } ?? []
+ return CostUsageTokenSnapshot(
sessionTokens: 123,
sessionCostUSD: 0.12,
last30DaysTokens: 123,
@@ -624,6 +694,7 @@ struct StatusMenuHostedSubmenuRefreshTests {
modelsUsed: nil,
modelBreakdowns: nil),
],
+ projects: projects,
updatedAt: Date())
}
}
diff --git a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift
index 2bb35b2509..2812dbb9d1 100644
--- a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift
+++ b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift
@@ -42,8 +42,7 @@ struct StatusMenuMergedOverviewRefreshTests {
}
#expect(requestCount == 0)
- #expect(controller.manualRefreshTask == nil)
- #expect(controller.manualRefreshProvider == nil)
+ #expect(controller.manualRefreshTasks.isEmpty)
}
private func makeSettings() -> SettingsStore {
diff --git a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift
index 81db772f1a..91b96f2b77 100644
--- a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift
+++ b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift
@@ -6,6 +6,65 @@ import Testing
@MainActor
@Suite(.serialized)
struct StatusMenuNativeSectionSpacingTests {
+ @Test
+ func `buy credits stays available without an error only credits section`() {
+ let previousRendering = StatusItemController.menuCardRenderingEnabled
+ StatusItemController.menuCardRenderingEnabled = true
+ defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
+
+ let settings = self.makeSettings()
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+ settings.showOptionalCreditsAndExtraUsage = true
+ self.enableOnlyCodex(settings)
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ store.lastCreditsError = UsageError.noRateLimitsFound.errorDescription
+ store.lastOpenAIDashboardError =
+ "No matching OpenAI web session found. Sign in to chatgpt.com, then refresh OpenAI cookies."
+ let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1)
+ let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30)
+ store.openAIDashboard = OpenAIDashboardSnapshot(
+ signedInEmail: "user@example.com",
+ codeReviewRemainingPercent: 100,
+ creditEvents: [event],
+ dailyBreakdown: breakdown,
+ usageBreakdown: breakdown,
+ creditsPurchaseURL: nil,
+ updatedAt: Date())
+ store.openAIDashboardAttachmentAuthorized = true
+ store.openAIDashboardRequiresLogin = false
+
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ defer { controller.releaseStatusItemsForTesting() }
+
+ let menu = controller.makeMenu(for: .codex)
+ controller.menuWillOpen(menu)
+
+ #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardCredits" } == false)
+ #expect(menu.items.contains { $0.title == "Buy Credits..." })
+ #expect(menu.items.contains { item in
+ item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true
+ })
+
+ settings.showOptionalCreditsAndExtraUsage = false
+ let hiddenMenu = controller.makeMenu(for: .codex)
+ controller.menuWillOpen(hiddenMenu)
+ #expect(hiddenMenu.items.contains { $0.title == "Buy Credits..." } == false)
+ #expect(hiddenMenu.items.contains { item in
+ item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true
+ } == false)
+ }
+
@Test
func `usage history cost and storage stay together without adjacent separators`() throws {
let previousRendering = StatusItemController.menuCardRenderingEnabled
diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift
index 859f4242d2..b4fcb34d67 100644
--- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift
+++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift
@@ -172,17 +172,17 @@ struct StatusMenuPersistentRefreshTests {
let gate = ManualRefreshGate()
controller._test_manualRefreshOperation = { await gate.wait() }
#expect(try controller.handleMenuTrackingShortcutEvent(self.keyEvent("r", keyCode: 15), menu: menu))
- for _ in 0..<20 where controller.manualRefreshTask == nil {
+ for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
await Task.yield()
}
- let task = try #require(controller.manualRefreshTask)
+ let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
#expect(!refreshItem.isEnabled)
gate.resume()
await task.value
- #expect(controller.manualRefreshTask == nil)
+ #expect(controller.manualRefreshTasks[.provider(.codex)] == nil)
#expect(refreshItem.isEnabled)
}
@@ -260,22 +260,37 @@ struct StatusMenuPersistentRefreshTests {
#expect(!refreshItem.isEnabled)
controller.store.isRefreshing = false
- controller.manualRefreshProvider = .claude
- controller.manualRefreshTask = Task {}
+
+ // A manual refresh scoped to another provider must not grey out this provider's row.
+ controller.manualRefreshTasks[.provider(.claude)] = Task {}
+ controller.updatePersistentRefreshItemsEnabled()
+ #expect(refreshItem.isEnabled)
+
+ // This provider's own manual refresh does disable its row.
+ controller.manualRefreshTasks[.provider(.codex)] = Task {}
+ controller.updatePersistentRefreshItemsEnabled()
+ #expect(!refreshItem.isEnabled)
+
+ controller.manualRefreshTasks[.provider(.codex)] = nil
+ controller.manualRefreshTasks[.provider(.claude)] = nil
+ controller.updatePersistentRefreshItemsEnabled()
+ #expect(refreshItem.isEnabled)
+
+ // An all-providers refresh greys every row.
+ controller.manualRefreshTasks[.global] = Task {}
controller.updatePersistentRefreshItemsEnabled()
#expect(!refreshItem.isEnabled)
- controller.manualRefreshTask = nil
- controller.manualRefreshProvider = nil
+ controller.manualRefreshTasks[.global] = nil
controller.updatePersistentRefreshItemsEnabled()
#expect(refreshItem.isEnabled)
refreshItem.representedObject = "notRefresh"
- controller.manualRefreshTask = Task {}
+ controller.manualRefreshTasks[.global] = Task {}
controller.updatePersistentRefreshItemsEnabled()
#expect(refreshItem.isEnabled)
#expect(!controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem })
- controller.manualRefreshTask = nil
+ controller.manualRefreshTasks[.global] = nil
}
@Test
@@ -291,9 +306,9 @@ struct StatusMenuPersistentRefreshTests {
#expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading)
controller.store.isRefreshing = false
- monitor.isManualRefreshInFlight = true
+ monitor.beginManualRefresh(frozenModels: [:], provider: nil)
#expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading)
- monitor.isManualRefreshInFlight = false
+ monitor.endManualRefresh()
controller.store.isRefreshing = false
let now = Date()
@@ -314,7 +329,7 @@ struct StatusMenuPersistentRefreshTests {
#expect(failure.style == .error)
#expect(failure.text == "Refresh failed")
- monitor.isManualRefreshInFlight = true
+ monitor.beginManualRefresh(frozenModels: [:], provider: nil)
#expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading)
}
@@ -328,7 +343,7 @@ struct StatusMenuPersistentRefreshTests {
let expectedClaude = monitor.subtitle(for: .claude, fallback: fallback)
monitor.beginManualRefresh(frozenModels: [.codex: codexModel], provider: .codex)
- defer { monitor.endManualRefresh() }
+ defer { monitor.endManualRefresh(for: .codex) }
#expect(monitor.isManualRefreshInFlight(for: .codex))
#expect(!monitor.isManualRefreshInFlight(for: .claude))
@@ -356,7 +371,7 @@ struct StatusMenuPersistentRefreshTests {
resetDescription: nil),
updatedAt: now)
let fallback = try #require(controller.menuCardModel(for: .claude))
- controller.menuCardRefreshMonitor.isManualRefreshInFlight = true
+ controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: .claude)
controller.store.snapshots[.claude] = UsageSnapshot(
primary: RateWindow(
@@ -374,7 +389,7 @@ struct StatusMenuPersistentRefreshTests {
let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback)
#expect(inFlight.metrics.map(\.percent) == fallback.metrics.map(\.percent))
- controller.menuCardRefreshMonitor.isManualRefreshInFlight = false
+ controller.menuCardRefreshMonitor.endManualRefresh(for: .claude)
let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback)
let expected = try #require(controller.menuCardModel(for: .claude))
@@ -730,7 +745,7 @@ struct StatusMenuPersistentRefreshTests {
controller.store.errors[.codex] =
"Refresh failed with a much longer replacement message that must not resize the tracked menu"
let replacementErrorHeight = fittingHeight(for: errorModel)
- controller.menuCardRefreshMonitor.isManualRefreshInFlight = true
+ controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: nil)
let retryHeight = fittingHeight(for: errorModel)
#expect(replacementErrorHeight == errorHeight)
@@ -752,7 +767,7 @@ struct StatusMenuPersistentRefreshTests {
controller.refreshNow()
#expect(requestCount == 0)
- #expect(controller.manualRefreshTask == nil)
+ #expect(controller.manualRefreshTasks.isEmpty)
#expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight)
}
@@ -772,7 +787,7 @@ struct StatusMenuPersistentRefreshTests {
}
controller.refreshNow()
- let task = try #require(controller.manualRefreshTask)
+ let task = try #require(controller.manualRefreshTasks[.global])
controller.refreshNow()
controller.refreshNow()
await Task.yield()
@@ -783,7 +798,7 @@ struct StatusMenuPersistentRefreshTests {
gate.resume()
await task.value
- #expect(controller.manualRefreshTask == nil)
+ #expect(controller.manualRefreshTasks[.global] == nil)
#expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight)
}
@@ -805,37 +820,29 @@ struct StatusMenuPersistentRefreshTests {
}
let mouseGate = ManualRefreshGate()
- var requestCount = 0
- controller._test_manualRefreshOperation = {
- requestCount += 1
- await mouseGate.wait()
- }
+ controller._test_manualRefreshOperation = { await mouseGate.wait() }
let refreshItem = try #require(menu.items.first { $0.title == "Refresh" })
#expect(controller.isPersistentRefreshItem(refreshItem))
controller.performPersistentRefreshAction(in: ObjectIdentifier(menu))
- for _ in 0..<20 where controller.manualRefreshTask == nil {
+ for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
await Task.yield()
}
- let mouseTask = try #require(controller.manualRefreshTask)
- #expect(controller.manualRefreshProvider == .claude)
- #expect(controller.isRefreshActionInFlight(for: codexMenu))
- #expect(controller.isRefreshActionInFlight(for: NSMenu()))
- let codexRefreshItem = try #require(codexMenu.items.first { $0.title == "Refresh" })
- #expect(controller.isPersistentRefreshItem(codexRefreshItem))
- controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu))
- await Task.yield()
- #expect(requestCount == 1)
+ let mouseTask = try #require(controller.manualRefreshTasks[.provider(.claude)])
+
+ // Refreshing Claude greys the Claude row but must leave the Codex row available.
+ #expect(controller.isRefreshActionInFlight(for: menu))
+ #expect(!controller.isRefreshActionInFlight(for: codexMenu))
+
mouseGate.resume()
await mouseTask.value
let keyboardGate = ManualRefreshGate()
controller._test_manualRefreshOperation = { await keyboardGate.wait() }
#expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15)))
- for _ in 0..<20 where controller.manualRefreshTask == nil {
+ for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
await Task.yield()
}
- let keyboardTask = try #require(controller.manualRefreshTask)
- #expect(controller.manualRefreshProvider == .claude)
+ let keyboardTask = try #require(controller.manualRefreshTasks[.provider(.claude)])
keyboardGate.resume()
await keyboardTask.value
}
@@ -865,8 +872,7 @@ struct StatusMenuPersistentRefreshTests {
}
#expect(requestCount == 0)
- #expect(controller.manualRefreshTask == nil)
- #expect(controller.manualRefreshProvider == nil)
+ #expect(controller.manualRefreshTasks.isEmpty)
}
@Test
@@ -888,11 +894,10 @@ struct StatusMenuPersistentRefreshTests {
let refreshItem = try #require(menu.items.first { $0.title == "Refresh" })
#expect(controller.isPersistentRefreshItem(refreshItem))
controller.performPersistentRefreshAction(in: ObjectIdentifier(menu))
- for _ in 0..<20 where controller.manualRefreshTask == nil {
+ for _ in 0..<20 where controller.manualRefreshTasks[.global] == nil {
await Task.yield()
}
- let overviewTask = try #require(controller.manualRefreshTask)
- #expect(controller.manualRefreshProvider == nil)
+ let overviewTask = try #require(controller.manualRefreshTasks[.global])
overviewGate.resume()
await overviewTask.value
@@ -901,11 +906,10 @@ struct StatusMenuPersistentRefreshTests {
let providerGate = ManualRefreshGate()
controller._test_manualRefreshOperation = { await providerGate.wait() }
#expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15)))
- for _ in 0..<20 where controller.manualRefreshTask == nil {
+ for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
await Task.yield()
}
- let providerTask = try #require(controller.manualRefreshTask)
- #expect(controller.manualRefreshProvider == .claude)
+ let providerTask = try #require(controller.manualRefreshTasks[.provider(.claude)])
providerGate.resume()
await providerTask.value
}
@@ -956,13 +960,13 @@ struct StatusMenuPersistentRefreshTests {
}
controller.refreshNow()
- let task = try #require(controller.manualRefreshTask)
+ let task = try #require(controller.manualRefreshTasks[.global])
#expect(!refreshItem.isEnabled)
gate.resume()
await task.value
- #expect(controller.manualRefreshTask == nil)
+ #expect(controller.manualRefreshTasks[.global] == nil)
#expect(refreshItem.isEnabled)
let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info)
#expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .error)
@@ -1092,3 +1096,125 @@ extension StatusMenuPersistentRefreshTests {
#expect(refreshView.frame.height == metrics.rowHeight)
}
}
+
+extension StatusMenuPersistentRefreshTests {
+ @Test
+ func `concurrent manual refreshes keep each provider's frozen card`() throws {
+ let settings = self.makeSettings()
+ let controller = self.makeController(settings: settings)
+ let monitor = controller.menuCardRefreshMonitor
+ let now = Date()
+
+ func quotaSnapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot {
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: 300,
+ resetsAt: updatedAt.addingTimeInterval(3600),
+ resetDescription: nil),
+ secondary: nil,
+ updatedAt: updatedAt)
+ }
+
+ // The snapshot helper supplies every enabled provider even for a provider-scoped refresh.
+ controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 21, updatedAt: now)
+ controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 15, updatedAt: now)
+ let claudeFrozen = try #require(controller.menuCardModel(for: .claude))
+ let codexBeforeItsRefresh = try #require(controller.menuCardModel(for: .codex))
+ monitor.beginManualRefresh(
+ frozenModels: [.claude: claudeFrozen, .codex: codexBeforeItsRefresh],
+ provider: .claude)
+
+ // Each provider must freeze the card visible when its own refresh starts.
+ controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 65, updatedAt: now.addingTimeInterval(1))
+ controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 42, updatedAt: now.addingTimeInterval(1))
+ let claudeMidRefresh = try #require(controller.menuCardModel(for: .claude))
+ let codexFrozen = try #require(controller.menuCardModel(for: .codex))
+ monitor.beginManualRefresh(
+ frozenModels: [.claude: claudeMidRefresh, .codex: codexFrozen],
+ provider: .codex)
+
+ let shownClaude = monitor.model(for: .claude, fallback: claudeMidRefresh)
+ let shownCodex = monitor.model(for: .codex, fallback: codexFrozen)
+ #expect(shownClaude.metrics.first?.percentLabel == "79% left")
+ #expect(shownCodex.metrics.first?.percentLabel == "58% left")
+
+ // Ending Codex leaves Claude frozen; ending Claude clears it.
+ monitor.endManualRefresh(for: .codex)
+ #expect(monitor.isManualRefreshInFlight(for: .claude))
+ #expect(!monitor.isManualRefreshInFlight(for: .codex))
+ monitor.endManualRefresh(for: .claude)
+ #expect(!monitor.isManualRefreshInFlight(for: .claude))
+ }
+
+ @Test
+ func `refreshing one provider does not block refreshing another`() async throws {
+ let settings = self.makeSettings()
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = false
+ self.enableOnly([.claude, .codex], settings: settings)
+
+ let controller = self.makeController(settings: settings)
+ let codexMenu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu)
+ controller.menuWillOpen(codexMenu)
+ defer { controller.menuDidClose(codexMenu) }
+
+ // Simulate a Claude manual refresh already in flight.
+ controller.manualRefreshTasks[.provider(.claude)] = Task {}
+ defer {
+ controller.manualRefreshTasks[.provider(.claude)]?.cancel()
+ controller.manualRefreshTasks[.provider(.claude)] = nil
+ }
+
+ let gate = ManualRefreshGate()
+ controller._test_manualRefreshOperation = { await gate.wait() }
+
+ // A Codex refresh must still start rather than being blocked by the in-flight Claude one.
+ controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu))
+ for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
+ await Task.yield()
+ }
+ let codexTask = try #require(controller.manualRefreshTasks[.provider(.codex)])
+ #expect(controller.isRefreshActionInFlight(for: codexMenu))
+
+ gate.resume()
+ await codexTask.value
+ }
+
+ @Test
+ func `overview stays busy through a provider refresh tail and blocks a global refresh`() async throws {
+ let settings = self.makeSettings()
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ self.enableOnly([.claude, .codex], settings: settings)
+ settings.mergedMenuLastSelectedWasOverview = true
+
+ let controller = self.makeController(settings: settings)
+ let menu = try #require(controller.makeMenu() as? StatusItemMenu)
+ controller.mergedMenu = menu
+ controller.menuWillOpen(menu)
+ defer { controller.menuDidClose(menu) }
+
+ // A per-provider Claude refresh whose task outlives the store's `refreshingProviders` window
+ // (the status/token/credits tail runs after the provider is removed from that set).
+ controller.manualRefreshTasks[.provider(.claude)] = Task {}
+ defer {
+ controller.manualRefreshTasks[.provider(.claude)]?.cancel()
+ controller.manualRefreshTasks[.provider(.claude)] = nil
+ }
+
+ // Overview stands for every provider, so its row stays greyed even with `refreshingProviders` empty.
+ #expect(controller.store.refreshingProviders.isEmpty)
+ #expect(controller.isRefreshActionInFlight(for: menu))
+
+ // And a global overview refresh must not start on top of the in-flight provider refresh.
+ var requestCount = 0
+ controller._test_manualRefreshOperation = { requestCount += 1 }
+ controller.performPersistentRefreshAction(in: ObjectIdentifier(menu))
+ for _ in 0..<20 {
+ await Task.yield()
+ }
+ #expect(requestCount == 0)
+ #expect(controller.manualRefreshTasks[.global] == nil)
+ }
+}
diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift
index 21b969fce6..59d9f4d99b 100644
--- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift
+++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift
@@ -349,7 +349,7 @@ struct StatusMenuSwitcherRefreshTests {
preferencesSelection: PreferencesSelection(),
statusBar: .system)
defer {
- controller.manualRefreshTask?.cancel()
+ controller.manualRefreshTasks.values.forEach { $0.cancel() }
controller.releaseStatusItemsForTesting()
}
@@ -401,7 +401,7 @@ struct StatusMenuSwitcherRefreshTests {
#expect(inFlight.metrics.first?.percentLabel == "79% left")
gate.resume()
- await controller.manualRefreshTask?.value
+ await controller.manualRefreshTasks[.global]?.value
#expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight)
let completed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback)
#expect(completed.metrics.isEmpty)
@@ -435,7 +435,7 @@ struct StatusMenuSwitcherRefreshTests {
preferencesSelection: PreferencesSelection(),
statusBar: .system)
defer {
- controller.manualRefreshTask?.cancel()
+ controller.manualRefreshTasks.values.forEach { $0.cancel() }
controller.releaseStatusItemsForTesting()
}
@@ -450,7 +450,7 @@ struct StatusMenuSwitcherRefreshTests {
let initialRefreshItem = try #require(menu.items.first { $0.title == "Refresh" })
controller.refreshNow()
- let refreshTask = try #require(controller.manualRefreshTask)
+ let refreshTask = try #require(controller.manualRefreshTasks[.global])
#expect(!initialRefreshItem.isEnabled)
var rebuildCount = 0
@@ -463,7 +463,7 @@ struct StatusMenuSwitcherRefreshTests {
gate.resume()
await refreshTask.value
- #expect(controller.manualRefreshTask == nil)
+ #expect(controller.manualRefreshTasks[.global] == nil)
let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
#expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag))
@@ -497,7 +497,7 @@ struct StatusMenuSwitcherRefreshTests {
preferencesSelection: PreferencesSelection(),
statusBar: .system)
defer {
- controller.manualRefreshTask?.cancel()
+ controller.manualRefreshTasks.values.forEach { $0.cancel() }
controller.releaseStatusItemsForTesting()
}
@@ -507,13 +507,13 @@ struct StatusMenuSwitcherRefreshTests {
controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.codex)])
let refreshItem = try #require(cache.items.first { $0.title == "Refresh" })
- controller.manualRefreshTask = Task {}
+ controller.manualRefreshTasks[.global] = Task {}
controller.updatePersistentRefreshItemsEnabled()
#expect(!refreshItem.isEnabled)
menu.removeAllItems()
#expect(refreshItem.menu == nil)
- controller.manualRefreshTask = nil
+ controller.manualRefreshTasks[.global] = nil
controller.updatePersistentRefreshItemsEnabled()
#expect(!refreshItem.isEnabled)
@@ -527,6 +527,51 @@ struct StatusMenuSwitcherRefreshTests {
#expect(refreshItem.isEnabled)
}
+ @Test
+ func `a provider manual refresh only greys its own tab`() {
+ let settings = Self.makeSettings()
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+ Self.enableCodexAndClaude(settings)
+ Self.disableOverview(settings)
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: .system)
+ defer {
+ controller.manualRefreshTasks.values.forEach { $0.cancel() }
+ controller.manualRefreshTasks.removeAll()
+ controller.releaseStatusItemsForTesting()
+ }
+
+ let menu = controller.makeMenu()
+ controller.mergedMenu = menu
+ controller.menuWillOpen(menu)
+ defer { controller.menuDidClose(menu) }
+
+ // A manual refresh of Claude must leave the Codex tab's Refresh row enabled.
+ controller.manualRefreshTasks[.provider(.claude)] = Task {}
+ #expect(!controller.isRefreshActionInFlight(for: menu))
+
+ // Switching to the Claude tab reflects Claude's own in-flight refresh.
+ settings.selectedMenuProvider = .claude
+ #expect(controller.isRefreshActionInFlight(for: menu))
+
+ // An all-providers refresh busies every tab regardless of the selected provider.
+ settings.selectedMenuProvider = .codex
+ controller.manualRefreshTasks[.provider(.claude)] = nil
+ controller.manualRefreshTasks[.global] = Task {}
+ #expect(controller.isRefreshActionInFlight(for: menu))
+ }
+
@Test
func `native image menu rows are replaced during reconciliation`() {
let settings = Self.makeSettings()
diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift
index 3fa28f8e03..3816d6ff4e 100644
--- a/Tests/CodexBarTests/StatusMenuTests.swift
+++ b/Tests/CodexBarTests/StatusMenuTests.swift
@@ -1214,12 +1214,13 @@ extension StatusMenuTests {
controller.menuWillOpen(menu)
let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" }
let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" }
+ let creditsHistoryItem = menu.items.first { item in
+ item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true
+ }
#expect(
usageItem?.submenu?.items
.contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true)
- #expect(
- creditsItem?.submenu?.items
- .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true)
+ #expect(creditsItem == nil && creditsHistoryItem != nil)
}
@Test
diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift
index 611a2c1c1d..5d31129eda 100644
--- a/Tests/CodexBarTests/StatusProbeTests.swift
+++ b/Tests/CodexBarTests/StatusProbeTests.swift
@@ -659,6 +659,26 @@ struct StatusProbeTests {
#expect(parsed == expected)
}
+ @Test
+ func `rolls claude reset date forward across the year boundary`() throws {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = try #require(TimeZone(identifier: "UTC"))
+ let now = try #require(calendar.date(from: DateComponents(
+ year: 2026, month: 12, day: 31, hour: 23, minute: 0, second: 0)))
+ let cases = [
+ ("Resets Jan 2, 3:15am (UTC)", 15),
+ ("Resets Jan 2, 3am (UTC)", 0),
+ ]
+
+ for (text, minute) in cases {
+ let parsed = ClaudeStatusProbe.parseResetDate(from: text, now: now)
+ let expected = calendar.date(from: DateComponents(
+ year: 2027, month: 1, day: 2, hour: 3, minute: minute, second: 0))
+ #expect(parsed == expected, "Failed to roll forward: \(text)")
+ #expect(try #require(parsed) > now)
+ }
+ }
+
@Test
func `parses claude reset with dot separated time`() throws {
let now = Date(timeIntervalSince1970: 1_733_690_000)
diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift
index a17e3c3f9b..dc3ea5e86e 100644
--- a/Tests/CodexBarTests/TTYIntegrationTests.swift
+++ b/Tests/CodexBarTests/TTYIntegrationTests.swift
@@ -74,8 +74,13 @@ struct TTYIntegrationTests {
@Test
func `claude pty usage stops on subscription notice`() async throws {
- let cli = try Self.makeSubscriptionNoticeClaudeCLI()
- defer { Task { await ClaudeCLISession.shared.reset() } }
+ let logURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString).log")
+ let cli = try Self.makeSubscriptionNoticeClaudeCLI(logURL: logURL)
+ defer {
+ try? FileManager.default.removeItem(at: logURL)
+ Task { await ClaudeCLISession.shared.reset() }
+ }
do {
try await ClaudeCLISession.withIsolatedSessionForTesting {
@@ -87,6 +92,10 @@ struct TTYIntegrationTests {
} catch {
#expect(Bool(false), "Unexpected error: \(error)")
}
+
+ let commands = try String(contentsOf: logURL, encoding: .utf8)
+ #expect(commands.contains("/usage"))
+ #expect(!commands.contains("/status"))
}
private static func makeSlowUsageClaudeCLI() throws -> URL {
@@ -117,7 +126,7 @@ struct TTYIntegrationTests {
return url
}
- private static func makeSubscriptionNoticeClaudeCLI() throws -> URL {
+ private static func makeSubscriptionNoticeClaudeCLI(logURL: URL) throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -125,6 +134,7 @@ struct TTYIntegrationTests {
let script = """
#!/bin/sh
while IFS= read -r line; do
+ printf '%s\\n' "$line" >> '\(logURL.path)'
case "$line" in
*"/usage"*)
printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage'
diff --git a/Tests/CodexBarTests/TailscaleSessionTests.swift b/Tests/CodexBarTests/TailscaleSessionTests.swift
new file mode 100644
index 0000000000..be91d66c4d
--- /dev/null
+++ b/Tests/CodexBarTests/TailscaleSessionTests.swift
@@ -0,0 +1,29 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct TailscaleSessionTests {
+ @Test
+ func `online mac and linux peers become hosts`() throws {
+ let url = try AgentSessionParserTests.fixtureURL("agent-sessions-tailscale", extension: "json")
+ let hosts = try TailscaleStatusParser.hosts(
+ from: Data(contentsOf: url),
+ excludingLocalHost: "local-mac")
+
+ #expect(hosts == ["clawmac", "linuxbox"])
+ }
+
+ @Test
+ func `ssh destinations reject options whitespace and controls`() {
+ let hosts = RemoteSessionFetcher.sanitizedHosts([
+ "user@clawmac",
+ "USER@CLAWMAC",
+ "-oProxyCommand=touch /tmp/unsafe",
+ "host with-space",
+ "host\nother",
+ "linuxbox",
+ ])
+
+ #expect(hosts == ["user@clawmac", "linuxbox"])
+ }
+}
diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift
index c42b6f854b..9098bf46eb 100644
--- a/Tests/CodexBarTests/TestStores.swift
+++ b/Tests/CodexBarTests/TestStores.swift
@@ -139,16 +139,25 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto
@MainActor
func testSettingsStore(
suiteName: String,
- tokenAccountStore: any ProviderTokenAccountStoring = InMemoryTokenAccountStore()) -> SettingsStore
+ tokenAccountStore: any ProviderTokenAccountStoring = InMemoryTokenAccountStore(),
+ config: CodexBarConfig? = nil) -> SettingsStore
{
let isolatedSuiteName = "\(suiteName)-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: isolatedSuiteName) else {
preconditionFailure("Could not create test defaults suite")
}
defaults.removePersistentDomain(forName: isolatedSuiteName)
+ let configStore = testConfigStore(suiteName: isolatedSuiteName)
+ if let config {
+ do {
+ try configStore.save(config)
+ } catch {
+ preconditionFailure("Could not save test config: \(error)")
+ }
+ }
return SettingsStore(
userDefaults: defaults,
- configStore: testConfigStore(suiteName: isolatedSuiteName),
+ configStore: configStore,
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore(),
codexCookieStore: InMemoryCookieHeaderStore(),
diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
index 1966a925c2..bac3bbb3fa 100644
--- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
+++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
@@ -4,6 +4,60 @@ import Testing
@testable import CodexBar
@testable import CodexBarCLI
+@Suite(.serialized)
+struct AlibabaTokenPlanRegionSelectionTests {
+ @Test @MainActor
+ func `fresh app settings default to International`() {
+ let settings = testSettingsStore(suiteName: "AlibabaTokenPlanRegionSelectionTests-fresh")
+
+ #expect(settings.alibabaTokenPlanAPIRegion == .international)
+ }
+
+ @Test @MainActor
+ func `legacy app settings without region remain China mainland`() {
+ var config = CodexBarConfig.makeDefault()
+ config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: nil))
+ let settings = testSettingsStore(
+ suiteName: "AlibabaTokenPlanRegionSelectionTests-legacy",
+ config: config)
+
+ #expect(settings.alibabaTokenPlanAPIRegion == .chinaMainland)
+ }
+
+ @Test @MainActor
+ func `app settings trim configured region`() {
+ var config = CodexBarConfig.makeDefault()
+ config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: " intl "))
+ let settings = testSettingsStore(
+ suiteName: "AlibabaTokenPlanRegionSelectionTests-trimmed",
+ config: config)
+
+ #expect(settings.alibabaTokenPlanAPIRegion == .international)
+ }
+
+ @Test
+ func `CLI honors explicit region and keeps legacy config on China mainland`() throws {
+ let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false)
+ let internationalContext = try TokenAccountCLIContext(
+ selection: selection,
+ config: CodexBarConfig(providers: [
+ ProviderConfig(id: .alibabatokenplan, region: AlibabaTokenPlanAPIRegion.international.rawValue),
+ ]),
+ verbose: false)
+ let legacyContext = try TokenAccountCLIContext(
+ selection: selection,
+ config: CodexBarConfig(providers: [
+ ProviderConfig(id: .alibabatokenplan, region: nil),
+ ]),
+ verbose: false)
+
+ #expect(internationalContext.settingsSnapshot(for: .alibabatokenplan, account: nil)?
+ .alibabaTokenPlan?.apiRegion == .international)
+ #expect(legacyContext.settingsSnapshot(for: .alibabatokenplan, account: nil)?
+ .alibabaTokenPlan?.apiRegion == .chinaMainland)
+ }
+}
+
@Suite(.serialized)
struct ZaiTokenAccountEnvironmentPrecedenceTests {
@Test
diff --git a/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift b/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift
new file mode 100644
index 0000000000..99325f09fb
--- /dev/null
+++ b/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift
@@ -0,0 +1,30 @@
+import Testing
+@testable import CodexBar
+
+@Suite("Usage breakdown chart menu")
+@MainActor
+struct UsageBreakdownChartMenuViewTests {
+ @Test
+ func `valid totals remain visible when service rows are absent`() {
+ #expect(
+ UsageBreakdownChartMenuView.presentationState(
+ hasSummary: true,
+ hasChartPoints: false) == .totalsOnly)
+ }
+
+ @Test
+ func `service rows select the chart presentation`() {
+ #expect(
+ UsageBreakdownChartMenuView.presentationState(
+ hasSummary: true,
+ hasChartPoints: true) == .chart)
+ }
+
+ @Test
+ func `missing totals and service rows select the empty presentation`() {
+ #expect(
+ UsageBreakdownChartMenuView.presentationState(
+ hasSummary: false,
+ hasChartPoints: false) == .empty)
+ }
+}
diff --git a/Tests/CodexBarTests/UsageChartScaleTests.swift b/Tests/CodexBarTests/UsageChartScaleTests.swift
new file mode 100644
index 0000000000..9d144a8cac
--- /dev/null
+++ b/Tests/CodexBarTests/UsageChartScaleTests.swift
@@ -0,0 +1,23 @@
+import CodexBarCore
+import Testing
+
+struct UsageChartScaleTests {
+ @Test
+ func `sub dollar maximum fills the chart`() {
+ let scale = UsageChartScale(values: [0.10, 0.25, 0.50])
+
+ #expect(scale.maximum == 0.50)
+ #expect(scale.fraction(for: 0.50) == 1)
+ #expect(scale.fraction(for: 0.25) == 0.5)
+ }
+
+ @Test
+ func `scale ignores invalid and nonpositive values`() {
+ let scale = UsageChartScale(values: [.nan, .infinity, -10, 0, 4])
+
+ #expect(scale.maximum == 4)
+ #expect(scale.fraction(for: .nan) == 0)
+ #expect(scale.fraction(for: -1) == 0)
+ #expect(scale.fraction(for: 8) == 1)
+ }
+}
diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift
index 09a9e00ee5..e3d82a9772 100644
--- a/Tests/CodexBarTests/UsageFormatterTests.swift
+++ b/Tests/CodexBarTests/UsageFormatterTests.swift
@@ -54,6 +54,12 @@ struct UsageFormatterTests {
#expect(UsageFormatter.percentString(1) == "1%")
#expect(UsageFormatter.percentString(101) == "100%")
#expect(UsageFormatter.usageLine(remaining: 99.9, used: 0.1, showUsed: true) == "<1% used")
+ // Values in (0.5, 1) round up to "1%" under %.0f, so the old post-format
+ // "0%" -> "<1%" replacement missed them. percentText must show "<1%"
+ // across the whole sub-1% range, matching percentString above.
+ #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "<1% used")
+ #expect(UsageFormatter.usageLine(remaining: 99.25, used: 0.75, showUsed: true) == "<1% used")
+ #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "<1% left")
let usedWindow = RateWindow(usedPercent: 0.1, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
let leftWindow = RateWindow(usedPercent: 99.9, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
@@ -66,6 +72,7 @@ struct UsageFormatterTests {
UsageFormatter.setLocalizationProvider { key in
switch key {
case "%.0f%% %@": "%2$@ %1$.0f%%"
+ case "<1%% %@": "%1$@ <1%%"
case "usage_percent_suffix_left": "剩余"
case "usage_percent_suffix_used": "已使用"
default: key
@@ -75,6 +82,8 @@ struct UsageFormatterTests {
#expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "剩余 22%")
#expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "已使用 78%")
+ #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "剩余 <1%")
+ #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "已使用 <1%")
}
@Test
@@ -214,12 +223,40 @@ struct UsageFormatterTests {
}
@Test
- func `reset countdown days and hours`() {
+ func `reset countdown caps days with hours at two units`() {
let now = Date(timeIntervalSince1970: 1_000_000)
- let reset = now.addingTimeInterval((26 * 3600) + 10)
+ let reset = now.addingTimeInterval((26 * 3600) + (1 * 60))
#expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h")
}
+ @Test
+ func `reset countdown days and exact hours`() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let reset = now.addingTimeInterval(26 * 3600)
+ #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h")
+ }
+
+ @Test
+ func `reset countdown days and minutes without whole hours`() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let reset = now.addingTimeInterval((24 * 3600) + (5 * 60))
+ #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 5m")
+ }
+
+ @Test
+ func `reset countdown exact days`() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let reset = now.addingTimeInterval(2 * 24 * 3600)
+ #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 2d")
+ }
+
+ @Test
+ func `reset countdown rounds the last minute into a day`() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let reset = now.addingTimeInterval((24 * 3600) - 59)
+ #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d")
+ }
+
@Test
func `reset countdown exact hour`() {
let now = Date(timeIntervalSince1970: 1_000_000)
@@ -344,6 +381,16 @@ struct UsageFormatterTests {
#expect(result == "$0.00")
}
+ @Test(arguments: [
+ (0.0, "$0"),
+ (0.50, "$0.50"),
+ (12.56, "$13"),
+ (1515.0, "$1,515"),
+ ])
+ func `compact currency keeps cents only below one unit`(value: Double, expected: String) {
+ #expect(UsageFormatter.compactCurrencyString(value, currencyCode: "USD") == expected)
+ }
+
@Test
func `currency string handles non USD currencies`() {
// FormatStyle handles all currencies with proper symbols
diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift
index 8e3bc88b2c..69c39d9815 100644
--- a/Tests/CodexBarTests/UsagePaceTextTests.swift
+++ b/Tests/CodexBarTests/UsagePaceTextTests.swift
@@ -299,6 +299,35 @@ struct UsagePaceTextTests {
#expect(detail?.rightLabel == "Projected empty in 45m")
}
+ @Test
+ func `session pace detail supports Antigravity five hour window`() {
+ let now = Date(timeIntervalSince1970: 0)
+ let window = RateWindow(
+ usedPercent: 80,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(2 * 3600),
+ resetDescription: nil)
+
+ let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now)
+
+ #expect(detail?.leftLabel == "20% in deficit")
+ #expect(detail?.rightLabel == "Projected empty in 45m")
+ }
+
+ @Test
+ func `session pace detail hides Antigravity weekly window`() {
+ let now = Date(timeIntervalSince1970: 0)
+ let window = RateWindow(
+ usedPercent: 80,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(2 * 24 * 3600),
+ resetDescription: nil)
+
+ let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now)
+
+ #expect(detail == nil)
+ }
+
@Test
func `session pace detail hides Ollama window without explicit duration`() {
let now = Date(timeIntervalSince1970: 0)
diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift
index 5b0fb6590e..4a460d2a37 100644
--- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift
+++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift
@@ -98,6 +98,99 @@ struct UsageStoreCachedTokenHydrationTests {
#expect(store.tokenSnapshot(for: .codex) == nil)
}
+ @Test
+ func `fresh cached hydration suppresses the redundant startup token refresh`() async throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+
+ let now = Date()
+ try Self.writeCodexSessionFile(
+ homeRoot: env.codexHomeRoot,
+ env: env,
+ day: now,
+ filename: "cached.jsonl",
+ tokens: 42)
+
+ let options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ cacheRoot: env.cacheRoot)
+ _ = try await CostUsageFetcher.loadTokenSnapshot(
+ provider: .codex,
+ now: now,
+ historyDays: 1,
+ scannerOptions: options)
+
+ let settings = Self.makeCodexOnlySettings(historyDays: 1)
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ costUsageFetcher: CostUsageFetcher(scannerOptions: options),
+ settings: settings,
+ startupBehavior: .testing,
+ environmentBase: [:])
+ var tokenRefreshCount = 0
+ store._test_tokenUsageRefreshOverride = { _, _ in tokenRefreshCount += 1 }
+
+ store.hydrateCachedTokenSnapshots(now: now)
+ for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+
+ await store.refreshTokenUsageNow(for: .codex, force: false)
+
+ #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42)
+ #expect(store.tokenLastAttemptAt(for: .codex).map { abs($0.timeIntervalSince(now)) < 0.001 } == true)
+ #expect(tokenRefreshCount == 0)
+ }
+
+ @Test
+ func `stale cached hydration still allows the startup token refresh`() async throws {
+ let env = try CostUsageTestEnvironment()
+ defer { env.cleanup() }
+
+ let now = Date()
+ try Self.writeCodexSessionFile(
+ homeRoot: env.codexHomeRoot,
+ env: env,
+ day: now,
+ filename: "cached.jsonl",
+ tokens: 42)
+
+ let options = CostUsageScanner.Options(
+ codexSessionsRoot: env.codexSessionsRoot,
+ cacheRoot: env.cacheRoot)
+ _ = try await CostUsageFetcher.loadTokenSnapshot(
+ provider: .codex,
+ now: now,
+ historyDays: 1,
+ scannerOptions: options)
+ var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
+ cache.lastScanUnixMs = Int64(now.addingTimeInterval(-2 * 60 * 60).timeIntervalSince1970 * 1000)
+ CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot)
+
+ let settings = Self.makeCodexOnlySettings(historyDays: 1)
+ let store = UsageStore(
+ fetcher: UsageFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ costUsageFetcher: CostUsageFetcher(scannerOptions: options),
+ settings: settings,
+ startupBehavior: .testing,
+ environmentBase: [:])
+ var tokenRefreshCount = 0
+ store._test_tokenUsageRefreshOverride = { _, _ in tokenRefreshCount += 1 }
+
+ store.hydrateCachedTokenSnapshots(now: now)
+ for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+
+ await store.refreshTokenUsageNow(for: .codex, force: false)
+
+ #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42)
+ #expect(store.tokenLastAttemptAt(for: .codex) != nil)
+ #expect(tokenRefreshCount == 1)
+ }
+
private static func makeCodexOnlySettings(historyDays: Int) -> SettingsStore {
let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift
index 5b6a7a2015..b3dd8e0d58 100644
--- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift
+++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift
@@ -85,7 +85,7 @@ struct UsageStoreHighestUsageTests {
}
@Test
- func `automatic metric uses secondary for kimi when ranking highest usage`() {
+ func `automatic metric uses rate limit for kimi when ranking highest usage`() {
let settings = SettingsStore(
configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-kimi-automatic"),
zaiTokenStore: NoopZaiTokenStore(),
@@ -111,7 +111,7 @@ struct UsageStoreHighestUsageTests {
updatedAt: Date())
let kimiSnapshot = UsageSnapshot(
primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
- secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
updatedAt: Date())
store._setSnapshotForTesting(codexSnapshot, provider: .codex)
diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift
new file mode 100644
index 0000000000..f321f45b33
--- /dev/null
+++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift
@@ -0,0 +1,305 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+struct UsageStorePlanUtilizationClaudeIdentityBoundaryTests {
+ @MainActor
+ @Test
+ func `claude history without identity falls back to last resolved account`() async {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: "alice@example.com",
+ accountOrganization: nil,
+ loginMethod: "max"))
+ store._setSnapshotForTesting(snapshot, provider: .claude)
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: snapshot,
+ now: Date(timeIntervalSince1970: 1_700_000_000))
+
+ let identitylessSnapshot = UsageSnapshot(
+ primary: snapshot.primary,
+ secondary: snapshot.secondary,
+ updatedAt: snapshot.updatedAt)
+ store._setSnapshotForTesting(identitylessSnapshot, provider: .claude)
+
+ let history = store.planUtilizationHistory(for: .claude)
+ #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10)
+ #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20)
+ }
+
+ @MainActor
+ @Test
+ func `established account accepts same owner after access token rotation`() async throws {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let owner = String(repeating: "a", count: 64)
+ let accountIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")
+ let start = Date(timeIntervalSince1970: 1_700_000_000)
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.snapshot(usedPercent: 30),
+ claudeOAuthPersistentRefHash: "account-a-ref",
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity),
+ isClaudeOAuthSample: true,
+ now: start)
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.snapshot(usedPercent: 30),
+ claudeOAuthPersistentRefHash: "account-a-ref",
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity),
+ isClaudeOAuthSample: true,
+ now: start.addingTimeInterval(30 * 60))
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.snapshot(usedPercent: 50),
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthKeychainCredentialMismatch: true,
+ claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity),
+ isClaudeOAuthSample: true,
+ now: start.addingTimeInterval(2 * 60 * 60))
+
+ let key = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner))
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [30, 50])
+ }
+
+ @MainActor
+ @Test
+ func `first sighting without keychain match is quarantined`() async {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 90,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: snapshot,
+ claudeOAuthHistoryOwnerIdentifier: String(repeating: "s", count: 64),
+ claudeOAuthKeychainCredentialMismatch: true,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")),
+ isClaudeOAuthSample: true)
+
+ #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty)
+ #expect(store.planUtilizationHistory[.claude] == nil)
+ }
+
+ @MainActor
+ @Test
+ func `file backed owner records history when keychain comparison is unavailable`() async throws {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let owner = String(repeating: "e", count: 64)
+ let key = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner))
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.snapshot(usedPercent: 90),
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthKeychainCredentialUnavailable: true,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")),
+ isClaudeOAuthSample: true)
+
+ #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty)
+ #expect(UsageStore.loadClaudeOAuthAccountBindingCandidateMap(
+ from: store.settings.userDefaults).isEmpty)
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [90])
+ }
+
+ @MainActor
+ @Test
+ func `absent keychain still quarantines an owner bound to another account`() async {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let owner = String(repeating: "f", count: 64)
+ store.persistClaudeOAuthAccountUuidMap([
+ owner: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A"),
+ ])
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.snapshot(usedPercent: 90),
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthKeychainCredentialAbsent: true,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")),
+ isClaudeOAuthSample: true)
+
+ #expect(store.planUtilizationHistory[.claude] == nil)
+ }
+
+ @MainActor
+ @Test
+ func `absent keychain records an unbound file owner`() async throws {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let owner = String(repeating: "b", count: 64)
+ let key = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner))
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.snapshot(usedPercent: 80),
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthKeychainCredentialAbsent: true,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")),
+ isClaudeOAuthSample: true)
+
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [80])
+ }
+
+ @MainActor
+ @Test
+ func `account change during identity capture cannot bind or write history`() async {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 90,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: snapshot,
+ claudeOAuthPersistentRefHash: "account-a-ref",
+ claudeOAuthHistoryOwnerIdentifier: String(repeating: "r", count: 64),
+ claudeOAuthActiveAccountObservation: .changed,
+ isClaudeOAuthSample: true)
+
+ #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty)
+ #expect(store.planUtilizationHistory[.claude] == nil)
+ }
+
+ @MainActor
+ @Test
+ func `missing active account identity preserves owner scoped history`() async throws {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let owner = String(repeating: "c", count: 64)
+ let key = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner))
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 35,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+
+ await UsageStore.withActiveClaudeAccountUuidForTesting(nil) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: snapshot,
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthActiveAccountObservation: .stable(identity: nil),
+ isClaudeOAuthSample: true)
+ }
+
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [35])
+ }
+
+ @MainActor
+ @Test
+ func `explicit oauth credential ignores Claude Code account identity`() async throws {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let owner = String(repeating: "d", count: 64)
+ let key = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner))
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 45,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+
+ await UsageStore.withActiveClaudeAccountUuidForTesting("claude-code-account") {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: snapshot,
+ claudeOAuthHistoryOwnerIdentifier: owner,
+ claudeOAuthActiveAccountObservation: .changed,
+ isClaudeOAuthSample: true)
+ }
+
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [45])
+ #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty)
+ }
+
+ @Test
+ func `claude oauth history scope requires full auth fingerprint stability`() {
+ let stablePersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting(
+ beforeFetchFingerprintToken: "stable-fingerprint",
+ afterFetchFingerprintToken: "stable-fingerprint",
+ beforeFetchPersistentRefHash: "stable-ref",
+ afterFetchPersistentRefHash: "stable-ref")
+ let changedFingerprintPersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting(
+ beforeFetchFingerprintToken: "before-fingerprint",
+ afterFetchFingerprintToken: "after-fingerprint",
+ beforeFetchPersistentRefHash: "stable-ref",
+ afterFetchPersistentRefHash: "stable-ref")
+
+ #expect(stablePersistentRefHash == "stable-ref")
+ #expect(changedFingerprintPersistentRefHash == nil)
+ }
+
+ @Test
+ func `credential change around account read invalidates the observation`() {
+ let identityA = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")
+ let identityB = UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")
+ let stable = UsageStore._claudeOAuthActiveAccountObservationForTesting(
+ identityBeforeFetch: identityB,
+ identityAfterFetch: identityB)
+ let changed = UsageStore._claudeOAuthActiveAccountObservationForTesting(
+ identityBeforeFetch: identityA,
+ identityAfterFetch: identityB)
+ let unstable = UsageStore._claudeOAuthActiveAccountObservationForTesting(
+ identityBeforeFetch: identityB,
+ identityAfterFetch: identityB,
+ beforeFetchWasStable: false)
+
+ #expect(stable == .stable(identity: identityB))
+ #expect(changed == .changed)
+ #expect(unstable == .changed)
+ }
+
+ private func snapshot(usedPercent: Double) -> UsageSnapshot {
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+ }
+}
diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift
index 1cc2fef476..b3e4ecf56c 100644
--- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift
+++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift
@@ -99,37 +99,6 @@ struct UsageStorePlanUtilizationClaudeIdentityTests {
#expect(buckets.accounts[aliceKey] == [bootstrap])
}
- @MainActor
- @Test
- func `claude history without identity falls back to last resolved account`() async {
- let store = UsageStorePlanUtilizationTests.makeStore()
- let snapshot = UsageSnapshot(
- primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
- secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
- updatedAt: Date(),
- identity: ProviderIdentitySnapshot(
- providerID: .claude,
- accountEmail: "alice@example.com",
- accountOrganization: nil,
- loginMethod: "max"))
- store._setSnapshotForTesting(snapshot, provider: .claude)
-
- await store.recordPlanUtilizationHistorySample(
- provider: .claude,
- snapshot: snapshot,
- now: Date(timeIntervalSince1970: 1_700_000_000))
-
- let identitylessSnapshot = UsageSnapshot(
- primary: snapshot.primary,
- secondary: snapshot.secondary,
- updatedAt: snapshot.updatedAt)
- store._setSnapshotForTesting(identitylessSnapshot, provider: .claude)
-
- let history = store.planUtilizationHistory(for: .claude)
- #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10)
- #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20)
- }
-
@MainActor
@Test
func `claude oauth credential owner separates switched account history`() async throws {
@@ -297,6 +266,220 @@ struct UsageStorePlanUtilizationClaudeIdentityTests {
.entries.map(\.usedPercent) == [70])
}
+ @MainActor
+ @Test
+ func `same dir account switch quarantines stale background credential`() async throws {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let ownerA = self.oauthOwnerIdentifier("a")
+ let ownerB = self.oauthOwnerIdentifier("b")
+ let keyA = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerA))
+ let keyB = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerB))
+ let hourStart = Date(timeIntervalSince1970: 1_700_000_000)
+
+ // 1) Active account A (~/.claude.json = uuid-A). Two stable exact-Keychain observations bind owner_A
+ // to A; the short confirmation interval does not add a second history point.
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") {
+ await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 30),
+ claudeOAuthPersistentRefHash: "account-a-ref",
+ claudeOAuthHistoryOwnerIdentifier: ownerA,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")),
+ isClaudeOAuthSample: true,
+ now: hourStart)
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 30),
+ claudeOAuthPersistentRefHash: "account-a-ref",
+ claudeOAuthHistoryOwnerIdentifier: ownerA,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")),
+ isClaudeOAuthSample: true,
+ now: hourStart.addingTimeInterval(30 * 60))
+ }
+ }
+
+ do {
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [30])
+ #expect(buckets.unscoped.isEmpty)
+ }
+
+ // 2) `/login` switched the active account to B (~/.claude.json = uuid-B), but the gated BACKGROUND
+ // poll still serves the STALE owner_A credential. Prompt-free Keychain comparison is unavailable,
+ // but the existing owner_A -> uuid-A binding detects the mismatch, so this must be quarantined:
+ // no new sample lands.
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 90),
+ claudeOAuthPersistentRefHash: nil,
+ claudeOAuthHistoryOwnerIdentifier: ownerA,
+ claudeOAuthKeychainCredentialUnavailable: true,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")),
+ isClaudeOAuthSample: true,
+ now: hourStart.addingTimeInterval(2 * 60 * 60))
+ }
+ }
+
+ do {
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ // key_A history is UNCHANGED (the stale sample was dropped, not appended).
+ #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [30])
+ // Critically, the quarantined sample did NOT leak into the shared unscoped bucket. This is the
+ // assertion that catches the naive-nil bug (nil accountKey writes to `unscoped`, not nowhere).
+ #expect(buckets.unscoped.isEmpty)
+ #expect(buckets.accounts[keyB] == nil)
+ }
+
+ // 3) A USER-INITIATED refresh yields account B's real credential (owner_B), with exact current-Keychain
+ // match evidence. Recovery: bind owner_B -> uuid-B, write to key_B, and leave key_A untouched.
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") {
+ await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 75),
+ claudeOAuthPersistentRefHash: "account-b-ref",
+ claudeOAuthHistoryOwnerIdentifier: ownerB,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")),
+ isClaudeOAuthSample: true,
+ now: hourStart.addingTimeInterval(3 * 60 * 60))
+ }
+ }
+
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[keyB] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [75])
+ #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [30])
+ #expect(buckets.unscoped.isEmpty)
+ }
+
+ @MainActor
+ @Test
+ func `exact keychain match arms account map on background poll`() async throws {
+ let store = UsageStorePlanUtilizationTests.makeStore()
+ let ownerA = self.oauthOwnerIdentifier("a")
+ let ownerB = self.oauthOwnerIdentifier("b")
+ let keyA = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerA))
+ let keyB = try #require(
+ UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerB))
+ let hourStart = Date(timeIntervalSince1970: 1_700_000_000)
+
+ // 1) First run after upgrading: the map is empty. A background poll serves owner_A while
+ // ~/.claude.json reports uuid-A, and exact current-Keychain evidence corroborates the credential.
+ // The first observation stages a candidate; a later identical observation confirms the binding.
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 30),
+ claudeOAuthPersistentRefHash: "account-a-ref",
+ claudeOAuthHistoryOwnerIdentifier: ownerA,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")),
+ isClaudeOAuthSample: true,
+ now: hourStart)
+ }
+ }
+
+ let accountAIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")
+ let accountBIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")
+ #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)[ownerA] == nil)
+ #expect(UsageStore.loadClaudeOAuthAccountBindingCandidateMap(
+ from: store.settings.userDefaults)[ownerA]?.identity == accountAIdentity)
+
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 30),
+ claudeOAuthPersistentRefHash: "account-a-ref",
+ claudeOAuthHistoryOwnerIdentifier: ownerA,
+ claudeOAuthActiveAccountObservation: .stable(identity: accountAIdentity),
+ isClaudeOAuthSample: true,
+ now: hourStart.addingTimeInterval(30 * 60))
+ }
+ }
+ #expect(UsageStore.loadClaudeOAuthAccountUuidMap(
+ from: store.settings.userDefaults)[ownerA] == accountAIdentity)
+
+ // 2) `/login` switched to account B (~/.claude.json = uuid-B), but the gated BACKGROUND poll still
+ // serves the stale owner_A credential. It no longer matches the current Keychain item, and the
+ // existing owner_A -> uuid-A binding detects the UUID mismatch, so the sample is quarantined.
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 90),
+ claudeOAuthPersistentRefHash: nil,
+ claudeOAuthHistoryOwnerIdentifier: ownerA,
+ claudeOAuthKeychainCredentialMismatch: true,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")),
+ isClaudeOAuthSample: true,
+ now: hourStart.addingTimeInterval(2 * 60 * 60))
+ }
+ }
+
+ do {
+ let mapAfterBackground = UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)
+ #expect(mapAfterBackground[ownerA] == accountAIdentity)
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [30])
+ #expect(buckets.unscoped.isEmpty)
+ }
+
+ // 3) A background poll now yields account B's real credential (owner_B) with exact current-Keychain
+ // match evidence. That evidence, not the interaction label, binds owner_B -> uuid-B and writes key_B.
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 75),
+ claudeOAuthPersistentRefHash: "account-b-ref",
+ claudeOAuthHistoryOwnerIdentifier: ownerB,
+ claudeOAuthActiveAccountObservation: .stable(
+ identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")),
+ isClaudeOAuthSample: true,
+ now: hourStart.addingTimeInterval(3 * 60 * 60))
+ }
+ }
+
+ #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)[ownerB] == nil)
+ await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 75),
+ claudeOAuthPersistentRefHash: "account-b-ref",
+ claudeOAuthHistoryOwnerIdentifier: ownerB,
+ claudeOAuthActiveAccountObservation: .stable(identity: accountBIdentity),
+ isClaudeOAuthSample: true,
+ now: hourStart.addingTimeInterval(3 * 60 * 60 + 30 * 60))
+ }
+ }
+
+ let mapAfterRecovery = UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)
+ #expect(mapAfterRecovery[ownerB] == accountBIdentity)
+ #expect(mapAfterRecovery[ownerA] == accountAIdentity)
+ let buckets = try #require(store.planUtilizationHistory[.claude])
+ #expect(findSeries(buckets.accounts[keyB] ?? [], name: .session, windowMinutes: 300)?
+ .entries.map(\.usedPercent) == [75])
+ #expect(buckets.unscoped.isEmpty)
+ }
+
@MainActor
@Test
func `coalesced claude oauth sample without owner cannot switch preferred account`() async throws {
@@ -470,18 +653,20 @@ struct UsageStorePlanUtilizationClaudeIdentityTests {
let accountBKey = try #require(UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(
historyOwnerIdentifier: accountBOwner))
- await store.recordPlanUtilizationHistorySample(
- provider: .claude,
- snapshot: self.identitylessClaudeSnapshot(usedPercent: 10),
- claudeOAuthHistoryOwnerIdentifier: accountAOwner,
- isClaudeOAuthSample: true,
- now: Date(timeIntervalSince1970: 1_700_000_000))
- await store.recordPlanUtilizationHistorySample(
- provider: .claude,
- snapshot: self.identitylessClaudeSnapshot(usedPercent: 75),
- claudeOAuthHistoryOwnerIdentifier: accountBOwner,
- isClaudeOAuthSample: true,
- now: Date(timeIntervalSince1970: 1_700_007_200))
+ await UsageStore.withActiveClaudeAccountUuidForTesting(nil) {
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 10),
+ claudeOAuthHistoryOwnerIdentifier: accountAOwner,
+ isClaudeOAuthSample: true,
+ now: Date(timeIntervalSince1970: 1_700_000_000))
+ await store.recordPlanUtilizationHistorySample(
+ provider: .claude,
+ snapshot: self.identitylessClaudeSnapshot(usedPercent: 75),
+ claudeOAuthHistoryOwnerIdentifier: accountBOwner,
+ isClaudeOAuthSample: true,
+ now: Date(timeIntervalSince1970: 1_700_007_200))
+ }
let buckets = try #require(store.planUtilizationHistory[.claude])
#expect(buckets.unscoped.isEmpty)
@@ -560,23 +745,6 @@ struct UsageStorePlanUtilizationClaudeIdentityTests {
#expect(first.dropFirst("__claude_oauth__:".count).count == 64)
}
- @Test
- func `claude oauth history scope requires full auth fingerprint stability`() {
- let stablePersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting(
- beforeFetchFingerprintToken: "stable-fingerprint",
- afterFetchFingerprintToken: "stable-fingerprint",
- beforeFetchPersistentRefHash: "stable-ref",
- afterFetchPersistentRefHash: "stable-ref")
- let changedFingerprintPersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting(
- beforeFetchFingerprintToken: "before-fingerprint",
- afterFetchFingerprintToken: "after-fingerprint",
- beforeFetchPersistentRefHash: "stable-ref",
- afterFetchPersistentRefHash: "stable-ref")
-
- #expect(stablePersistentRefHash == "stable-ref")
- #expect(changedFingerprintPersistentRefHash == nil)
- }
-
@Test
func `same claude email separates team and personal plan history keys`() throws {
let team = UsageSnapshot(
diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift
index b106855d95..502c315534 100644
--- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift
+++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift
@@ -5,6 +5,122 @@ import Testing
@MainActor
struct UsageStoreWidgetSnapshotTests {
+ @Test
+ func `widget snapshot preserves raw Codex windows for timeline projection`() async throws {
+ let suite = "UsageStoreWidgetSnapshotTests-codex-weekly-cap"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ let now = Date()
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 1,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(1800),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 100,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil),
+ updatedAt: now.addingTimeInterval(-7200)),
+ provider: .codex)
+
+ var widgetSnapshots: [WidgetSnapshot] = []
+ store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) }
+ defer { store._test_widgetSnapshotSaveOverride = nil }
+
+ store.persistWidgetSnapshot(reason: "codex-weekly-cap-test")
+ await store.widgetSnapshotPersistTask?.value
+
+ let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .codex })
+ #expect(entry.usageRows?.map(\.id) == ["session", "weekly"])
+ #expect(entry.usageRows?.compactMap(\.percentLeft) == [99, 0])
+ #expect(entry.usageRows?.first?.window?.usedPercent == 1)
+ #expect(entry.usageRows?.last?.window?.resetsAt == now.addingTimeInterval(3600))
+ }
+
+ @Test
+ func `widget snapshot includes Kimi subscription quota rows`() async throws {
+ let suite = "UsageStoreWidgetSnapshotTests-kimi-subscription-rows"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ tertiary: nil,
+ extraRateWindows: [
+ NamedRateWindow(
+ id: "kimi-code-7d",
+ title: "Code 7-day",
+ window: RateWindow(
+ usedPercent: 10,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: nil,
+ resetDescription: nil)),
+ NamedRateWindow(
+ id: "kimi-future-quota",
+ title: "Future quota",
+ window: RateWindow(
+ usedPercent: 5,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: nil)),
+ NamedRateWindow(
+ id: "kimi-monthly",
+ title: "Monthly",
+ window: RateWindow(
+ usedPercent: 75,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: nil)),
+ ],
+ updatedAt: Date(),
+ identity: ProviderIdentitySnapshot(
+ providerID: .kimi,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil))
+ store._setSnapshotForTesting(snapshot, provider: .kimi)
+
+ var widgetSnapshots: [WidgetSnapshot] = []
+ store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) }
+ defer { store._test_widgetSnapshotSaveOverride = nil }
+
+ store.persistWidgetSnapshot(reason: "kimi-subscription-rows-test")
+ await store.widgetSnapshotPersistTask?.value
+
+ let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .kimi })
+ // Widgets preserve persisted lane order; menu-only presentation may reorder these lanes.
+ #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"])
+ #expect(entry.usageRows?.map(\.title) == ["Weekly", "Rate Limit", "Monthly", "Code 7-day"])
+ #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25, 90])
+ }
+
@Test
func `widget snapshot includes antigravity grouped usage rows`() async throws {
let suite = "UsageStoreWidgetSnapshotTests-antigravity-grouped"
@@ -252,4 +368,52 @@ struct UsageStoreWidgetSnapshotTests {
#expect(entry.tokenUsage?.sessionTokens == 4200)
#expect(entry.tokenUsage?.last30DaysTokens == 42000)
}
+
+ @Test(arguments: [true, false])
+ func `widget snapshot respects extra usage visibility for Devin`(_ showsExtraUsage: Bool) async throws {
+ let suite = "UsageStoreWidgetSnapshotTests-devin-extra-usage-\(showsExtraUsage)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+ settings.showOptionalCreditsAndExtraUsage = showsExtraUsage
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ let updatedAt = Date(timeIntervalSince1970: 1_800_000_000)
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ providerCost: ProviderCostSnapshot(
+ used: 48,
+ limit: 0,
+ currencyCode: "USD",
+ period: "Extra usage balance",
+ updatedAt: updatedAt),
+ updatedAt: updatedAt,
+ identity: ProviderIdentitySnapshot(
+ providerID: .devin,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)),
+ provider: .devin)
+
+ var widgetSnapshots: [WidgetSnapshot] = []
+ store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) }
+ defer { store._test_widgetSnapshotSaveOverride = nil }
+
+ store.persistWidgetSnapshot(reason: "devin-extra-usage-visibility-test")
+ await store.widgetSnapshotPersistTask?.value
+
+ let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .devin })
+ #expect((entry.providerCost != nil) == showsExtraUsage)
+ }
}
diff --git a/TestsLinux/CLICardsRendererTests.swift b/TestsLinux/CLICardsRendererTests.swift
new file mode 100644
index 0000000000..10040de896
--- /dev/null
+++ b/TestsLinux/CLICardsRendererTests.swift
@@ -0,0 +1,615 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBarCLI
+
+struct CLICardsRendererTests {
+ @Test
+ func `computes column count from terminal width`() {
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 80) == 2)
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 120) == 3)
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 160) == 4)
+ #expect(CLICardsRenderer.columnCount(terminalWidth: 30) == 1)
+ }
+
+ @Test
+ func `renders single codex card without color`() {
+ let identity = ProviderIdentitySnapshot(
+ providerID: .codex,
+ accountEmail: "user@example.com",
+ accountOrganization: nil,
+ loginMethod: "pro")
+ let snapshot = UsageSnapshot(
+ primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: "today at 3:00 PM"),
+ secondary: .init(usedPercent: 25, windowMinutes: 10080, resetsAt: nil, resetDescription: "Fri at 9:00 AM"),
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0),
+ identity: identity)
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()),
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .absolute,
+ weeklyWorkDays: nil,
+ now: Date()))
+
+ let output = CLICardsRenderer.render(cards: [card], failures: [], terminalWidth: 80, useColor: false)
+
+ #expect(output.contains("Codex"))
+ #expect(output.contains("[oauth]"))
+ #expect(output.contains("PLAN Pro 20x"))
+ #expect(output.contains("Session"))
+ #expect(output.contains("88% left"))
+ #expect(output.contains("[ "))
+ #expect(output.contains("━"))
+ #expect(output.contains("Credits:"))
+ #expect(output.contains("42 left"))
+ #expect(output.contains("@ user@example.com"))
+ #expect(output.contains("╰"))
+ }
+
+ @Test
+ func `card includes account line`() {
+ let identity = ProviderIdentitySnapshot(
+ providerID: .codex,
+ accountEmail: "user@example.com",
+ accountOrganization: nil,
+ loginMethod: "pro")
+ let snapshot = UsageSnapshot(
+ primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0),
+ identity: identity)
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: nil,
+ source: "cli",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .absolute,
+ weeklyWorkDays: nil,
+ now: Date()))
+
+ let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false)
+ let joined = lines.joined(separator: "\n")
+
+ #expect(joined.contains("@ user@example.com"))
+ #expect(joined.contains("Session"))
+ #expect(!joined.contains("Plan: Pro 20x"))
+ }
+
+ @Test
+ func `renders two card grid at fixed width`() {
+ let codex = CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let claude = CLICardModel(
+ provider: .claude,
+ title: "Claude",
+ sourceLabel: "web",
+ planBadge: "Max",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+
+ let output = CLICardsRenderer.render(cards: [codex, claude], failures: [], terminalWidth: 120, useColor: false)
+
+ #expect(output.contains("Codex"))
+ #expect(output.contains("Claude"))
+ #expect(output.contains("88% left"))
+ #expect(output.contains("50% left"))
+ #expect(output.components(separatedBy: "╰").count >= 3)
+ }
+
+ @Test
+ func `renders failure footer without cards`() {
+ let failures = [
+ CLICardFailure(provider: .cursor, accountLabel: nil, message: "not configured"),
+ ]
+ let output = CLICardsRenderer.render(cards: [], failures: failures, terminalWidth: 80, useColor: false)
+
+ #expect(output.contains("Failed providers:"))
+ #expect(output.contains("Cursor: not configured"))
+ }
+
+ @Test
+ func `appends failure footer after successful cards`() {
+ let card = CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let failures = [
+ CLICardFailure(provider: .grok, accountLabel: nil, message: "timeout"),
+ ]
+
+ let output = CLICardsRenderer.render(cards: [card], failures: failures, terminalWidth: 80, useColor: false)
+
+ #expect(output.contains("88% left"))
+ #expect(output.contains("Failed providers:"))
+ #expect(output.contains("Grok: timeout"))
+ }
+
+ @Test
+ func `brief mode renders usage table`() {
+ let card = CLICardModel(
+ provider: .claude,
+ title: "Claude",
+ sourceLabel: "web",
+ planBadge: "Max",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 2, resetText: "⏳ Resets in 1h 49m")],
+ extraLines: [],
+ statusLine: nil)
+ let rows = CLICardsBriefRenderer.makeRows(cards: [card])
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(output.contains("codexbar • AI Usage & Limits"))
+ #expect(output.contains("Provider"))
+ #expect(output.contains("Claude"))
+ #expect(output.contains("web"))
+ #expect(output.contains("Max"))
+ #expect(output.contains("98%"))
+ #expect(output.contains("█"))
+ #expect(output.contains("1h 49m"))
+ #expect(output.contains("⚠ Warnings:"))
+ let tableLine = output.split(separator: "\n").first { $0.hasPrefix("┌") } ?? ""
+ #expect(tableLine.count >= 50)
+ #expect(tableLine.count <= 72)
+ }
+
+ @Test
+ func `synthetic quota lanes do not replace real brief usage`() {
+ let snapshot = UsageSnapshot(
+ primary: .init(
+ usedPercent: 0,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil,
+ isSyntheticPlaceholder: true),
+ secondary: .init(
+ usedPercent: 20,
+ windowMinutes: 10080,
+ resetsAt: nil,
+ resetDescription: nil),
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0))
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .claude,
+ snapshot: snapshot,
+ credits: nil,
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .countdown,
+ weeklyWorkDays: nil,
+ now: Date(timeIntervalSince1970: 0)))
+ let rows = CLICardsBriefRenderer.makeRows(cards: [card])
+
+ #expect(card.metrics.map(\.label) == ["Weekly"])
+ #expect(rows.first?.usedPercent == 20)
+ }
+
+ @Test
+ func `brief reset summary wraps to terminal width`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let card = CLICardModel(
+ provider: .alibabatokenplan,
+ title: "Alibaba Token Plan",
+ sourceLabel: "web",
+ planBadge: "International",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(
+ label: "Monthly budget",
+ remainingPercent: 50,
+ resetText: "⏳ Resets July 30 at 11:59 PM",
+ resetAt: now.addingTimeInterval(3600))],
+ extraLines: [],
+ statusLine: nil)
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: [card]),
+ failures: [],
+ terminalWidth: 40,
+ useColor: false,
+ now: now)
+ let lines = output.split(separator: "\n", omittingEmptySubsequences: false)
+
+ #expect(output.contains("Next reset: Alibaba Token Plan"))
+ #expect(lines.allSatisfy { $0.count <= 40 })
+ }
+
+ @Test
+ func `detail backed quota descriptions are not rendered as resets`() {
+ let snapshot = UsageSnapshot(
+ primary: .init(
+ usedPercent: 25,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "25/100 credits"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0))
+ let card = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .kilo,
+ snapshot: snapshot,
+ credits: nil,
+ source: "api",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .countdown,
+ weeklyWorkDays: nil,
+ now: Date(timeIntervalSince1970: 0)))
+
+ #expect(card.metrics.first?.resetText == nil)
+ #expect(card.metrics.first?.detailText == "25/100 credits")
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: [card]),
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+ #expect(!output.contains("Next reset"))
+ #expect(!output.contains("Reset 25/100 credits"))
+ }
+
+ @Test
+ func `card metrics honor reset display style`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let snapshot = UsageSnapshot(
+ primary: .init(
+ usedPercent: 25,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now)
+ let countdown = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: nil,
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .countdown,
+ weeklyWorkDays: nil,
+ now: now))
+ let absolute = CLICardsRenderer.makeCard(CLICardBuildInput(
+ provider: .codex,
+ snapshot: snapshot,
+ credits: nil,
+ source: "oauth",
+ status: nil,
+ notes: [],
+ useColor: false,
+ resetStyle: .absolute,
+ weeklyWorkDays: nil,
+ now: now))
+
+ #expect(countdown.metrics.first?.resetText != absolute.metrics.first?.resetText)
+ #expect(countdown.metrics.first?.resetText?.contains("in 1h") == true)
+ #expect(absolute.metrics.first?.resetAt == now.addingTimeInterval(3600))
+ }
+
+ @Test
+ func `long detail rows stay within card width`() {
+ let card = CLICardModel(
+ provider: .clawrouter,
+ title: "ClawRouter",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: ["Workspace: " + String(repeating: "long-name-", count: 12)],
+ metrics: [],
+ extraLines: [],
+ statusLine: nil)
+
+ let lines = CLICardsRenderer.renderCard(card, width: 38, useColor: true, enhanced: true)
+ #expect(lines.allSatisfy { TextParsing.stripANSICodes($0).count == 38 })
+ }
+
+ @Test
+ func `brief warnings name the actual quota metric`() {
+ let card = CLICardModel(
+ provider: .openrouter,
+ title: "OpenRouter",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Spend", remainingPercent: 10, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: [card]),
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(output.contains("OpenRouter Spend: 90% used"))
+ #expect(!output.contains("session limit"))
+ }
+
+ @Test
+ func `brief rows preserve account identity`() {
+ let cards = [
+ CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: "one@x.dev",
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 80, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: "two@x.dev",
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 60, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ ]
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: cards),
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+
+ #expect(output.contains("one@x.dev"))
+ #expect(output.contains("two@x.dev"))
+ }
+
+ @Test
+ func `brief warnings wrap to terminal width`() {
+ let cards = ["OpenRouter", "Antigravity", "CommandCode"].map { title in
+ CLICardModel(
+ provider: .openrouter,
+ title: title,
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Monthly budget", remainingPercent: 5, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ }
+
+ let output = CLICardsBriefRenderer.render(
+ rows: CLICardsBriefRenderer.makeRows(cards: cards),
+ failures: [],
+ terminalWidth: 40,
+ useColor: false,
+ now: Date(timeIntervalSince1970: 0))
+ let warningLines = output.split(separator: "\n").filter {
+ $0.contains("Warnings:") || $0.contains("% used")
+ }
+
+ #expect(warningLines.count > 1)
+ #expect(warningLines.allSatisfy { $0.count <= 40 })
+ }
+
+ @Test
+ func `brief summary ignores unparseable reset labels and fits narrow terminals`() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let rows = CLICardsBriefRenderer.makeRows(cards: [
+ CLICardModel(
+ provider: .kilo,
+ title: "Kilo",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Credits", remainingPercent: 75, resetText: "Reset Unlimited")],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(
+ label: "Session",
+ remainingPercent: 50,
+ resetText: "⏳ Resets in 5h",
+ resetAt: now.addingTimeInterval(5 * 3600))],
+ extraLines: [],
+ statusLine: nil),
+ ])
+
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 40,
+ useColor: false,
+ now: now)
+ let lines = output.split(separator: "\n", omittingEmptySubsequences: false)
+
+ #expect(output.contains("Next reset: Codex in 5h"))
+ #expect(!output.contains("Next reset: Kilo"))
+ #expect(lines.allSatisfy { $0.count <= 40 })
+ }
+
+ @Test
+ func `enhanced brief mode fills bars from used percentage`() {
+ let rows = CLICardsBriefRenderer.makeRows(cards: [
+ CLICardModel(
+ provider: .codex,
+ title: "Unused",
+ sourceLabel: "oauth",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ ])
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 80,
+ useColor: true,
+ enhanced: true,
+ now: Date(timeIntervalSince1970: 0))
+ let plainLines = TextParsing.stripANSICodes(output).split(separator: "\n")
+ let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "")
+ let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "")
+
+ #expect(unusedLine.contains("0%"))
+ #expect(unusedLine.filter { $0 == "█" }.isEmpty)
+ #expect(exhaustedLine.contains("100%"))
+ #expect(exhaustedLine.filter { $0 == "░" }.isEmpty)
+ }
+
+ @Test
+ func `standard brief mode fills bars from used percentage`() {
+ let rows = CLICardsBriefRenderer.makeRows(cards: [
+ CLICardModel(
+ provider: .codex,
+ title: "Unused",
+ sourceLabel: "oauth",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil),
+ ])
+ let output = CLICardsBriefRenderer.render(
+ rows: rows,
+ failures: [],
+ terminalWidth: 80,
+ useColor: false,
+ enhanced: false,
+ now: Date(timeIntervalSince1970: 0))
+ let plainLines = output.split(separator: "\n")
+ let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "")
+ let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "")
+
+ #expect(unusedLine.contains("0%"))
+ #expect(unusedLine.filter { $0 == "█" }.isEmpty)
+ #expect(exhaustedLine.contains("100%"))
+ #expect(exhaustedLine.filter { $0 == "░" }.isEmpty)
+ }
+
+ @Test
+ func `standard card grid shows empty remaining bar at exhaustion`() {
+ let card = CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false, enhanced: false)
+ let barLine = String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? "")
+ #expect(barLine.filter { $0 == "━" }.isEmpty)
+ }
+
+ @Test
+ func `enhanced card grid shows empty remaining bar at exhaustion`() {
+ let card = CLICardModel(
+ provider: .openrouter,
+ title: "Exhausted",
+ sourceLabel: "api",
+ planBadge: nil,
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: true, enhanced: true)
+ let plainBarLine = TextParsing.stripANSICodes(
+ String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? ""))
+ #expect(plainBarLine.filter { !$0.isWhitespace && $0 != "│" && $0 != "[" && $0 != "]" }.isEmpty)
+ }
+
+ @Test
+ func `enhanced mode uses truecolor gradient bars`() {
+ let card = CLICardModel(
+ provider: .codex,
+ title: "Codex",
+ sourceLabel: "oauth",
+ planBadge: "Pro",
+ accountLine: nil,
+ infoLines: [],
+ metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)],
+ extraLines: [],
+ statusLine: nil)
+ let output = CLICardsRenderer.render(
+ cards: [card],
+ failures: [],
+ terminalWidth: 80,
+ useColor: true,
+ enhanced: true)
+ #expect(output.contains("38;2;"))
+ #expect(output.contains("48;2;"))
+ #expect(output.contains("[ "))
+ }
+}
diff --git a/TestsLinux/CLITerminalCapabilitiesTests.swift b/TestsLinux/CLITerminalCapabilitiesTests.swift
new file mode 100644
index 0000000000..a8d01a6839
--- /dev/null
+++ b/TestsLinux/CLITerminalCapabilitiesTests.swift
@@ -0,0 +1,49 @@
+import Foundation
+import Testing
+@testable import CodexBarCLI
+
+struct CLITerminalCapabilitiesTests {
+ @Test
+ func `detects kitty graphics backend`() {
+ let env = ["KITTY_WINDOW_ID": "1", "TERM": "xterm-kitty"]
+ #expect(CLITerminalCapabilities.detect(environment: env) == .kittyGraphics)
+ #expect(CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env))
+ }
+
+ @Test
+ func `detects ghostty backend`() {
+ let env = ["GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty", "TERM": "xterm-ghostty"]
+ #expect(CLITerminalCapabilities.detect(environment: env) == .kittyGraphics)
+ }
+
+ @Test
+ func `detects truecolor without graphics env`() {
+ let env = ["COLORTERM": "truecolor", "TERM": "alacritty"]
+ #expect(CLITerminalCapabilities.detect(environment: env) == .truecolor)
+ }
+
+ @Test
+ func `respects forced enhanced env override`() {
+ let env = ["TERM": "dumb", "CODEXBAR_CARDS_ENHANCED": "1"]
+ #expect(CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env))
+ }
+
+ @Test
+ func `defaults cards to standard on plain ansi terminals`() {
+ let env = ["TERM": "xterm-256color"]
+ #expect(!CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env))
+ #expect(!CLITerminalCapabilities.supportsEnhancedCards(useColor: false, environment: env))
+ }
+
+ @Test
+ func `defaults cards to enhanced on truecolor terminals`() {
+ let env = ["TERM": "xterm-256color", "COLORTERM": "truecolor"]
+ #expect(CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env))
+ }
+
+ @Test
+ func `respects forced enhanced opt out`() {
+ let env = ["TERM": "xterm-256color", "CODEXBAR_CARDS_ENHANCED": "0"]
+ #expect(!CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env))
+ }
+}
diff --git a/TestsLinux/CursorLinuxTests.swift b/TestsLinux/CursorLinuxTests.swift
new file mode 100644
index 0000000000..f78acb1ba5
--- /dev/null
+++ b/TestsLinux/CursorLinuxTests.swift
@@ -0,0 +1,66 @@
+#if os(Linux)
+import Foundation
+import Testing
+@testable import CodexBarCLI
+@testable import CodexBarCore
+
+struct CursorLinuxTests {
+ @Test
+ func `Cursor database path honors absolute XDG config home`() {
+ let path = CursorAppAuthStore.resolveDefaultDBPath(
+ home: "/home/test",
+ environment: ["XDG_CONFIG_HOME": "/custom/config"])
+ #expect(path == "/custom/config/Cursor/User/globalStorage/state.vscdb")
+ }
+
+ @Test
+ func `Cursor database path falls back to dot config`() {
+ let path = CursorAppAuthStore.resolveDefaultDBPath(
+ home: "/home/test",
+ environment: [:])
+ #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb")
+ }
+
+ @Test
+ func `Cursor database path rejects relative XDG config home`() {
+ let path = CursorAppAuthStore.resolveDefaultDBPath(
+ home: "/home/test",
+ environment: ["XDG_CONFIG_HOME": "relative/config"])
+ #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb")
+ }
+
+ @Test
+ func `Cursor automatic source does not require macOS web support`() {
+ #expect(!CodexBarCLI.sourceModeRequiresWebSupport(
+ .auto,
+ provider: .cursor,
+ settings: ProviderSettingsSnapshot.make(
+ cursor: .init(cookieSource: .auto, manualCookieHeader: nil))))
+ }
+
+ @Test
+ func `Cursor descriptor accepts explicit web source`() {
+ #expect(CursorProviderDescriptor.descriptor.fetchPlan.sourceModes.contains(.web))
+ }
+
+ @Test
+ func `Cursor manual cookie does not require macOS web support`() {
+ #expect(!CodexBarCLI.sourceModeRequiresWebSupport(
+ .web,
+ provider: .cursor,
+ settings: ProviderSettingsSnapshot.make(
+ cursor: .init(
+ cookieSource: .manual,
+ manualCookieHeader: "WorkosCursorSessionToken=test"))))
+ }
+
+ @Test
+ func `disabled Cursor web source still requires macOS web support`() {
+ #expect(CodexBarCLI.sourceModeRequiresWebSupport(
+ .web,
+ provider: .cursor,
+ settings: ProviderSettingsSnapshot.make(
+ cursor: .init(cookieSource: .off, manualCookieHeader: nil))))
+ }
+}
+#endif
diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj
index f97efb4b69..ae91927b36 100644
--- a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj
+++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj
@@ -24,7 +24,6 @@
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 = ""; };
- 9FA0A78FB7CA1D877E7BA54B /* codexbar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = codexbar; path = ..; sourceTree = SOURCE_ROOT; };
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; };
@@ -48,7 +47,6 @@
4FAD1E2FCD6C4AC65D308ABC /* Packages */ = {
isa = PBXGroup;
children = (
- 9FA0A78FB7CA1D877E7BA54B /* codexbar */,
);
name = Packages;
sourceTree = "";
diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000000..37ef8e0663
--- /dev/null
+++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,77 @@
+{
+ "originHash" : "2c34a752ff5b5315e5bae001ccb84dbfa3f7ee7793a23b8862dbc8d096d771ba",
+ "pins" : [
+ {
+ "identity" : "commander",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/steipete/Commander",
+ "state" : {
+ "revision" : "b9fb00564aa3229deeb48090801fec2c185951f4",
+ "version" : "0.2.3"
+ }
+ },
+ {
+ "identity" : "keyboardshortcuts",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sindresorhus/KeyboardShortcuts",
+ "state" : {
+ "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
+ "version" : "2.4.0"
+ }
+ },
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
+ }
+ },
+ {
+ "identity" : "sweetcookiekit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/steipete/SweetCookieKit",
+ "state" : {
+ "revision" : "21bedea672a3e63ccad24d744051e76cdf0462dd",
+ "version" : "0.4.1"
+ }
+ },
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
+ "version" : "1.7.1"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
+ "version" : "3.15.1"
+ }
+ },
+ {
+ "identity" : "swift-log",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-log",
+ "state" : {
+ "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a",
+ "version" : "1.14.0"
+ }
+ },
+ {
+ "identity" : "vortex",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zats/Vortex",
+ "state" : {
+ "revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/appcast.xml b/appcast.xml
index 5f4a3a3ccd..7ea3d8b98e 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -3,126 +3,123 @@
CodexBar
-
- 0.38.0
- Fri, 03 Jul 2026 04:18:13 -0700
+ 0.41.0
+ Tue, 07 Jul 2026 00:45:58 +0100
https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml
- 94
- 0.38.0
+ 100
+ 0.41.0
14.0
- CodexBar 0.38.0
+ CodexBar 0.41.0
Added
-- Doubao: add signed Volcengine AK/SK support for Coding Plan session, weekly, and monthly usage. Thanks @LeoLin990405!
-- CrossModel: add API-key wallet balance and UTC daily, weekly, and monthly spend tracking. Thanks @hujuncheng!
-- Localization: complete Traditional Chinese provider and menu coverage, and route remaining provider UI copy through localized formatters. Thanks @jack24254029!
-- Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git!
-- Qoder: add big-model credit usage from qoder.com and qoder.com.cn browser sessions or manual cookies. Thanks @Yuxin-Qiao!
-- Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree!
-- Sakana AI: add manual-cookie usage for five-hour and weekly quota windows. Thanks @LeoLin990405!
-- Status pages: show live component submenus for Claude, Codex, and Augment. Thanks @elijahfriedman!
-- Cost history: choose inline, submenu, or combined local-cost presentation. Thanks @Zihao-Qi!
-- Confetti: optionally celebrate session-limit resets with full-screen confetti, configurable beside the weekly-limit celebration in Advanced settings. Thanks @bystritskiy!
-- z.ai: support saved token-account team usage with account-scoped organization and project metadata. Thanks @zqbake!
-- CLI: show session pace in text output, expose derived pace data in JSON, and honor the configured weekly work-day baseline. Thanks @kmatsunami!
-- Claude: add a combined "Session + Weekly" menu bar metric that shows the 5-hour session and weekly lanes together (paced on the weekly lane), matching Codex, and classify lanes by cadence so a weekly-only account is not mislabeled as a session. Thanks @Shengqiang-Zhang!
-
-Changed
-
-- Settings: complete redesign as a System Settings-style window — a sidebar lists app panes plus every provider (search, drag reorder, status dots, enable via context menu), panes use native grouped forms, the window keeps one size instead of resizing per tab, and the last selected pane is remembered across launches.
-- Menu: group Plan Usage, Cost, and Storage rows so related account usage is easier to scan. Thanks @Zihao-Qi!
+- CLI: add responsive
codexbar cards and compact --brief terminal usage views. Thanks @DonnieFi!
+- Antigravity: show pace details for legacy model-family and current session/weekly quota rows without changing compact icon lane semantics. Thanks @Zihao-Qi!
+- Widgets: make Kimi available with Weekly, Rate Limit, and Monthly quota rows. Thanks @joeVenner!
+- Kimi: show the subscription 7-day Code quota in menus and large widgets. Thanks @skyzer!
+- Claude: distinguish Max 5x and Max 20x in the plan label instead of a flat "Max". Thanks @kes02!
Fixed
-- Usage refresh: refresh provider data shortly after known quota reset boundaries instead of leaving expired reset times visible until the next normal poll. Thanks @pavbar!
-- Settings: align General-pane controls, show compact installed terminal app icons, and enlarge the window to fit more options.
-- Sakana AI: parse server-rendered quota reset timestamps as UTC instead of device-local time (#1826). Thanks @ss251!
-- Cursor: hide misleading pace and run-out details once a billing-cycle quota is fully depleted. Thanks @Yuxin-Qiao!
-- Claude Education: treat subscription-only CLI responses as unavailable quotas, keep local cost data in menus and widgets, and suppress expected refresh cancellations (#1808).
-- Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely.
-- Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes.
-- Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen!
-- Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan!
-- OpenCode Go: keep rolling usage available when the dashboard omits the optional weekly window. Thanks @mohkg1017!
-- Menu bar: make Show most-used provider rank only providers selected for Overview. Thanks @dstier-git!
-- Codex: show expiring reset-credit availability even when optional credits and extra usage are hidden, while preserving CLI
--no-credits. Thanks @simon-ami!
-- Claude CLI: prevent logged-out background Auto fallbacks from opening browser OAuth during app refresh. Thanks @afarwind!
-- Keychain prompts: explain that macOS handles password entry, surface the existing opt-out path, and link to troubleshooting before access begins (fixes #1681). Thanks @someshfengde and @Yuxin-Qiao!
-- Claude: use the dedicated Claude Code authentication command for sign-in, report its real exit status, and stop treating a browser URL as completed login (fixes #1715).
-- OpenAI API: explain that project service-account keys cannot read organization usage instead of surfacing a generic credit-balance HTTP 401 error (fixes #1792). Thanks @dhruv-anand-aintech!
-- Codex cost history: stop double-billing cached input and reprice stale Codex and Pi cache entries. Thanks @dstier-git!
-- Overview: render row selection on the GPU to keep trackpad scrolling smooth. Thanks @hhh2210!
-- Codex cost history: count cache reads separately, deduplicate active and archived sessions at row level, and preserve cached days across narrow refreshes. Thanks @kiranmagic7!
-- Pi cost history: price Codex cache reads once using their true context size. Thanks @kiranmagic7!
-- Menu bar: in the combined "Session + Weekly" metric (Codex and Claude), pair the 5-hour session usage with the weekly pace in pace and both display modes instead of showing the busier (most-constrained) lane's usage, which mislabeled the readout as weekly usage + weekly pace. Thanks @Shengqiang-Zhang!
-- Menu bar: in the combined "Session + Weekly" metric, ignore Claude web's synthetic 0% five-hour placeholder (emitted for accounts with no live session window but a real weekly lane) so the readout shows the weekly lane instead of a non-existent
5h 0%/5h 100% session.
-- Memory pressure: finish isolating utility-queue source reads from main-actor state to prevent the remaining callback crash. Thanks @Zihao-Qi!
-- Kiro: run account, usage, and context commands through a PTY so current CLI versions return usage without timing out. Thanks @sf-jin-ku!
-- OpenAI web: ignore stale profiles from removed browsers, discover registered installs outside standard app folders, and surface browser-profile access and cookie-load timeout diagnostics.
-- PTY probes: preserve Darwin device identifiers without crashing when Intel macOS reports signed values.
-- CLI server: collect
/usage providers concurrently under finite per-provider deadlines so one hung provider degrades to its own error row without discarding healthy results. Thanks @enieuwy!
-- Privacy: hide account and team identity values without showing a
Hidden placeholder or empty account rows. Thanks @Zihao-Qi!
-- Mistral: restore Vibe monthly-plan usage by forwarding only required console session cookies. Thanks @lfmundim!
-- Codex: show enterprise monthly credit limits across OAuth, CLI, menu, and widget surfaces. Thanks @ChenZiHong-Gavin!
-- Codex: avoid launching monthly-credit CLI enrichment during usage-only OAuth refreshes.
-- Usage display: keep positive values below one percent visible instead of rounding them to zero. Thanks @Max0633!
-- Menu bar: show pace as
0% instead of a signed +0% or -0% when the pace delta rounds to zero. Thanks @devYRPauli!
-- Menu: align the persistent Refresh row with native actions, keep Settings, About, and Quit keyboard-navigable, and use a narrower Usage Dashboard icon. Thanks @Zihao-Qi!
-- Menu: match the persistent Refresh symbol size, weight, and icon column to native action rows across standard and narrow provider menus. Thanks @micnem!
-- 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!
-- Antigravity CLI: reuse an authenticated user-launched
agy server for faster, more reliable one-shot usage checks. Thanks @junmo-kim!
+- Ollama: point missing-session recovery to the current
/signin page instead of the protected settings page. Thanks @joeVenner!
+- Alibaba Token Plan: support International Model Studio while preserving China-mainland upgrades and isolating regional cookie caches. Thanks @harshav167!
+- Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0!
+- Browser cookies: stop automatic Chromium-family probes after the first Safe Storage denial, while keeping an explicit Refresh retry available (#1952). Thanks @CoreyCole!
+- Claude: keep yearless reset dates in the upcoming year when a quota crosses New Year's Day. Thanks @devYRPauli!
+- Claude web: preserve fractional session and weekly utilization instead of displaying it as zero or unavailable. Thanks @devYRPauli!
+- Gemini: detect Google's consumer-tier shutdown response and offer an explicit Antigravity handoff without changing ordinary auth failures or enabling fallback automatically. Thanks @Yuxin-Qiao!
+- Gemini: use the real Flash quota in menu-bar metrics when an account has no Pro quota. Thanks @devYRPauli!
+- Ollama API: describe rejected keys as invalid or revoked, matching Ollama's current key lifecycle. Thanks @joeVenner!
+- Settings: keep Language, Default Terminal, and Refresh cadence selectors interactive on macOS 27.
+- Usage formatting: show every positive sub-1% value as
<1% instead of rounding values above 0.5% up to 1%. Thanks @devYRPauli!
+- Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings.
+- Codex quotas: show the session quota as unavailable while an exhausted weekly limit is still binding, including menu-bar icons and widgets. Thanks @Yuxin-Qiao!
+- Codex cost history: reuse cached aggregate pricing and one pricing catalog across daily and project reports, carry fresh cache state across launches, and treat unpriced models as migrated, avoiding repeated row scans, filesystem work, and duplicate background scans on large local histories.
+- Devin: keep exact 1% usage from being inflated to 100% while preserving fractional fallback quota semantics. Thanks @Lex-ic-on!
+- Kimi K2: reject invalid and out-of-range numeric timestamps while preserving valid second and millisecond values. Thanks @joeVenner!
+- Kimi K2: reject non-finite credit and token values before they reach menus, CLI output, or widgets. Thanks @joeVenner!
+- Kimi: call the current
GetSubscriptionStats membership endpoint so the Monthly subscription quota is populated again. Thanks @skyzer!
+- Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi!
+- Menu bar: detect Tahoe's blocked no-window state at startup when macOS still records the icon as enabled, so affected users receive recovery guidance instead of a silently missing icon (#1945). Thanks @mmyyfirstb!
View full changelog
]]>
-
+
-
- 0.37.2
- Mon, 22 Jun 2026 10:42:03 +0100
+ 0.40.0
+ Mon, 06 Jul 2026 00:10:16 +0100
https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml
- 92
- 0.37.2
+ 99
+ 0.40.0
14.0
- CodexBar 0.37.2
+ CodexBar 0.40.0
Added
-- Diagnostics: write redacted provider reports to a file with platform and app-version context. Thanks @Yuxin-Qiao!
-- CLI server: report the startup build version from
/health so clients can detect stale helper processes after updates. Thanks @enieuwy!
+- Claude: show opt-in read-only claude-swap accounts as stacked usage cards without delaying ambient refreshes. Thanks @optimiz-r!
+- Claude: switch inactive claude-swap accounts directly from their stacked usage cards and refresh usage immediately.
+- Codex dashboard: show calendar-correct raw Today and 30-day credit totals without converting credits to billed dollars. Thanks @avenoxai!
+- Cost charts: show visible, unit-safe scale labels across detailed history, inline menus, and widgets. Thanks @FNDEVVE!
+- Cursor: read the signed-in app token on Linux, with explicit manual-cookie web-source support and XDG config paths. Thanks @DonnieFi!
+- Devin: show remaining extra-usage balance in menus, CLI, and widgets while respecting optional-usage visibility. Thanks @FNDEVVE!
+- Widgets: make Mistral available in provider selection and switching. Thanks @joeVenner!
+
+Changed
+
+- Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi!
+- Debug builds: add a compact
D beside menu-bar icons and identify them as CodexBar Debug in tooltips and accessibility.
+- Usage bars: distinguish full-height quota-warning thresholds from subtle workday-boundary markers. Thanks @Alekstodo!
Fixed
-- Claude: pause background CLI usage probes briefly after rate limiting while keeping manual refresh available. Thanks @kiranmagic7!
-- Codex OAuth: publish refreshed
auth.json credentials with private file permissions already applied. Thanks @Hinotoi-agent!
-- Provider endpoints: reject unsafe Deepgram, z.ai, and Xiaomi MiMo overrides before attaching credentials. Thanks @Hinotoi-agent!
-- Azure OpenAI: reject unsafe endpoint overrides before attaching API keys while keeping invalid configurations visible with an actionable error. Thanks @Hinotoi-agent!
+- Codex cost history: reuse one pricing catalog while building project rollups and carry fresh cache state across launches, avoiding repeated filesystem work and duplicate background scans on large local histories.
+- Providers: detect Claude Desktop on fresh installs and ignore Gemini CLI installations without usable OAuth credentials.
+- Claude cost history: include nested Claude Desktop local-agent logs while preserving current Code/Cowork coverage through the shared
~/.claude/projects store. Thanks @Zihao-Qi!
+- Claude: give multiple claude-swap accounts precedence over token-account cards and segmented switching so adapter rows remain visible. Thanks @optimiz-r!
+- Menus: scope manual refresh state to the provider being refreshed, allowing independent provider refreshes without greying unrelated rows. Thanks @hhh2210!
+- Claude history: quarantine same-directory account-switch samples until credential ownership is stable, preventing plan-utilization history from crossing accounts. Thanks @ss251!
+- Language picker: keep language names readable in their native form and make System follow macOS without removing unrelated overrides. Thanks @Zihao-Qi!
+- Reset times: preserve minute precision in long day-scale countdowns when there are no whole hours, while keeping countdowns compact to two units. Thanks @konon4!
+- Mistral: reject non-finite and overflowing credit balances before they can reach menu, CLI, or widget formatting. Thanks @joeVenner!
View full changelog
]]>
-
+
-
- 0.37.1
- Mon, 22 Jun 2026 00:16:24 +0100
+ 0.39.0
+ Sat, 04 Jul 2026 13:01:10 -0700
https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml
- 91
- 0.37.1
+ 97
+ 0.39.0
14.0
- CodexBar 0.37.1
+ CodexBar 0.39.0
+
Added
+
+- Codex: show every available reset-credit expiry in menus and provider settings, including non-expiring credits, and summarize credits nearing expiry. Thanks @brahimhamichan!
+- Cost history: optionally show shorter 7, 30, and 90-day comparisons from the selected local history window (#1500). Thanks @jtl06!
+- Codex cost history: group local usage and costs by project and worktree in menus and CLI output. Thanks @clemenspeters!
+- Sakana AI: show best-effort pay-as-you-go credit balance and recent usage without delaying subscription quota refreshes. Thanks @ss251!
+- Kimi: show monthly subscription usage alongside weekly and five-hour limits with a short total budget for the optional membership request. Thanks @zhiyue!
+- Mistral: show available credit balance from the authenticated billing session while preserving API spend and Monthly Plan usage. Thanks @Zihao-Qi!
+
+Changed
+
+- Codex: compact reset-credit expiry inventory into a single scannable timeline instead of one row per credit.
+- Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner!
+
Fixed
-- MiniMax: recover detailed token-plan windows from the remains API when the coding-plan page only exposes coarse usage. Thanks @Yuxin-Qiao!
-- Cost history: remove the redundant tooltip from submenu-backed Cost rows. Thanks @Zihao-Qi!
-- Menu refresh: keep the menu open and show in-place progress when Refresh is clicked. Thanks @elijahfriedman!
-- Menu: align provider usage-card spacing with the Overview layout. Thanks @Zihao-Qi!
-- Memory pressure: avoid actor-isolation crashes when system callbacks arrive on a utility queue. Thanks @Zihao-Qi!
-- Menu: remove extra separators and spacing around Storage, Cost, and Subscription Utilization rows. Thanks @elijahfriedman!
-- Antigravity: show limits as unavailable when OAuth identifies the account but quota endpoints deny access. Thanks @Yuxin-Qiao!
+- Alibaba: keep the browser Safe Storage keychain read non-interactive and honor the "Disable Keychain access" setting, so cookie import can never trigger a Keychain prompt.
+- Tests: block real Keychain and
security CLI access by default so test runs cannot display password prompts.
+- Mistral: discard non-finite and overflowing billing costs so malformed price data cannot poison spend totals or charts. Thanks @joeVenner!
+- Claude: notify on model-scoped weekly and Daily Routines quota thresholds using independent warning state. Thanks @cleanerzkp!
+- Claude CLI: skip the identity probe after terminal usage errors or loading stalls, cutting failed refresh latency and subprocess churn.
+- OpenCode web: search Dia after Chrome for automatic cookie import, with Keychain preflight scoped to the candidate browser (fixes #1822). Thanks @zeajose!
+- Claude: make the "Avoid Keychain prompts" setting use the no-prompt policy instead of the experimental
security CLI reader. Thanks @gmkbenjamin!
View full changelog
]]>
-
+
-
0.14.0
diff --git a/codexbar.png b/codexbar.png
deleted file mode 100644
index feb52be248..0000000000
Binary files a/codexbar.png and /dev/null differ
diff --git a/docs/agent-sessions-design.md b/docs/agent-sessions-design.md
new file mode 100644
index 0000000000..c74d770023
--- /dev/null
+++ b/docs/agent-sessions-design.md
@@ -0,0 +1,98 @@
+# Agent Sessions (prototype)
+
+Track live Codex + Claude Code agent sessions — local Mac first, other Macs on the tailnet second — and surface them in the CodexBar menu with click-to-focus of the owning terminal window.
+
+## Why in CodexBar
+
+CodexBar already parses `~/.claude/projects` JSONL (cost scanner) and ships a bundled CLI on macOS + Linux. Sessions reuse both: the local scanner feeds the menu UI, and the same scanner exposed as `codexbar sessions --json` is what remote Macs run over SSH. No daemon, no new app.
+
+## Data model (CodexBarCore)
+
+```swift
+public struct AgentSession: Codable, Sendable, Identifiable {
+ public enum Provider: String, Codable, Sendable { case codex, claude }
+ public enum Source: String, Codable, Sendable { case cli, desktopApp, ide, unknown }
+ public enum State: String, Codable, Sendable { case active, idle }
+
+ public var id: String // session UUID when resolvable, else "pid:"
+ public var provider: Provider
+ public var source: Source
+ public var state: State
+ public var pid: Int32? // nil for file-only (e.g. Codex desktop) sessions
+ public var cwd: String?
+ public var projectName: String? // last path component of cwd
+ public var startedAt: Date?
+ public var lastActivityAt: Date? // transcript mtime
+ public var transcriptPath: String?
+ public var host: String // local hostname, or remote host label
+}
+```
+
+`active` = last activity ≤ 120 s ago. `idle` = live process (or recent file) with older activity. Constants live in one `SessionScanConfig` struct (activeWindow 120 s, fileOnlyWindow 30 min) so thresholds are tunable/testable.
+
+## Local scanner (CodexBarCore, no new deps)
+
+`LocalAgentSessionScanner` combines two signals:
+
+1. **Process scan** — parse `ps -axo pid=,ppid=,lstart=,command=`.
+ - Claude: command basename `claude` (skip obvious non-agent helpers). Source: path contains `Application Support/Claude/claude-code` → `.desktopApp`, else `.cli`. Deduplicate the wrapper/child pair (desktop spawns `disclaimer` parent + `claude` child with same argv; keep the child).
+ - Codex: basename `codex` with no `app-server` argument → `.cli` (TUI or `exec`). `codex app-server` marks the desktop app as present but is not itself a session.
+ - cwd per pid via one batched `lsof -a -d cwd -Fn -p ` call (parse `p`/`n` records). Failure → cwd nil, session still listed.
+2. **Transcript correlation**
+ - Claude: cwd → `~/.claude/projects//` (escape: every non-alphanumeric ASCII → `-`), newest `*.jsonl` by mtime → session id (filename UUID), lastActivityAt (mtime). Also reuse `ClaudeDesktopProjectsLocator` roots so desktop local-agent-mode sessions resolve.
+ - Codex: enumerate `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` for today + yesterday (`$CODEX_HOME` respected). Read only the first line (`session_meta`: `session_id`, `cwd`, `originator`, `source`). File with mtime ≤ fileOnlyWindow and no matching live pid → file-only session, source from `originator` (`codex_exec`/`exec` → `.cli`; ide-ish originators → `.ide`; desktop → `.desktopApp`). Live `codex` pids match to rollouts by cwd (newest wins); unmatched live pid still listed with nil transcript.
+ - Never read more than the first line of any JSONL; never load whole transcripts.
+
+Scanner is `Sendable`, pure functions where possible; ps/lsof output parsing lives in dedicated parser types fed by strings so tests use fixtures.
+
+## CLI (CodexBarCLI)
+
+- `codexbar sessions` — table; `--json` — `[AgentSession]` (stable field names above; ISO-8601 dates).
+- `codexbar sessions focus ` — macOS only: focus the session's terminal window (see Focus). Exit 1 if id unknown, 2 if focus failed.
+- Follows existing `CLI*Command.swift` conventions. Works on Linux for listing (ps/proc paths guarded), focus is Darwin-only.
+
+## Remote hosts (CodexBarCore + app)
+
+`RemoteSessionFetcher`:
+
+- Host list = manual entries (settings, ssh destinations like `steipete@clawmac`) ∪ automatic Tailscale discovery (no-op when tailscale is absent): run `tailscale status --json` (PATH, then `/Applications/Tailscale.app/Contents/MacOS/Tailscale`), take online peers with `"OS": "macOS"|"linux"`, use first `DNSName` label as host. Local host excluded.
+- Fetch per host (parallel, 5 s budget): `ssh -o BatchMode=yes -o ConnectTimeout=3 sh -lc 'codexbar sessions --json'` with fallback to the bundled app CLI path (resolve the canonical bundled location from `Scripts/package_app.sh` and hardcode it as fallback: `… || sessions --json`). Host errors are non-fatal: host shown as unreachable, others still render.
+- Remote focus: fire-and-forget `ssh sh -lc 'codexbar sessions focus '`.
+- Refresh: local scan every 30 s while the status item exists (cheap), remote every 60 s and immediately on menu open; both skipped when the feature is off. Reuse existing refresh loop plumbing rather than new timers if it fits.
+
+## Menu UI (CodexBar app)
+
+- New menu section **Agent Sessions (N)** (N = total, all hosts) above the settings/footer area, built through the existing `MenuDescriptor`-style seam so it's testable headless.
+- Local sessions first, then one group per remote host (`clawmac — 2`, unreachable hosts greyed with a tooltip). Row: state dot (● active / ○ idle), provider glyph, `projectName — provider · source · 12m`.
+- Click local row → `SessionWindowFocuser`. Click remote row → remote focus ssh call.
+- Settings: "Sessions" group — a single enable toggle (default on) plus a manual hosts text field (comma-separated); Tailscale discovery is always on while the feature is enabled. Persist in `SettingsStore` like neighboring prefs.
+
+## Focus (macOS, app + CLI shared in Core or app-adjacent target)
+
+`SessionWindowFocuser`:
+
+1. pid → walk ppid chain to the nearest ancestor whose `NSRunningApplication.bundleIdentifier` is a known terminal/editor host: Ghostty, iTerm2, Apple Terminal, Warp, WezTerm, kitty, Alacritty, VS Code, Cursor, Zed, Claude desktop (`com.anthropic.claudefordesktop`). Fallback: the app owning the pid.
+2. Activate the app, then AX (`AXUIElementCreateApplication` → `AXWindows`): raise the window whose title contains projectName or the cwd tail; fallback to frontmost window of that app. Requires Accessibility permission — call `AXIsProcessTrustedWithOptions` with prompt on first use; degrade gracefully (activate app only) when untrusted.
+3. File-only sessions (no pid): Claude desktop → activate Claude.app; Codex desktop → activate Codex.app; otherwise no-op with log.
+
+tmux pane / terminal-tab precision is out of scope for the prototype.
+
+## Tests (Tests/CodexBarTests)
+
+Fixture-driven, no live processes, no Keychain/AX:
+
+- ps output parser: desktop `disclaimer`+`claude` dedupe, codex vs `codex app-server`, weird argv.
+- lsof `-Fn` parser.
+- Claude cwd escaping → project dir mapping; newest-jsonl selection (temp dirs).
+- Codex rollout first-line parse → AgentSession (fixture JSONL), file-only window cutoff.
+- Tailscale status JSON → host list (fixture; offline/iOS peers excluded).
+- Sessions JSON round-trip (CLI output schema stability).
+- Menu section descriptor: counts, grouping, unreachable-host rendering.
+
+## Non-goals (prototype)
+
+Claude.ai chat sessions; Codex cloud tasks; historical session browsing/analytics; "waiting on permission" state; tmux pane/tab focus; Bonjour/mDNS; persistent remote daemon or push transport; widget changes. No new SPM dependencies.
+
+## Proof
+
+`make check` clean; `make test` (or focused `swift test --filter` covering the new tests) green; `swift run CodexBarCLI sessions --json` produces plausible output on this Mac.
diff --git a/docs/antigravity.md b/docs/antigravity.md
index e625977b1d..5ad0dd43d9 100644
--- a/docs/antigravity.md
+++ b/docs/antigravity.md
@@ -9,6 +9,12 @@ read_when:
# Antigravity provider
+For Google individual, AI Pro, and Ultra accounts blocked by the June 2026 Gemini CLI OAuth
+shutdown, Antigravity is the replacement path for Gemini quota tracking in CodexBar. Launch
+the Antigravity app or run `agy`, sign in, then refresh. See `docs/gemini.md` for the Gemini
+provider migration notes. CodexBar offers the handoff only after an observed Google migration
+signal and never enables or falls back to Antigravity automatically.
+
Antigravity supports four usage data sources:
1. The Antigravity 2.0 app's local `language_server` (preferred when the app is open).
diff --git a/docs/claude-multi-account-and-status-items.md b/docs/claude-multi-account-and-status-items.md
index c0831fd4c3..e188ce7cb0 100644
--- a/docs/claude-multi-account-and-status-items.md
+++ b/docs/claude-multi-account-and-status-items.md
@@ -8,8 +8,7 @@ read_when:
# Claude multi-account and status item decision
-Status: **Phase 1 design accepted; do not merge an implementation until its independent adapter and model proof is
-complete.**
+Status: **Phase 1 account display implemented; Phase 2 explicit account activation accepted.**
Related: [#1756](https://github.com/steipete/CodexBar/issues/1756),
[#1268](https://github.com/steipete/CodexBar/issues/1268), and the bounded Claude sign-in repair in
@@ -17,12 +16,12 @@ Related: [#1756](https://github.com/steipete/CodexBar/issues/1756),
## Accepted direction
-1. Add an opt-in, read-only `claude-swap` adapter as the first Claude subscription multi-account source.
+1. Use an opt-in `claude-swap` adapter as the first Claude subscription multi-account source.
2. Normalize its results behind a provider-neutral account snapshot before adding any status item UI.
3. Make per-account status items opt-in, replace the provider item for that provider, cap selection at four, and keep
them mutually exclusive with Merge Icons.
-4. Defer account switching. A later phase may invoke `cswap --switch-to --json` only after a separate product
- decision and explicit user action.
+4. Allow an explicit click on an inactive account card to invoke exactly `cswap --switch-to --json`. Keep
+ automatic switching and session launching out of scope.
This solves the durable OAuth refresh problem without making CodexBar a second credential vault. It also avoids a
Claude-only status item implementation that would need to be redesigned for Codex and other providers.
@@ -57,16 +56,15 @@ Keychain and prompt behavior. The safer seam is a credential-free usage adapter
| Option | Credential ownership | Durability | Risk | Recommendation |
| --- | --- | --- | --- | --- |
| First-party OAuth account vault | CodexBar | High | New login, refresh, storage, revocation, migration, and security surface | Defer |
-| Read-only `claude-swap` adapter | `claude-swap` | High | External executable and schema dependency | **Phase 1** |
+| Bounded `claude-swap` adapter | `claude-swap` | High | External executable and schema dependency | **Phase 1–2** |
| Discover Claude Code Keychain entries | Claude Code / ambiguous | Unknown | Undocumented enumeration; prompt and identity hazards | Reject |
| Existing token accounts | CodexBar config | Low for OAuth | Access token expires without refresh metadata | Keep for current cookie/API-key uses |
-As of [`claude-swap` v0.16.0](https://github.com/realiti4/claude-swap/releases/tag/v0.16.0),
+As of [`claude-swap` v0.18.0](https://github.com/realiti4/claude-swap/releases/tag/v0.18.0),
`cswap --list --json` still returns a versioned object with `schemaVersion: 1`, an active account number, account slots,
redaction-sensitive email labels, 5-hour and 7-day usage percentages, and reset timestamps. Handled failures return an
-error object and non-zero exit. v0.16.0 also adds mutation-capable automatic switching; that does not expand this
-integration. CodexBar does not need `--token-status`, credential files, Keychain access, or raw OAuth values for the
-display-only phase.
+error object and non-zero exit. Direct switching returns the same versioned envelope. CodexBar does not need
+`--token-status`, credential files, Keychain access, or raw OAuth values for display or explicit activation.
## Phase 1 adapter contract
@@ -88,6 +86,20 @@ display-only phase.
The executable is an optional external dependency, not a bundled component. Preferences should show detected version,
last refresh, adapter errors, and a link to the upstream project; CodexBar should not install or update it.
+## Phase 2 explicit activation contract
+
+- Only an explicit click on an inactive, actionable account card can start a switch.
+- Derive the numeric slot from the already validated account snapshot and execute exactly
+ `cswap --switch-to --json`; never accept free-form arguments or invoke a shell.
+- Serialize switches, validate `schemaVersion == 1` and the returned target slot, and bound captured output.
+- Once launched, let the external credential transaction reach its natural exit without forced timeout or
+ cancellation. If the adapter setting changes, hide its UI state and discard its result when the original
+ configuration is no longer current.
+- Refresh ambient Claude usage and the adapter account list after completion. Show switch errors independently from
+ list-refresh errors and preserve the last successful usage snapshots.
+- Keep expired, missing, unknown, and Keychain-inaccessible credential slots non-actionable. Never auto-switch, launch
+ sessions, add/import/export/purge accounts, or mutate credentials directly.
+
## Provider-neutral account model
Introduce one projection used by menus and status items rather than teaching status item code about Claude OAuth:
@@ -98,6 +110,7 @@ struct ProviderAccountUsageSnapshot: Identifiable {
let provider: UsageProvider
let displayLabel: String
let isActive: Bool
+ let canActivate: Bool
let snapshot: UsageSnapshot?
let error: String?
let sourceLabel: String?
@@ -147,14 +160,15 @@ No real credential, browser session, or provider call was used.
## Accepted decisions
-1. The optional external `claude-swap` dependency is accepted only for exact read-only `cswap --list --json` execution.
-2. Switching, automatic switching, account mutation, and session launching stay out of Phase 1.
+1. The optional external `claude-swap` dependency is accepted for exact `cswap --list --json` execution and explicit
+ `cswap --switch-to --json` activation.
+2. Automatic switching, account add/import/export/purge, and session launching stay out of scope.
3. Provider-neutral account snapshots land before any per-account status item work.
4. Per-account status items are capped at four and mutually exclusive with Merge Icons.
5. Status item labels use aliases or privacy-safe ordinals, never email identity.
-Any change to these decisions requires a new product/auth review before implementation because it changes storage,
-status item migration, process authority, or the credential boundary.
+Any further change to these decisions requires a new product/auth review before implementation because it changes
+storage, status item migration, process authority, or the credential boundary.
## Implementation and validation sequence
@@ -163,6 +177,7 @@ status item migration, process authority, or the credential boundary.
2. Add the opt-in adapter and provider-neutral projection. Verify no credential reads and no impact on ambient Claude.
3. Add settings-state and menu-model tests. Keep AppKit status item creation out of headless tests.
4. Add status item identity/migration tests, then implement account items behind the opt-in setting.
-5. Run focused tests, `make check`, `make test`, packaged synthetic proof, and macOS UI proof with redacted fixtures.
+5. Add exact-argv, strict switch-result, serialization, and refresh tests using a fake executable only.
+6. Run focused tests, `make check`, `make test`, packaged synthetic proof, and macOS UI proof with redacted fixtures.
-No credential import, account mutation, or compatibility shim is part of this proposal.
+No credential import, automatic switching, session launching, or compatibility shim is part of this proposal.
diff --git a/docs/claude.md b/docs/claude.md
index 58d8f477d9..cd497f371d 100644
--- a/docs/claude.md
+++ b/docs/claude.md
@@ -81,7 +81,8 @@ Admin API key setup:
- `extra_usage` → Extra usage cost (monthly spend/limit).
- Successful OAuth login enables Claude and selects OAuth as the usage source.
- Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to
- Max/Pro/Team/Enterprise.
+ Max/Pro/Team/Enterprise. When a Max `rate_limit_tier` carries a usage multiplier
+ (`default_claude_max_5x` / `default_claude_max_20x`), it is surfaced in the label as "Max 5x" / "Max 20x".
## Web API (cookies)
- Preferences → Providers → Claude → Cookie source (Automatic or Manual).
@@ -112,6 +113,37 @@ Admin API key setup:
- Extra usage spend/limit (if enabled).
- Account email + inferred plan.
+## claude-swap accounts (opt-in)
+
+The accepted multi-account design in
+[claude-multi-account-and-status-items.md](claude-multi-account-and-status-items.md).
+
+- Setup: Preferences → Providers → Claude → "Read accounts from claude-swap", then set the path to the
+ [`cswap`](https://github.com/realiti4/claude-swap) executable (for example `~/.local/bin/cswap`).
+- Behavior: on each Claude refresh, CodexBar runs `cswap --list --json` independently of the ambient Claude fetch (no
+ shell, fixed arguments, bounded runtime and output), requires `schemaVersion == 1`, and parses only slot number,
+ active state, usage status, email (display only), and the 5-hour/7-day windows.
+- Display: when claude-swap reports more than one account, the Claude menu shows one stacked card per
+ account (active account first) alongside nothing else changing; with zero or one account the menu is
+ unchanged. Account identity is `claude-swap:`.
+- Isolation: CodexBar never reads claude-swap or Claude Code credential storage for this feature; the
+ subprocess handles its own credential access. Adapter failures keep the last successful accounts as
+ stale data, surface the error in provider settings, and never affect the ambient Claude usage card.
+- Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`,
+ `unavailable`) render as per-account notes instead of usage bars.
+- Switching: an inactive account with usable source credentials shows “Switch Account…”. Clicking it runs exactly
+ `cswap --switch-to --json`, validates the versioned result and requested slot, then refreshes both ambient
+ Claude usage and every claude-swap account card. Switches are serialized; no automatic switching occurs.
+- Expired, missing, unknown, or Keychain-inaccessible credentials stay non-actionable. A failed switch remains visible
+ on that account without discarding its last successful usage. A running Claude Code process can take up to the
+ claude-swap Keychain cache interval to observe the new account.
+- When multiple claude-swap accounts are available, they take explicit precedence over Claude
+ token-account presentation (stacked cards and the segmented switcher).
+
+Packaged synthetic proof (fake `cswap` executable, no real accounts or credentials):
+
+
+
## CLI PTY (fallback)
- Runs `claude` in a PTY session (`ClaudeCLISession`).
- Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes.
@@ -139,10 +171,16 @@ Admin API key setup:
- `$CLAUDE_CONFIG_DIR` (comma-separated), each root uses `/projects`.
- Fallback roots:
- `~/.config/claude/projects`
- - `~/.claude/projects`
+ - `~/.claude/projects` (Claude Code and current Claude Desktop Code/Cowork CLI sessions)
+ - Additional embedded Claude Desktop project stores, when present:
+ - `~/Library/Application Support/Claude/local-agent-mode-sessions/**/.claude/projects`
+ - `~/Library/Application Support/Claude/claude-code-sessions/**/.claude/projects`
+ - Current Claude Desktop metadata under `claude-code-sessions` points to shared CLI session JSONL by
+ `cliSessionId`; metadata-only directories are not treated as usage sources.
- Supported pi sessions:
- `~/.pi/agent/sessions/**/*.jsonl`
-- Files: `**/*.jsonl` under the native project roots plus supported pi session files.
+- Files: `**/*.jsonl` under the native project roots, discovered Claude Desktop project roots,
+ plus supported pi session files.
- Parsing:
- Native Claude logs parse lines with `type: "assistant"` and `message.usage`.
- Uses per-model token counts (input, cache read/create, output).
diff --git a/docs/clawrouter.md b/docs/clawrouter.md
new file mode 100644
index 0000000000..a65a73055c
--- /dev/null
+++ b/docs/clawrouter.md
@@ -0,0 +1,51 @@
+---
+summary: "ClawRouter setup for monthly budget, spend, and routed-provider usage."
+read_when:
+ - Configuring ClawRouter usage tracking
+ - Debugging ClawRouter budget or provider breakdown display
+ - Explaining ClawRouter API key and base URL settings
+---
+
+# ClawRouter
+
+CodexBar reads the policy attached to a ClawRouter API key. The menu card shows its monthly budget, spend, requests,
+tokens, and provider breakdown. Provider rows come directly from ClawRouter, so the integration works with any routed
+model provider configured there; CodexBar does not need a separate provider plugin for each route.
+
+## Setup
+
+Create a ClawRouter key with access to the routes you want, then store it in CodexBar:
+
+```bash
+printf '%s' "$CLAWROUTER_API_KEY" | codexbar config set-api-key --provider clawrouter --stdin
+```
+
+You can also paste the key in CodexBar Settings → Providers → ClawRouter. The hosted service is used by default:
+
+```text
+https://clawrouter.openclaw.ai
+```
+
+For another deployment, set the Base URL in Settings or use `CLAWROUTER_BASE_URL`. The value may point to the service
+root or `/v1`; CodexBar normalizes both to `/v1/usage`. Overrides must be HTTPS URLs or bare hosts normalized to HTTPS.
+
+## Display
+
+- Monthly budget meter and reset date when the policy has a budget.
+- This-month spend against the configured limit.
+- Request count and total token usage.
+- Up to five routed-provider rows, ordered by spend and request count.
+- Unmetered policy status and spend when no monthly limit is configured.
+
+ClawRouter usage is policy-wide. If one key can route OpenAI, Anthropic, Google, OpenRouter, or non-model services, the
+same CodexBar card aggregates them and lists the provider identifiers returned by `/v1/usage`.
+
+## Environment variables
+
+| Variable | Description |
+| --- | --- |
+| `CLAWROUTER_API_KEY` | ClawRouter API key. |
+| `CLAWROUTER_BASE_URL` | Optional HTTPS service root or `/v1` URL. |
+
+CodexBar sends the key only to the validated ClawRouter endpoint. `/v1/usage` returns accounting metadata; CodexBar
+never receives routed prompts or model responses.
diff --git a/docs/cli.md b/docs/cli.md
index 28809223d6..9e7f4a04d1 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -47,6 +47,16 @@ See `docs/configuration.md` for the schema.
- `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access.
- `--format text|json` (default: text).
- `--refresh` ignores cached scans.
+- `codexbar cards` prints a one-shot usage snapshot as a responsive terminal card grid.
+ - Reuses the same provider, source, account, credits, and status flags as `codexbar usage`.
+ - Account lines and plan badges are included in the card grid by default.
+ - `--brief` renders a compact table (Provider / Usage / Reset) instead of the card grid.
+ - Stdout is always rendered text; `--json-output` only affects stderr logs (no JSON card payload).
+ - Failed providers are summarized in a footer (not rendered as error cards).
+ - Honors `$COLUMNS` for layout; falls back to 80 columns. Use `--no-color` for plain output.
+ - Kitty, Ghostty, WezTerm, and other truecolor terminals auto-enable enhanced gradients/outlines.
+ - Force enhanced mode elsewhere with `CODEXBAR_CARDS_ENHANCED=1`.
+ - Exit code is non-zero when any provider fetch fails.
- `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON.
- `--port ` defaults to `8080`.
- `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL.
@@ -115,6 +125,7 @@ payloads include the visible account label in `account`.
- `sessionTokens`, `sessionCostUSD`
- `last30DaysTokens`, `last30DaysCostUSD`
- `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`)
+- Codex only: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]`
- `totals`: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`
## Example usage
@@ -126,6 +137,7 @@ codexbar --format json --pretty # machine output
codexbar --format json --provider both
codexbar cost # local cost usage (default 30-day window + today)
codexbar cost --days 90 # choose a 1...365 day cost window
+codexbar cost --provider codex --group-by project
codexbar cost --provider claude --format json --pretty
codexbar serve --port 8080 # localhost HTTP JSON server
codexbar serve --request-timeout 0 # disable serve request deadlines
diff --git a/docs/codex.md b/docs/codex.md
index 54826f58f9..bfce642a6b 100644
--- a/docs/codex.md
+++ b/docs/codex.md
@@ -31,6 +31,11 @@ Usage source picker:
- Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`).
- Refreshes access tokens when `last_refresh` is older than 8 days.
- Calls `GET https://chatgpt.com/backend-api/wham/usage` (default) with `Authorization: Bearer `.
+- The app reads reset-credit inventory once per refresh with a best-effort
+ `GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits` using the same account-scoped OAuth context;
+ the CLI requests it only when optional credits are included.
+- The menu and provider settings list every still-available expiry, while the optional credits setting controls
+ nearing-expiry notifications. CodexBar does not redeem or modify reset credits.
- `rate_limit.primary_window` / `secondary_window` map to the session/weekly lanes.
- `additional_rate_limits[]` (model-specific limits such as GPT-5.3-Codex-Spark) map to named
`UsageSnapshot.extraRateWindows` entries (Spark uses a stable `codex-spark` id / `Codex Spark` title).
diff --git a/docs/configuration.md b/docs/configuration.md
index 69684801e1..a645ad6902 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -51,7 +51,7 @@ All provider fields are optional unless noted.
- `auto` uses provider-specific fallback order (see `docs/providers.md`).
- `api` uses the provider's API-backed mode; only some providers consume the `apiKey` field.
- `apiKey`: raw API token for providers that support config-backed direct API usage.
-- `enterpriseHost`: provider-specific API host/base URL override. Today this is used by Azure OpenAI, Copilot, and LLM Proxy.
+- `enterpriseHost`: provider-specific API host/base URL override. Used by Azure OpenAI, Copilot, LLM Proxy, LiteLLM, and ClawRouter.
- `cookieSource`: cookie selection policy.
- `auto` (browser import), `manual` (use `cookieHeader`), `off` (disable cookies)
- `cookieHeader`: raw cookie header value (e.g. `key=value; other=...`).
@@ -107,6 +107,7 @@ printf '%s' "$OPENAI_ADMIN_KEY" | codexbar config set-api-key --provider openai
printf '%s' "$GROQ_API_KEY" | codexbar config set-api-key --provider groq --stdin
printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin
printf '%s' "$LITELLM_API_KEY" | codexbar config set-api-key --provider litellm --stdin
+printf '%s' "$CLAWROUTER_API_KEY" | codexbar config set-api-key --provider clawrouter --stdin
```
OpenAI API project scoping uses `workspaceID` in config. This maps to `OPENAI_PROJECT_ID` for Admin API usage and is
@@ -143,6 +144,18 @@ LiteLLM also needs a base URL. Set `enterpriseHost` in config or `LITELLM_BASE_U
}
```
+ClawRouter defaults to the hosted service. To use another deployment, set `enterpriseHost` in config or
+`CLAWROUTER_BASE_URL` in the process environment:
+
+```json
+{
+ "id": "clawrouter",
+ "enabled": true,
+ "apiKey": "",
+ "enterpriseHost": "https://router.example.com"
+}
+```
+
See [CLI configuration](cli-configuration.md) for scripting examples and output formats.
Manual cookies are secrets. Keep the CodexBar config file private, leave its permissions at `0600`, never commit it,
@@ -169,7 +182,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s
## Provider IDs
Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`):
-`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `crossmodel`.
+`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `crossmodel`, `clawrouter`.
## Ordering
The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering.
diff --git a/docs/cost-window-comparisons.md b/docs/cost-window-comparisons.md
new file mode 100644
index 0000000000..3351fa0e77
--- /dev/null
+++ b/docs/cost-window-comparisons.md
@@ -0,0 +1,35 @@
+# Cost window comparison decision
+
+Issues: [#1500](https://github.com/steipete/CodexBar/issues/1500), [#1708](https://github.com/steipete/CodexBar/issues/1708)
+
+## Proposed product shape
+
+Keep the existing history-window setting as the maximum local scan window. Add an opt-in **Show shorter comparison periods** preference, defaulting off. When enabled, the cost card adds fixed 7, 30, and 90-day totals that are shorter than the selected history window.
+
+Examples:
+
+- 30-day history: Today, Last 30 days, Last 7 days.
+- 90-day history: Today, Last 90 days, Last 7 days, Last 30 days.
+- 365-day history: Today, Last 365 days, Last 7 days, Last 30 days, Last 90 days.
+
+The implementation derives every comparison from the already-loaded daily report. It does not widen scans, add network requests, retain new data, or change provider source selection. Missing calendar days remain zero-usage days rather than making “last 7 days” mean “last 7 non-empty rows.”
+
+## Why this does not claim lifetime cost
+
+“All available local logs” and “lifetime since install” are different contracts. Local Codex, Claude, and Pi logs may be moved, pruned, excluded, or created before CodexBar was installed. The existing plan-utilization history is also capped and has no token or cost ledger. A 365-day total therefore cannot honestly be labeled a lifetime bill.
+
+A true #1708 implementation needs separate approval for an append-only local ledger with:
+
+- an explicit collection start date and completeness state;
+- provider/account ownership and reset behavior;
+- migration, retention, export, and deletion controls;
+- privacy documentation and bounded storage tests;
+- UI wording that separates observed utilization snapshots from estimated local-log cost.
+
+Recommendation: ship the opt-in comparison rows for #1500 independently. Keep #1708 open until the ledger/data-retention contract is approved; label any future scan-only total **Available local logs**, never **Lifetime**.
+
+## Maintainer choice
+
+1. **Recommended:** merge with the preference default off. Existing UI and scan cost stay unchanged until a user opts in.
+2. Default the preference on. More useful immediately, but adds vertical menu density for existing users.
+3. Keep the model only and revisit the presentation. This preserves tested calendar-window aggregation without shipping a new setting.
diff --git a/docs/cursor.md b/docs/cursor.md
index d303fdfe96..24be1e7b59 100644
--- a/docs/cursor.md
+++ b/docs/cursor.md
@@ -31,8 +31,11 @@ Cursor is primarily web-backed. Usage is fetched via browser cookies or a stored
4) **Cursor.app local auth** (last fallback)
- Reads Cursor.app's VS Code-style global state DB for the local app bearer token.
- - File: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`.
+ - File:
+ - macOS: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
+ - Linux: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`)
- Used only after cookie/session sources fail so existing account-selection precedence stays stable.
+ - On Linux, this is the primary automatic source because browser import and the WebKit login flow are macOS-only.
- Derives Cursor's first-party web-session cookie, then uses the same usage and account endpoints as browser sessions.
- Account identity comes from that authenticated session; cached app profile fields are not mixed across accounts.
@@ -53,8 +56,13 @@ Manual option:
- Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite`
+## Linux CLI
+- `codexbar usage --provider cursor` reads the signed-in Cursor app's access token from the Linux global state DB and reuses the same `cursor.com` usage endpoints as macOS.
+- Automatic browser cookie import and the in-app WebKit login flow remain macOS-only.
+- Manual cookie headers from `~/.config/codexbar/config.json` (or legacy `~/.codexbar/config.json`) work on Linux.
+
## Local storage footprint
-When **Settings → Advanced → Track provider local storage** is enabled, CodexBar measures:
+When **Settings → Advanced → Track provider local storage** is enabled on macOS, CodexBar measures:
- `~/Library/Application Support/Cursor`
- `~/Library/Application Support/Caches/cursor-updater`
- `~/.cursor`
diff --git a/docs/gemini.md b/docs/gemini.md
index c3c3f915ed..82c53e7fd5 100644
--- a/docs/gemini.md
+++ b/docs/gemini.md
@@ -66,9 +66,23 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser
- Tier from `loadCodeAssist`:
- `standard-tier` → "Paid"
- `free-tier` + `hd` claim → "Workspace"
+ - `free-tier` + `paidTier.name` → consumer subscription label (e.g. "Plus", "Ultra")
- `free-tier` → "Free"
- `legacy-tier` → "Legacy"
- Email from `id_token` JWT claims.
+## Consumer-tier migration (June 2026)
+- [Google stopped serving](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals)
+ Gemini CLI OAuth for individual, AI Pro, and Ultra accounts on 2026-06-18. Standard and Enterprise
+ subscriptions remain supported; paid API-key access is outside CodexBar's OAuth-backed Gemini provider.
+- When quota, `loadCodeAssist`, or token-refresh responses include Google's unsupported-client
+ migration signal (`UNSUPPORTED_CLIENT`, `IneligibleTierError`, or Antigravity migration copy),
+ CodexBar surfaces `consumerTierDeprecated` with guidance to use the Antigravity provider.
+- Settings shows an **Enable Antigravity provider** action only after CodexBar observes
+ `consumerTierDeprecated` during a Gemini refresh (typed sentinel state, not user-facing text matching).
+- The action is explicit: CodexBar never automatically enables Antigravity or falls back to it.
+- Ordinary Gemini login, `notLoggedIn`, and Antigravity setup errors remain unchanged. CodexBar does not
+ capture Terminal `gemini` OAuth output, so Terminal-only failures cannot activate the migration action.
+
## Key files
- `Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift`
diff --git a/docs/index.html b/docs/index.html
index 4b748e2634..c354bc44a2 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -26,10 +26,12 @@
+
+
@@ -332,7 +334,7 @@

T3 ChatCookies

CodebuffAPI key
- 
PoeAPI key
+ 
PoeAPI key

ChutesAPI key

ZedLocal
Your providerAuthoring guide
diff --git a/docs/keychain-prompts.md b/docs/keychain-prompts.md
index 1cb34c4a79..3b9c57d248 100644
--- a/docs/keychain-prompts.md
+++ b/docs/keychain-prompts.md
@@ -68,6 +68,11 @@ on Keychain can still work where the provider supports them.
## Browser Safe Storage prompts
+If a Chromium-family Safe Storage check requires interaction or is denied, CodexBar pauses automatic cookie imports
+for every Chromium-family browser for six hours. This prevents a denial in Arc, Edge, Brave, or another Chromium
+browser from immediately moving to the next browser and showing another prompt. **Refresh Now** is an explicit retry
+for the browser that was blocked; Safari and Firefox-family cookie imports remain available during the pause.
+
For normal browser-cookie import prompts, either allow CodexBar in the Keychain item's Access Control list or disable
Keychain access:
diff --git a/docs/logos/poe.svg b/docs/logos/poe.svg
index 757be15421..51f30a339e 100644
--- a/docs/logos/poe.svg
+++ b/docs/logos/poe.svg
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/docs/ollama.md b/docs/ollama.md
index aec630650b..e6c981e978 100644
--- a/docs/ollama.md
+++ b/docs/ollama.md
@@ -27,6 +27,8 @@ Usage limits for session and weekly windows.
3. For API-key mode, paste an API key from `https://ollama.com/settings/keys` or set `OLLAMA_API_KEY`.
4. For quota bars, leave **Cookie source** on **Auto** (recommended, imports Chrome cookies by default).
+Ollama API keys currently do not expire, but they can be revoked from the key settings page.
+
### Manual cookie import (optional)
1. Open `https://ollama.com/settings` in your browser.
@@ -37,6 +39,10 @@ Usage limits for session and weekly windows.
- API-key mode fetches `https://ollama.com/api/tags` with `Authorization: Bearer ` to verify Cloud API access.
- Cookie mode fetches `https://ollama.com/settings` using browser cookies.
+- Cookie discovery recognizes the current WorkOS AuthKit `wos-session` cookie alongside legacy Ollama and NextAuth
+ session names.
+- Redirects from settings to `/signin` or the WorkOS AuthKit authorization page are treated as expired sessions, so
+ CodexBar can try the next cookie candidate and show sign-in guidance instead of a parser error.
- Parses:
- Plan badge under **Cloud Usage**.
- **Session usage** and **Weekly usage** percentages.
@@ -46,12 +52,12 @@ Usage limits for session and weekly windows.
### “No Ollama session cookie found”
-Log in to `https://ollama.com/settings` in Chrome, then refresh in CodexBar.
+Sign in at `https://ollama.com/signin` in Chrome, then refresh CodexBar.
If your active session is only in Safari (or another browser), use **Cookie source → Manual** and paste a cookie header.
### “Ollama session cookie expired”
-Sign out and back in at `https://ollama.com/settings`, then refresh.
+Sign out and back in at `https://ollama.com/signin`, then refresh.
### “Could not parse Ollama usage”
diff --git a/docs/opencode.md b/docs/opencode.md
index 3d1c504e70..d65c9d5159 100644
--- a/docs/opencode.md
+++ b/docs/opencode.md
@@ -22,7 +22,9 @@ read_when:
## Notes
- Responses are `text/javascript` with serialized objects; parse via regex.
- Missing workspace ID or rolling usage fields should raise parse errors; omitted weekly usage stays absent.
-- Cookie import defaults to Chrome-only to avoid extra browser prompts; pass a browser list to override.
+- OpenCode web Auto imports Chrome first, then Dia when their cookie stores exist; Keychain preflight stays scoped
+ to each candidate browser. Other browsers stay on Manual Cookie import until CodexBar has an explicit browser
+ selector.
- Set `CODEXBAR_OPENCODE_WORKSPACE_ID` to skip workspace lookup and force a specific workspace.
- Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL.
- Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.opencode`, source + timestamp). Browser
diff --git a/docs/providers.md b/docs/providers.md
index 2e82946cd7..2a40a8b42d 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -8,7 +8,7 @@ read_when:
# Providers
-CodexBar currently registers 56 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or
+CodexBar currently registers 57 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or
OpenCode vs OpenCode Go, because the auth source and quota shape differ.
## Fetch strategies (current)
@@ -57,9 +57,9 @@ headers, source selection, provider ordering, and token accounts are stored in `
| Perplexity | Browser cookies/manual cookie/env session token → credits API (`web`). |
| Xiaomi MiMo | Browser cookies → balance/token plan endpoints (`web`). |
| Doubao | API key from config/env → Volcengine Ark chat-completions probe (`api`). |
-| Sakana AI | Manual Cookie header → billing page parser for 5-hour and weekly quota windows (`web`). |
+| Sakana AI | Manual Cookie header → billing page parser for 5-hour/weekly quota windows plus a best-effort pay-as-you-go credit balance (`web`). |
| Abacus AI | Browser cookies → compute points + billing API (`web`). |
-| Mistral | Console billing and Vibe subscription usage via browser cookies (`web`). |
+| Mistral | Console billing, credit balance, and Vibe subscription usage via browser cookies (`web`). |
| DeepSeek | API key from env or token accounts → balance endpoint (`api`). |
| Moonshot | API key from config/env → balance endpoint (`api`). |
| Codebuff | API token from config/env or `codebuff login` credentials → usage API (`api`). |
@@ -71,6 +71,7 @@ headers, source selection, provider ordering, and token accounts are stored in `
| Grok | `grok agent stdio` JSON-RPC `x.ai/billing` (`cli`) → grok.com billing gRPC-web via Chrome session cookies (`web`); local `~/.grok/sessions` signals as fallback. |
| GroqCloud | API key → Prometheus metrics API for request/token/cache-hit rates (`api`). |
| LLM Proxy | API key + base URL → `/v1/quota-stats` aggregate proxy usage (`api`). |
+| ClawRouter | API key + optional base URL → `/v1/usage` monthly budget, spend, and routed-provider usage (`api`). |
| LiteLLM | API key + base URL → `/key/info`, then `/user/info` or `/team/info` budget usage (`api`). |
| Deepgram | API key → project discovery and usage breakdown API (`api`). |
| Chutes | API key from config/env → subscription usage and quota API (`api`). |
@@ -104,7 +105,9 @@ headers, source selection, provider ordering, and token accounts are stored in `
- Admin API shows organization spend/messages summaries with the same inline dashboard pattern as OpenAI API.
- App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`).
- CLI Auto: Web API (`web`) → CLI PTY (`claude`).
-- Local cost usage: scans `CLAUDE_CONFIG_DIR` when set, otherwise `~/.config/claude/projects` and `~/.claude/projects` JSONL files for the configured history window.
+- Local cost usage: scans `CLAUDE_CONFIG_DIR` when set, otherwise `~/.config/claude/projects`,
+ `~/.claude/projects` (including current Claude Desktop Code/Cowork CLI sessions), and nested Claude Desktop
+ local-agent JSONL files for the configured history window.
- Status: Statuspage.io (Anthropic).
- Details: `docs/claude.md`.
@@ -336,6 +339,8 @@ headers, source selection, provider ordering, and token accounts are stored in `
## Sakana AI
- Manual `Cookie:` header from `console.sakana.ai`; no automatic browser import.
- Reads the billing page and surfaces 5-hour and weekly quota windows when present.
+- Also fetches the pay-as-you-go tab (best-effort, never fails the primary quota fetch) for a
+ `fugu`/`fugu-ultra` prepaid credit balance and a rolling usage total.
- Status: none yet.
- Details: `docs/sakana.md`.
@@ -349,11 +354,11 @@ headers, source selection, provider ordering, and token accounts are stored in `
## Mistral
- Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header.
- CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` for billing and Vibe usage requests.
-- Domains: `admin.mistral.ai` for API billing and `console.mistral.ai` for optional Vibe subscription usage. Console requests forward only `csrftoken` and `ory_session_*`; all other admin cookies stay origin-bound.
-- Reads monthly usage and pricing from the Mistral billing API.
+- Domains: `admin.mistral.ai` for API billing and credit balance, and `console.mistral.ai` for optional Vibe subscription usage. Console requests forward only `csrftoken` and `ory_session_*`; all other admin cookies stay origin-bound.
+- Reads monthly usage and pricing from the billing usage endpoint, plus credit balance from the billing credits endpoint, using the Mistral web session.
- Cost is computed client-side from token counts and response pricing.
- Reads Vibe monthly-plan usage percentage and reset time when the console endpoint is available.
-- The menu bar metric can show either pay-as-you-go API spend or monthly-plan usage.
+- The menu bar metric can show either pay-as-you-go API spend or monthly-plan usage; the provider card shows balance when the credits endpoint is available.
- Resets at end of calendar month.
- Status: `https://status.mistral.ai` (link only, no auto-polling).
@@ -429,6 +434,13 @@ headers, source selection, provider ordering, and token accounts are stored in `
- Status: none yet.
- Details: `docs/llm-proxy.md`.
+## ClawRouter
+- API key from the resolved CodexBar config (`providers[].apiKey`) or `CLAWROUTER_API_KEY`.
+- Defaults to `https://clawrouter.openclaw.ai`; optional config `enterpriseHost` or `CLAWROUTER_BASE_URL` selects another HTTPS deployment.
+- Reads `/v1/usage` for the key policy's monthly budget, spend, request/token totals, and per-provider breakdown.
+- Provider rows are data-driven, so any routed provider returned by ClawRouter is displayed without provider-specific CodexBar code.
+- Details: `docs/clawrouter.md`.
+
## AWS Bedrock
- AWS credentials from `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optional `AWS_SESSION_TOKEN`.
- Region from `AWS_REGION` / `AWS_DEFAULT_REGION`, defaulting to `us-east-1`.
diff --git a/docs/repository-size.md b/docs/repository-size.md
new file mode 100644
index 0000000000..7379201b20
--- /dev/null
+++ b/docs/repository-size.md
@@ -0,0 +1,69 @@
+---
+summary: "Repository size findings, prevention checks, and the maintainer-only history cleanup runbook."
+read_when:
+ - Adding large assets or release artifacts
+ - Investigating clone size
+ - Planning a Git history rewrite
+---
+
+# Repository size
+
+The checked-out source tree is not the main cause of CodexBar's clone size. At commit `61ff9320`, tracked files total
+22,292,801 bytes, while the packed Git object database is 103.91 MiB. `CHANGELOG.md` is 150,271 bytes in the current
+tree and all of its historical blobs occupy about 0.55 MB compressed.
+
+Most of the packed history comes from files that are no longer present on `main`:
+
+| Historical path group | Approximate compressed bytes |
+| --- | ---: |
+| `screenshots/` | 39,023,716 |
+| `Quotio/` | 23,141,837 |
+| `CodexBar 2.app/` | 7,088,125 |
+| `CodexBar-0.2.2.zip` | 2,296,323 |
+
+The largest single blob is the retired `Quotio/Resources/Proxy/cli-proxy-api-plus` executable: 49,202,546 bytes
+before compression and about 15.8 MB in the pack. Deleting these files in later commits stopped further growth but
+did not remove their blobs from existing history.
+
+## Prevention
+
+`Scripts/check_repository_size.sh` runs under `make check`. It inspects the exact blobs in Git's index, not mutable
+working-tree copies, and rejects:
+
+- tracked files larger than 2 MiB;
+- app bundles, dSYMs, Xcode results, IPAs, archives, deltas, disk images, and installer packages.
+
+Publish release artifacts through GitHub Releases. Optimize required source images before committing them. If a
+source asset genuinely must exceed the limit, discuss and review the limit change explicitly rather than bypassing
+the check.
+
+For a smaller local checkout when full history is unnecessary, use one of:
+
+```bash
+git clone --filter=blob:none https://github.com/steipete/CodexBar.git
+git clone --depth=1 https://github.com/steipete/CodexBar.git
+```
+
+## One-time history cleanup
+
+A normal pull request cannot reduce the size of already reachable Git objects. Maintainers can recover most of the
+avoidable clone weight only with a coordinated history rewrite. Candidate paths that are absent from current
+`main` are:
+
+```text
+screenshots/
+Quotio/Resources/Proxy/cli-proxy-api-plus
+CodexBar 2.app/
+CodexBar_Dev.app/
+CodexBar-0.2.2.zip
+CodexBar6-2.delta
+CodexBar6-4.delta
+```
+
+Use `git filter-repo` on a fresh mirror, validate all branches and tags, and force-push only during an announced
+maintenance window. This changes commit and tag IDs and requires contributors to re-clone or carefully rebase, so
+the rewrite must not be performed from a normal contributor branch.
+
+An isolated mirror rehearsal using exactly the candidate paths above reduced the packed object database from
+103.90 MiB to 41.99 MiB (59.6%) and passed `git fsck --full --no-dangling`. Treat this as a reproducible baseline,
+not authorization to rewrite the canonical repository.
diff --git a/docs/sakana.md b/docs/sakana.md
index 2af31db60d..2dbdad267c 100644
--- a/docs/sakana.md
+++ b/docs/sakana.md
@@ -1,9 +1,10 @@
---
-summary: "Sakana AI provider: manual Cookie header, billing page parser, 5-hour and weekly quota windows."
+summary: "Sakana AI provider: manual Cookie header, billing page parser, 5-hour/weekly quota windows, and pay-as-you-go credit balance."
read_when:
- Adding or modifying the Sakana AI provider
- Debugging Sakana AI cookie import or quota parsing
- Adjusting Sakana AI menu labels or reset window display
+ - Debugging Sakana pay-as-you-go credit balance or usage-total parsing
---
# Sakana AI
@@ -43,10 +44,52 @@ Alternatively, set the environment variable `SAKANA_COOKIE` to the raw cookie he
`"MMMM d, yyyy 'at' h:mm a"` format strings.
- Plan name and price label (e.g. `Standard $20/mo`) are joined and surfaced as the `loginMethod` identity field for
plan display in the menu.
-- Token cost tracking (`supportsTokenCost: false`): not supported; cost summary is unavailable.
-- Credits row (`supportsCredits: false`): not shown.
+- Token cost tracking (`supportsTokenCost: false`): not supported; cost summary is unavailable. Sakana has no
+ organization-level usage/cost API to query historically, only the per-request `usage` object returned by chat
+ completions calls (which CodexBar never makes), so there is no local-log source to scan the way Claude/Codex are.
+- Credits row (`supportsCredits: false`): not shown. The shared credits-card UI path (`MenuCardView+Costs.swift`)
+ has no Sakana branch and would just render the static `creditsHint` string instead of the fetched balance, so
+ `supportsCredits` stays off; the balance is surfaced explicitly instead (see below).
- Widget support: not currently available for Sakana AI.
+## Pay-as-you-go credits
+
+Sakana also sells prepaid credit for pay-as-you-go API usage (the model IDs `fugu` and `fugu-ultra`), separate from
+the subscription quota windows above. `console.sakana.ai/billing` renders this data server-side under its
+"Pay as you go" tab, but that tab's markup is only present in the HTML response when the request URL includes
+`?tab=payAsYouGo` — the default `/billing` response (used for the subscription quota fetch above) does not include
+it. CodexBar issues a **second, best-effort** GET to `https://console.sakana.ai/billing?tab=payAsYouGo` with the same
+cookie header alongside the subscription request — skipped entirely (no request made) when
+`context.includeOptionalUsage` is `false`, i.e. Settings → Advanced → "Show optional credits and extra usage" is
+disabled.
+
+- **Credit balance**: parsed from the `Credit balance
` card's adjacent `tabular-nums` amount.
+- **Recent usage total**: parsed from the `Usage` chart header's `Total: $…` text, covering whatever date range is
+ currently selected on the console (defaults to the last 30 days). React renders this text with ``
+ hydration-boundary comments splitting the label from the amount; the parser strips those before reading the value.
+- **Date range label**: the raw text of the "Usage date range" picker button (e.g. `Jun 02, 2026 - Jul 01, 2026`),
+ kept only as context — CodexBar does not currently interpret it as start/end dates.
+
+This second fetch never throws and never blocks the primary result: if it fails (network error, non-200, wrong
+origin, empty body, or the expected markup isn't found), the pay-as-you-go fields are simply absent from that
+refresh and the subscription quota windows are returned exactly as before. An account with no pay-as-you-go credit
+purchased still returns a `$0.00` balance (the card is always rendered), so absence here almost always means the
+request itself failed rather than "no credit."
+
+The optional request runs concurrently with the required subscription request and has its own five-second bound.
+It is cancelled when the required request fails or the caller cancels, so it cannot add a second full request
+timeout or outlive the refresh that started it.
+
+- Menu: an `Extra usage` card shows `Balance: $X.XX` and, when available, `Usage: $X.XX` alongside the quota windows.
+ The values are gated on Settings → Advanced → "Show optional credits and extra usage" at **both**
+ the fetch and the render layer: turning the setting off only rebuilds the menu without an immediate refetch, so a
+ previously-fetched balance would otherwise linger in the cached snapshot until the next refresh. Both the live
+ menu-card model and the text descriptor hide the values whenever the setting is off, independent of cached data.
+- Not shown in the menu bar text: unlike some other credits-only providers, Sakana already has real 5-hour/weekly
+ rate windows, and the "secondary metric" preference that would otherwise select an alternate menu bar display is
+ the legitimate way to show the *weekly* window there. Reusing it for the PAYG balance would silently replace the
+ weekly percentage for anyone who picks that preference, so the balance is menu-only for now.
+
## CLI usage
```
@@ -75,9 +118,13 @@ There is no `codexbar config set` command for `cookieHeader`; use one of the pat
- `Sources/CodexBarCore/Providers/Sakana/`
- `SakanaProviderDescriptor.swift` — provider metadata, fetch plan, CLI config
- `SakanaSettingsReader.swift` — `SAKANA_COOKIE` env key, cookie normalizer
- - `SakanaUsageFetcher.swift` — billing-page HTML fetch and quota parser
+ - `SakanaUsageFetcher.swift` — billing-page HTML fetch and quota parser; also defines
+ `SakanaPayAsYouGoSnapshot` and the pay-as-you-go tab fetch/parser
- `Sources/CodexBar/Providers/Sakana/`
- `SakanaProviderImplementation.swift` — settings UI, availability check
- `SakanaSettingsStore.swift` — `sakanaCookieHeader` settings binding
+- `Sources/CodexBar/MenuCardView+Costs.swift` — live menu-card balance and usage section
+- `Sources/CodexBar/MenuDescriptor.swift` — text-descriptor balance and usage rows
- `Tests/CodexBarTests/SakanaUsageFetcherTests.swift` — parser regression tests
-- Dashboard: `https://console.sakana.ai/billing`
+- Dashboard: `https://console.sakana.ai/billing` (subscription tab), `https://console.sakana.ai/billing?tab=payAsYouGo`
+ (pay-as-you-go tab)
diff --git a/docs/screenshots/claude-swap-accounts-synthetic-proof.png b/docs/screenshots/claude-swap-accounts-synthetic-proof.png
new file mode 100644
index 0000000000..b24d8ecadb
Binary files /dev/null and b/docs/screenshots/claude-swap-accounts-synthetic-proof.png differ
diff --git a/docs/screenshots/clawrouter-settings.png b/docs/screenshots/clawrouter-settings.png
new file mode 100644
index 0000000000..46ef0754aa
Binary files /dev/null and b/docs/screenshots/clawrouter-settings.png differ
diff --git a/docs/screenshots/clawrouter-usage.png b/docs/screenshots/clawrouter-usage.png
new file mode 100644
index 0000000000..7bca8015bd
Binary files /dev/null and b/docs/screenshots/clawrouter-usage.png differ
diff --git a/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png b/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png
new file mode 100644
index 0000000000..cb8d287ec3
Binary files /dev/null and b/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png differ
diff --git a/docs/screenshots/codexbar-weekly-caps-session-fix.png b/docs/screenshots/codexbar-weekly-caps-session-fix.png
new file mode 100644
index 0000000000..e3b6326920
Binary files /dev/null and b/docs/screenshots/codexbar-weekly-caps-session-fix.png differ
diff --git a/docs/screenshots/cost-chart-yaxis-labels.png b/docs/screenshots/cost-chart-yaxis-labels.png
new file mode 100644
index 0000000000..14ad4d6cc7
Binary files /dev/null and b/docs/screenshots/cost-chart-yaxis-labels.png differ
diff --git a/docs/screenshots/current-merged-menu-redacted.png b/docs/screenshots/current-merged-menu-redacted.png
new file mode 100644
index 0000000000..d76087308b
Binary files /dev/null and b/docs/screenshots/current-merged-menu-redacted.png differ
diff --git a/docs/sessions.md b/docs/sessions.md
new file mode 100644
index 0000000000..0e70787013
--- /dev/null
+++ b/docs/sessions.md
@@ -0,0 +1,17 @@
+# Agent Sessions
+
+CodexBar can list live Codex and Claude Code sessions on this Mac and other Macs or Linux hosts reachable over SSH.
+
+Enable **Settings → General → Sessions**. Local sessions refresh every 30 seconds. Remote sessions refresh every 60 seconds and whenever the menu opens. Tailscale discovery includes online macOS and Linux peers; add extra SSH destinations as a comma-separated list, such as `user@host`.
+
+The menu groups local sessions first, followed by each remote host. A filled dot is active; an empty dot is idle. Select a local row to activate its terminal, editor, or desktop app. The first focus attempt can request macOS Accessibility permission so CodexBar can raise the matching window. Remote rows run the same focus command over SSH.
+
+The CLI exposes the same scanner:
+
+```console
+codexbar sessions
+codexbar sessions --json
+codexbar sessions focus
+```
+
+Remote hosts need key-based, non-interactive SSH and either `codexbar` on `PATH` or CodexBar installed in `/Applications`.
diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs
index 8822da8e21..d640a78828 100644
--- a/docs/site-locales.mjs
+++ b/docs/site-locales.mjs
@@ -60,6 +60,10 @@ export const localeCatalog = [
"code": "uk",
"name": "Українська"
},
+ {
+ "code": "ru",
+ "name": "Русский"
+ },
{
"code": "id",
"name": "Bahasa Indonesia"
@@ -77,6 +81,10 @@ export const localeCatalog = [
"code": "th",
"name": "ไทย"
},
+ {
+ "code": "gl",
+ "name": "Galego"
+ },
{
"code": "ca",
"name": "Català"
@@ -2203,6 +2211,147 @@ export const localeMessages = {
"widgets.dragToDesktop": "Перетягніть віджет на робочий стіл…",
"detail.more": "Більше"
},
+ "ru": {
+ "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню",
+ "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 56 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.",
+ "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 56 AI-провайдеров для кодинга прямо из строки меню macOS.",
+ "nav.primary": "Основная навигация",
+ "nav.language": "Язык",
+ "nav.docs": "Документация",
+ "nav.source": "Исходный код",
+ "nav.download": "Скачать",
+ "theme.toLight": "Переключиться на светлую тему",
+ "theme.toDark": "Переключиться на темную тему",
+ "hero.prefix": "Все лимиты AI-кодинга,",
+ "hero.accent": "в строке меню.",
+ "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.",
+ "hero.download": "Скачать для macOS",
+ "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew",
+ "providers.title": "56 провайдеров,{mobileBreak}одна строка меню",
+ "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.",
+ "providers.yourProvider": "Ваш провайдер",
+ "providers.authoringGuide": "Руководство по добавлению",
+ "auth.cookies": "Cookie",
+ "auth.cookiesGo": "Cookie + Go",
+ "auth.deviceFlow": "Device flow",
+ "auth.bearerLocal": "Bearer / Local",
+ "auth.apiKey": "API-ключ",
+ "auth.token": "Токен",
+ "auth.legacyApi": "Старый API",
+ "auth.local": "Локально",
+ "auth.apiKeyCookies": "API-ключ + Cookie",
+ "auth.virtualKey": "Виртуальный ключ",
+ "showcase.alt": "Поповер CodexBar в строке меню с плитками провайдеров, индикаторами использования, обратным отсчетом до сброса и данными о кредитах.",
+ "features.resetsTitle": "Сбросы, которые можно планировать",
+ "features.resetsBody": "Сессионные, недельные и месячные окна по каждому провайдеру с обратным отсчетом до следующего сброса — больше не нужно гадать, стоит ли начинать долгую задачу.",
+ "features.spendTitle": "Кредиты, расходы и сканирование стоимости",
+ "features.spendBody": "Балансы кредитов и месячные расходы там, где провайдер их показывает, плюс настраиваемое локальное сканирование стоимости для Codex и Claude.",
+ "features.statusTitle": "Статус и инциденты",
+ "features.statusBody": "Опрос статуса провайдеров показывает бейджи инцидентов в меню и индикатор поверх значка в строке меню.",
+ "features.cliTitle": "CLI и виджеты",
+ "features.cliBody": "Виджеты WidgetKit для поддерживаемых провайдеров и встроенный CLI {codexbar} для скриптов и CI — сборки для macOS и Linux в каждом релизе.",
+ "detail.installTitle": "Установка и обновления",
+ "detail.installBody": "Универсальное приложение через {releases} с автообновлениями Sparkle или через {cask} (обновления через {upgrade}).",
+ "detail.cliTitle": "CLI",
+ "detail.cliBody": "Встроенный бинарный файл {codexbar} плюс отдельные tarball-архивы для macOS и Linux в каждом релизе. В Linux: {linuxCommand}.",
+ "detail.privacyTitle": "Конфиденциальность",
+ "detail.privacyBody": "Повторно использует существующие сессии провайдеров — OAuth, device flow, API-ключи, cookie браузера, локальные файлы — поэтому пароли не сохраняются. Примечания аудита в {issue}.",
+ "detail.permissionsTitle": "Разрешения macOS",
+ "detail.permissionsBody": "Полный доступ к диску для cookie Safari, доступ к Keychain для расшифровки cookie и OAuth-потоков, а также запросы «Файлы и папки», когда вспомогательные процессы провайдеров читают каталоги проектов.",
+ "footer.builtBy": "Создал",
+ "footer.changelog": "История изменений",
+ "clipboard.copy": "Скопировать в буфер обмена",
+ "clipboard.copied": "Скопировано",
+ "auth.oauth": "OAuth",
+ "auth.cli": "CLI",
+ "auth.gcloud": "gcloud",
+ "nav.home": "Главная CodexBar",
+ "nav.projectLinks": "Ссылки проекта",
+ "nav.sourceGitHub": "Исходный код CodexBar на GitHub",
+ "nav.sourceStars": "исходный код",
+ "nav.displayDownload": "Отображение и загрузка",
+ "clipboard.copyBrew": "Скопировать команду установки Homebrew",
+ "hero.badgeStart": "Лимиты использования AI",
+ "hero.badgeEnd": "для macOS",
+ "hero.descriptionShort": "Отслеживайте окна использования, балансы кредитов и время до сброса лимитов в AI-инструментах, за которые вы платите.",
+ "hero.fineprintShort": "Бесплатно и с открытым исходным кодом · macOS 14+",
+ "features.title": "Лимиты,\u00a0расходы\u00a0и статус",
+ "mockup.openPanelAria": "CodexBar открыт в строке меню macOS",
+ "mockup.macbookAlt": "Строка меню MacBook",
+ "mockup.menuBarAria": "Элементы статуса в строке меню macOS",
+ "mockup.menuIconAria": "Значок CodexBar в строке меню",
+ "mockup.usagePanelAria": "Открытая панель использования CodexBar",
+ "mockup.updatedJustNow": "Обновлено только что",
+ "mockup.plus": "Plus",
+ "mockup.session": "Сессия",
+ "mockup.weekly": "Неделя",
+ "mockup.left60": "Осталось 60%",
+ "mockup.left73": "Осталось 73%",
+ "mockup.left89": "Осталось 89%",
+ "mockup.left98": "Осталось 98%",
+ "mockup.reserve2": "2% в резерве",
+ "mockup.reserve5": "5% в резерве",
+ "mockup.onPace": "По плану",
+ "mockup.resets2h53m": "Сброс через 2 ч 53 мин",
+ "mockup.resets4h12m": "Сброс через 4 ч 12 мин",
+ "mockup.resets5d4h": "Сброс через 5 д 4 ч",
+ "mockup.resets6d23h": "Сброс через 6 д 23 ч",
+ "mockup.runsOut4d21h": "Закончится через 4 д 21 ч",
+ "mockup.lastsUntilReset": "Хватит до сброса",
+ "mockup.limitResetCredits": "Кредиты сброса лимита",
+ "mockup.nextExpires28d2h": "Следующий истекает через 28 д 2 ч",
+ "mockup.nextExpires27d21h": "Следующий истекает через 27 д 21 ч",
+ "mockup.manualResetAvailable": "Доступен 1 сброс",
+ "mockup.recentUsageAria": "Недавнее использование Codex",
+ "mockup.today": "Сегодня",
+ "mockup.last31DaysCost": "Расходы за 31 день",
+ "mockup.last31DaysTokens": "Токены за 31 день",
+ "mockup.latestTokens": "Токены в последнем запросе",
+ "mockup.usageChartAria": "График использования за 31 день",
+ "mockup.topModel": "Основная модель: Example-1",
+ "mockup.estimatedLogs": "Оценка по локальным логам Codex для выбранного акка...",
+ "mockup.credits": "Кредиты",
+ "mockup.zeroLeft": "Осталось 0",
+ "mockup.buyCredits": "Купить кредиты...",
+ "mockup.partialDegradation": "Частичное ухудшение",
+ "mockup.twoIncidents": "2 инцидента",
+ "mockup.majorOutage": "Серьёзный сбой",
+ "mockup.degraded": "Работает с перебоями",
+ "mockup.operational": "Работает нормально",
+ "cli.outputPreviewAria": "Предпросмотр вывода CLI CodexBar",
+ "cli.left99": "Осталось 99%",
+ "cli.left100": "Осталось 100%",
+ "cli.left79": "Осталось 79%",
+ "cli.left74": "Осталось 74%",
+ "cli.resets5h": "Сброс через 5 ч",
+ "cli.resets7d": "Сброс через 7 д",
+ "cli.resets23d18h": "Сброс через 23 д 18 ч",
+ "cli.available1": "Доступен 1",
+ "cli.nextResetCredit": "Следующий кредит сброса",
+ "cli.expires27d22h": "истекает через 27 д 22 ч",
+ "cli.account": "Аккаунт",
+ "cli.plan": "Тариф",
+ "cli.total": "Всего",
+ "cli.auto": "Авто",
+ "cli.titleLine2": "в CLI",
+ "cli.lead": "Встроенный бинарный файл {codexbar} с теми же данными об использовании, что и в строке меню, — для скриптов, CI и быстрых проверок в терминале.",
+ "cli.note": "Отдельные tarball-архивы для macOS и Linux в каждом релизе.",
+ "cli.privacy": "CodexBar повторно использует существующие сессии провайдеров — пароли не сохраняются на диске.",
+ "widgets.titleLine2": "виджеты",
+ "widgets.lead": "Виджеты WidgetKit для поддерживаемых провайдеров: окна использования, время до сброса и расходы, если они доступны. Разместите их на экране «Домой» или рабочем столе.",
+ "widgets.note": "Малый, средний и большой размеры. Переключайте провайдеров, не покидая виджет.",
+ "widgets.galleryAria": "Предпросмотр галереи виджетов macOS",
+ "widgets.categoriesAria": "Категории виджетов",
+ "widgets.allWidgets": "Все виджеты",
+ "widgets.metricCaption": "Компактный виджет для кредитов или расходов.",
+ "widgets.historyCaption": "График истории использования с последними итогами.",
+ "widgets.usageCaption": "Сессионное и недельное использование с кредитами и расходами.",
+ "widgets.switcherCaption": "Виджет использования с переключателем провайдеров.",
+ "widgets.burndownCaption": "Оставшийся бюджет в сравнении с идеальным равномерным темпом расходования.",
+ "widgets.burndownCombinedCaption": "Графики расходования сессионного и недельного лимитов в одной плитке.",
+ "widgets.dragToDesktop": "Перетащите виджет на рабочий стол…",
+ "detail.more": "Подробнее"
+ },
"id": {
"meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda",
"meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 56 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.",
@@ -2767,6 +2916,147 @@ export const localeMessages = {
"widgets.dragToDesktop": "ลากวิดเจ็ตไปยังเดสก์ท็อป…",
"detail.more": "เพิ่มเติม"
},
+ "gl": {
+ "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús",
+ "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 56 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.",
+ "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 56 provedores de programación con IA desde a barra de menús de macOS.",
+ "nav.primary": "Principal",
+ "nav.language": "Idioma",
+ "nav.docs": "Documentación",
+ "nav.source": "Código fonte",
+ "nav.download": "Descargar",
+ "theme.toLight": "Cambiar ao tema claro",
+ "theme.toDark": "Cambiar ao tema escuro",
+ "hero.prefix": "Todos os límites de programación con IA,",
+ "hero.accent": "na túa barra de menús.",
+ "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.",
+ "hero.download": "Descargar para macOS",
+ "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew",
+ "providers.title": "56 provedores,{mobileBreak}unha barra de menús",
+ "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.",
+ "providers.yourProvider": "O teu provedor",
+ "providers.authoringGuide": "Guía de creación",
+ "auth.oauth": "OAuth",
+ "auth.cli": "CLI",
+ "auth.gcloud": "gcloud",
+ "auth.cookies": "Cookies",
+ "auth.cookiesGo": "Cookies + Go",
+ "auth.deviceFlow": "Fluxo de dispositivo",
+ "auth.bearerLocal": "Bearer / Local",
+ "auth.apiKey": "Chave de API",
+ "auth.token": "Token",
+ "auth.legacyApi": "API antiga",
+ "auth.local": "Local",
+ "auth.apiKeyCookies": "Chave de API + cookies",
+ "auth.virtualKey": "Chave virtual",
+ "showcase.alt": "Panel emerxente de CodexBar na barra de menús con tarxetas de provedores, barras de uso, contas atrás de restablecemento e detalles de crédito.",
+ "features.resetsTitle": "Restablecementos que podes planificar",
+ "features.resetsBody": "Xanelas de sesión, semanais e mensuais por provedor con contas atrás ata o seguinte restablecemento — sen ter que adiviñar se podes comezar unha tarefa longa.",
+ "features.spendTitle": "Créditos, gasto e análises de custos",
+ "features.spendBody": "Saldos de crédito e gasto mensual cando o provedor os ofrece, ademais de análises locais de custos configurables para Codex e Claude.",
+ "features.statusTitle": "Estado e incidencias",
+ "features.statusBody": "A consulta do estado dos provedores mostra distintivos de incidencias no menú e un indicador sobre a icona da barra.",
+ "features.cliTitle": "CLI e widgets",
+ "features.cliBody": "Widgets de WidgetKit para os provedores compatibles e unha CLI {codexbar} incluída para scripts e CI — compilacións para macOS e Linux en cada versión.",
+ "detail.installTitle": "Instalación e actualizacións",
+ "detail.installBody": "Aplicación universal mediante {releases} con actualizacións automáticas de Sparkle ou mediante {cask} (actualizacións con {upgrade}).",
+ "detail.cliTitle": "CLI",
+ "detail.cliBody": "Un binario {codexbar} incluído, ademais de tarballs independentes para macOS e Linux en cada versión. En Linux: {linuxCommand}.",
+ "detail.privacyTitle": "Privacidade",
+ "detail.privacyBody": "Reutiliza as sesións existentes dos provedores — OAuth, fluxo de dispositivo, chaves de API, cookies do navegador e ficheiros locais — polo que non se gardan contrasinais. Notas da auditoría en {issue}.",
+ "detail.permissionsTitle": "Permisos de macOS",
+ "detail.permissionsBody": "Acceso completo ao disco para as cookies de Safari, acceso ao Chaveiro para descifrar cookies e fluxos OAuth, e solicitudes de Ficheiros e cartafoles cando os auxiliares dos provedores len os directorios dos proxectos.",
+ "footer.builtBy": "Creado por",
+ "footer.changelog": "Rexistro de cambios",
+ "clipboard.copy": "Copiar ao portapapeis",
+ "clipboard.copied": "Copiado",
+ "nav.home": "Inicio de CodexBar",
+ "nav.projectLinks": "Ligazóns do proxecto",
+ "nav.sourceGitHub": "Código fonte de CodexBar en GitHub",
+ "nav.sourceStars": "código fonte",
+ "nav.displayDownload": "Visualización e descarga",
+ "clipboard.copyBrew": "Copiar a orde de instalación de Homebrew",
+ "hero.badgeStart": "Límites de uso da IA",
+ "hero.badgeEnd": "para macOS",
+ "hero.descriptionShort": "Controla as xanelas de uso, os saldos de crédito e as contas atrás de restablecemento das ferramentas de IA polas que realmente pagas.",
+ "hero.fineprintShort": "Gratuíto e de código aberto · macOS 14+",
+ "features.title": "Límites,\u00a0gasto e estado",
+ "mockup.openPanelAria": "CodexBar aberto na barra de menús de macOS",
+ "mockup.macbookAlt": "Barra de menús do MacBook",
+ "mockup.menuBarAria": "Elementos de estado da barra de menús de macOS",
+ "mockup.menuIconAria": "Icona de CodexBar na barra de menús",
+ "mockup.usagePanelAria": "Abrir o panel de uso de CodexBar",
+ "mockup.updatedJustNow": "Actualizado agora mesmo",
+ "mockup.plus": "Plus",
+ "mockup.session": "Sesión",
+ "mockup.weekly": "Semanal",
+ "mockup.left60": "60% restante",
+ "mockup.left73": "73% restante",
+ "mockup.left89": "89% restante",
+ "mockup.left98": "98% restante",
+ "mockup.reserve2": "2% en reserva",
+ "mockup.reserve5": "5% en reserva",
+ "mockup.onPace": "Ao ritmo previsto",
+ "mockup.resets2h53m": "Restablécese en 2 h 53 min",
+ "mockup.resets4h12m": "Restablécese en 4 h 12 min",
+ "mockup.resets5d4h": "Restablécese en 5 d 4 h",
+ "mockup.resets6d23h": "Restablécese en 6 d 23 h",
+ "mockup.runsOut4d21h": "Esgótase en 4 d 21 h",
+ "mockup.lastsUntilReset": "Dura ata o restablecemento",
+ "mockup.limitResetCredits": "Créditos para restablecer límites",
+ "mockup.nextExpires28d2h": "O seguinte caduca en 28 d 2 h",
+ "mockup.nextExpires27d21h": "O seguinte caduca en 27 d 21 h",
+ "mockup.manualResetAvailable": "1 restablecemento dispoñible",
+ "mockup.recentUsageAria": "Uso recente de Codex",
+ "mockup.today": "Hoxe",
+ "mockup.last31DaysCost": "Custo dos últimos 31 días",
+ "mockup.last31DaysTokens": "Tokens dos últimos 31 días",
+ "mockup.latestTokens": "Tokens máis recentes",
+ "mockup.usageChartAria": "Gráfica de uso de 31 días",
+ "mockup.topModel": "Modelo principal: Example-1",
+ "mockup.estimatedLogs": "Estimado a partir dos rexistros locais de Codex para a conta seleccionada...",
+ "mockup.credits": "Créditos",
+ "mockup.zeroLeft": "0 restante",
+ "mockup.buyCredits": "Comprar créditos...",
+ "mockup.partialDegradation": "Degradación parcial",
+ "mockup.twoIncidents": "2 incidencias",
+ "mockup.majorOutage": "Interrupción grave",
+ "mockup.degraded": "Degradado",
+ "mockup.operational": "Operativo",
+ "cli.outputPreviewAria": "Vista previa da saída da CLI de CodexBar",
+ "cli.left99": "99% restante",
+ "cli.left100": "100% restante",
+ "cli.left79": "79% restante",
+ "cli.left74": "74% restante",
+ "cli.resets5h": "Restablécese en 5 h",
+ "cli.resets7d": "Restablécese en 7 d",
+ "cli.resets23d18h": "Restablécese en 23 d 18 h",
+ "cli.available1": "1 dispoñible",
+ "cli.nextResetCredit": "Seguinte crédito de restablecemento",
+ "cli.expires27d22h": "caduca en 27 d 22 h",
+ "cli.account": "Conta",
+ "cli.plan": "Plan",
+ "cli.total": "Total",
+ "cli.auto": "Auto",
+ "cli.titleLine2": "na CLI",
+ "cli.lead": "Un binario {codexbar} incluído coa mesma saída de uso que a barra de menús — scripts, CI e comprobacións rápidas no terminal.",
+ "cli.note": "Tarballs independentes para macOS e Linux en cada versión.",
+ "cli.privacy": "CodexBar reutiliza as sesións existentes dos provedores — non se gardan contrasinais no disco.",
+ "widgets.titleLine2": "widgets",
+ "widgets.lead": "Widgets de WidgetKit para os provedores compatibles — xanelas de uso, contas atrás de restablecemento e gasto cando estea dispoñible. Colócaos na pantalla de inicio e no escritorio.",
+ "widgets.note": "Tamaños pequeno, mediano e grande. Cambia de provedor sen saír do widget.",
+ "widgets.galleryAria": "Vista previa da galería de widgets de macOS",
+ "widgets.categoriesAria": "Categorías de widgets",
+ "widgets.allWidgets": "Todos os widgets",
+ "widgets.metricCaption": "Widget compacto para créditos ou custos.",
+ "widgets.historyCaption": "Gráfica do historial de uso cos totais recentes.",
+ "widgets.usageCaption": "Uso da sesión e semanal con créditos e custos.",
+ "widgets.switcherCaption": "Widget de uso cun selector de provedor.",
+ "widgets.burndownCaption": "Orzamento restante comparado cun ritmo ideal de consumo constante.",
+ "widgets.burndownCombinedCaption": "Gráficas de consumo da sesión e da semana nunha soa tarxeta.",
+ "widgets.dragToDesktop": "Arrastra un widget ao escritorio…",
+ "detail.more": "Máis"
+ },
"ca": {
"meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús",
"meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 56 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.",
diff --git a/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md b/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md
new file mode 100644
index 0000000000..d91356b639
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md
@@ -0,0 +1,240 @@
+---
+summary: "Decision-ready design for provider pins beside the merged icon and focus-aware merged-icon selection."
+read_when:
+ - Implementing separate provider icons while Merge Icons is enabled
+ - Implementing focus-aware provider presentation
+ - Changing merged-icon provider precedence or status-item layout
+---
+
+# Provider Presentation and Focus-Aware Icon — Design
+
+**Status:** accepted on 2026-07-04
+**Date:** 2026-07-01
+**Issues:** [#1167](https://github.com/steipete/CodexBar/issues/1167),
+[#780](https://github.com/steipete/CodexBar/issues/780)
+
+## Decision summary
+
+Two opt-in controls should share one presentation policy:
+
+1. **Also show separately:** while Merge Icons is enabled, selected providers get stable provider status items in
+ addition to the merged item.
+2. **Merged icon source:** choose `Current selection`, `Highest usage`, or `Frontmost provider app`.
+
+Provider pins affect status-item layout only. Frontmost-app matching affects the collapsed merged icon only. Neither
+feature changes the selected menu tab, selected account, enabled providers, or Overview contents.
+
+Implementation should ship provider pins and privacy-safe native app matching as separate PRs. Terminal-session
+inference remains a later, separately reviewed opt-in.
+
+## Current behavior
+
+With multiple providers and Merge Icons enabled, CodexBar creates one merged status item and removes all provider
+status items. `primaryProviderForUnifiedIcon()` chooses the merged icon from highest usage, the first Overview
+provider, the selected menu provider, or the first enabled provider. Menu selection is persisted.
+
+
+
+The current implementation spreads the binary merged/split assumption across status-item visibility, menu
+attachment, icon animation, and observation signatures. Adding either request as another independent conditional
+would let those paths disagree.
+
+## Goals
+
+- Let users pin important providers beside the merged icon without losing the unified menu.
+- Let the collapsed merged icon follow a recognized, enabled frontmost provider app.
+- Preserve explicit menu, account, and Overview selection.
+- Keep provider data siloed and avoid new sensitive-data collection.
+- Keep status-item identities stable across refreshes and focus changes.
+- Use an event-driven monitor; no polling or idle battery cost.
+
+## Non-goals
+
+- Filtering providers out of the merged menu or Overview.
+- A second provider-content subset; Overview selection remains the only content subset.
+- Switching provider accounts based on focus.
+- Reading window titles, terminal scrollback, command lines, working directories, environment variables, prompts, or
+ shell history.
+- Requiring Accessibility permission for the native-app MVP.
+- Inferring a terminal tab's provider in the first implementation.
+
+## User model
+
+### Provider pins
+
+Preferences → Display → Menu Bar gains **Also show separately** when Merge Icons is enabled and at least two
+providers are enabled. Its Configure popover lists enabled providers in provider order.
+
+- A pinned provider gets its own icon and provider menu.
+- The provider remains in the merged switcher and Overview eligibility.
+- The merged icon remains visible even when every enabled provider is pinned.
+- Turning Merge Icons off restores existing all-separate behavior. Pins remain stored but dormant.
+- Disabling a pinned provider hides its separate icon but preserves the pin for a later re-enable.
+
+Pins are additive, not subtractive. This avoids a provider silently disappearing from the unified menu and keeps one
+consistent place for cross-provider comparison.
+
+### Merged icon source
+
+Replace the current highest-usage toggle with a picker:
+
+| Choice | Collapsed merged icon | Menu opens on |
+| --- | --- | --- |
+| Current selection | Existing resolver | Existing persisted selection or Overview |
+| Highest usage | Existing closest-to-limit resolver | Existing persisted selection or Overview |
+| Frontmost provider app | Recognized enabled provider; otherwise existing resolver | Existing persisted selection or Overview |
+
+The existing `menuBarShowsHighestUsage = true` preference migrates to `highestUsage`; false migrates to
+`currentSelection`. No new provider filter is introduced. This avoids overlap with #1781 and preserves the existing
+Overview provider subset.
+
+## Presentation policy
+
+Introduce a pure model consumed by status-item lifecycle, menus, icons, animation, and tests:
+
+```swift
+struct StatusItemLayout: Equatable, Sendable {
+ let showsMergedItem: Bool
+ let separateProviders: [UsageProvider]
+}
+
+struct UnifiedIconContext: Equatable, Sendable {
+ let source: UnifiedIconSource
+ let focusedProvider: UsageProvider?
+ let isMergedMenuOpen: Bool
+}
+```
+
+`StatusItemLayout.resolve(...)` takes provider order, enabled providers, `mergeIcons`, stored pins, and fallback state.
+It returns identities only; it never creates AppKit objects.
+
+| State | Merged item | Separate items |
+| --- | --- | --- |
+| Merge off | hidden | all enabled providers, or existing fallback |
+| Merge on, fewer than two displayable providers | existing single-provider behavior | existing provider/fallback item |
+| Merge on, two or more displayable providers | visible | enabled pinned providers in provider order |
+
+`updateVisibility()`, `rebuildProviderStatusItems()`, `attachMenus()`, icon observation signatures, and animation all
+consume the same resolved layout. No path independently derives merged versus split visibility.
+
+The merged item's content and animation inputs continue to include all enabled providers. Each separate provider item
+uses only that provider. Existing render-signature caches prevent unchanged duplicate work.
+
+## Focus-aware resolution
+
+`FrontmostProviderMonitor` observes `NSWorkspace.didActivateApplicationNotification` and seeds itself from
+`NSWorkspace.frontmostApplication` at startup. It publishes an in-memory `FocusMatch` only when a provider-owned
+bundle-identifier mapping recognizes the application.
+
+Provider mappings belong to provider descriptors rather than a central switch. A mapping is eligible only when its
+provider is enabled. Unknown or disabled apps produce no override.
+
+The frontmost match is an ephemeral input to unified-icon resolution:
+
+```text
+merged menu open ───────────────► keep current menu presentation stable
+frontmost mode + enabled match ─► render matched provider on collapsed merged item
+otherwise ──────────────────────► run the existing resolver unchanged
+```
+
+Focus changes never write `selectedMenuProvider`, `mergedMenuLastSelectedWasOverview`, account selection, or provider
+configuration. When the menu closes, the latest focus match can affect the next collapsed render. Activation bursts
+are coalesced on the main actor; no timer runs while the frontmost app is unchanged.
+
+### Terminal boundary
+
+`NSWorkspace` can identify Terminal, iTerm, or Warp as the frontmost app, but not which tab or pane is active. Treating
+the terminal application itself as one provider would be incorrect.
+
+If terminal inference is later approved, add provider-source adapters behind a second off-by-default preference. Each
+adapter must return only a provider identity and confidence/source metadata. It must not retain or log raw terminal
+content. Permission prompts, supported terminal applications, ambiguity behavior, and energy impact need their own
+design and Tahoe proof before merge.
+
+## Settings and migration
+
+- `separateProvidersWhenMerged: [UsageProvider]`: normalized, de-duplicated provider order; unknown values ignored.
+- `unifiedIconSource: UnifiedIconSource`: `currentSelection`, `highestUsage`, or `frontmostApp`.
+- Read the legacy `menuBarShowsHighestUsage` value only when the new enum key is absent.
+- Preserve pins for disabled providers; expose only enabled providers in the Configure popover.
+- Turning features off does not delete their stored choices.
+
+## Lifecycle and privacy
+
+- Start `FrontmostProviderMonitor` only when `frontmostApp` is selected; stop and unregister when another source is
+ selected or the controller shuts down.
+- Store only the current matched provider and match source in memory.
+- Do not persist frontmost-app history.
+- Logs may contain the resolved provider and source category, never titles, arguments, paths, or raw application data.
+- Native bundle matching uses public process metadata and requires no new permission.
+
+## Failure and edge cases
+
+| Case | Behavior |
+| --- | --- |
+| Pinned provider disabled | Separate item removed; pin retained dormant |
+| Provider order changes | Separate items follow new provider order with stable identities |
+| Every enabled provider pinned | Merged item plus every pinned provider remain visible |
+| Merge Icons disabled | Existing all-separate behavior; pin controls disabled |
+| One displayable provider remains | Existing single-provider behavior; no duplicate merged/provider pair |
+| Focused app unrecognized | Existing merged-icon resolver |
+| Focused provider disabled | Existing merged-icon resolver |
+| Focus changes while menu open | Menu and icon presentation stay stable until close |
+| App terminates without another activation | Re-evaluate `frontmostApplication`; fall back if unmatched |
+| Screen/menu-bar reconstruction | Recreate identities from `StatusItemLayout`; do not discard stored pins |
+
+## Testing
+
+### Pure model
+
+- Table-test `StatusItemLayout` for merge on/off, zero/one/many providers, every pin subset, provider reordering,
+ disabled pins, fallback state, and all-providers-pinned.
+- Table-test unified-icon precedence for all three sources, menu-open suppression, disabled matches, unknown apps,
+ Overview selection, explicit provider selection, and missing snapshots.
+- Test migration from `menuBarShowsHighestUsage` and round-trip normalization of provider pins.
+
+### Controller seams
+
+- Extend split lifecycle tests to prove stable status-item identities while pins, order, and enabled state change.
+- Prove merged and pinned menus attach to the correct item and never share provider-only menu state.
+- Prove animation and icon observation signatures include the resolved layout and focus match.
+- Inject a fake workspace event source; do not activate real applications in unit tests.
+- Prove monitor registration, coalescing, and shutdown without AppKit status-bar construction where possible.
+
+### Tahoe visual and performance proof
+
+Use a freshly packaged exact-head build in the macOS Tahoe VM:
+
+1. Verify one merged item plus the selected provider pins.
+2. Toggle pins, provider enabled state, Merge Icons, and provider order; capture redacted screenshots.
+3. Switch between two mapped native apps and an unmapped app; verify only the collapsed merged icon changes.
+4. Open the menu during focus switches; verify the selected tab/account and Overview stay fixed.
+5. Profile repeated focus changes and merged-menu scrolling. Require no persistent idle timer, no status-item churn, and
+ no regression from the native Overview scroller.
+
+Run focused model/controller suites, `make check`, and `make test`. Keep all live proof free of account identity, usage
+values, and unpublished provider/model data.
+
+## Rollout
+
+1. **Provider pins PR:** settings, pure layout model, controller adoption, tests, Tahoe screenshots.
+2. **Native focus PR:** enum migration, provider mappings, event monitor, collapsed-icon precedence, energy proof.
+3. **Terminal adapters:** only after a separate product/privacy decision.
+
+Each implementation PR should remain independently reversible. Neither changes provider fetching or account state.
+
+## Accepted owner decisions
+
+| Decision | Recommendation | Alternative and cost |
+| --- | --- | --- |
+| Are pins additive? | Yes; pinned providers remain in merged content | Removing them fragments Overview/switcher semantics |
+| Keep merged item when all providers are pinned? | Yes; explicit Merge Icons promise stays stable | Auto-hide makes layout change as the last pin toggles |
+| Unified-icon setting shape | One three-choice picker | Multiple toggles create conflicting precedence |
+| Focus effect | Collapsed merged icon only | Mutating selection makes focus fight explicit user state |
+| First focus scope | Native provider apps only | Terminal inference adds privacy, permission, and ambiguity work |
+| Terminal follow-up | Separate off-by-default design | Bundling it blocks the privacy-safe MVP |
+
+## Acceptance
+
+The six recommendations above are approved. This spec is the bounded implementation contract; it intentionally
+changes no runtime behavior.
diff --git a/docs/ui.md b/docs/ui.md
index e5d5e085cb..a7ee9e2fc9 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -56,8 +56,8 @@ window has elapsed.
reports sizes and cleanup ideas, it does not delete files.
- Display: “Overview tab providers” controls which providers appear in Merge Icons → Overview (up to 3).
- If no providers are selected for Overview, the Overview tab is hidden.
-- Providers → Claude: “Avoid Keychain prompts” uses the prompt-free Security CLI reader when available.
-- The lower-level “Keychain prompt policy” picker only appears when the Security.framework reader is active.
+- Providers → Claude: “Avoid Keychain prompts” selects the Security.framework reader's `Never prompt` policy.
+- The lower-level “Keychain prompt policy” picker remains visible as the source of truth for Claude OAuth prompts.
## Widgets (high level)
- Widgets render shared usage snapshots for the supported widget families and
diff --git a/version.env b/version.env
index de060132a3..415f08102f 100644
--- a/version.env
+++ b/version.env
@@ -1,2 +1,2 @@
-MARKETING_VERSION=0.38.1
-BUILD_NUMBER=95
+MARKETING_VERSION=0.41.1
+BUILD_NUMBER=101