From 654a6d84b370b5fa67b0e2fbff2b0fa9c2e706bd Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:32:58 +0300 Subject: [PATCH 1/3] feat(menubar): separate Claude rings per account/organization and config profile in Capacity Dock (#1757) --- mac/Sources/CodeBurnMenubar/AppStore.swift | 141 ++++++++++++- .../CapacityDockController.swift | 18 +- .../Data/CapacityDockPreferences.swift | 82 +++++++- .../Data/ClaudeCredentialStore.swift | 192 +++++++++++++++++- .../Data/ClaudeSubscriptionService.swift | 58 ++++++ .../CodeBurnMenubar/Data/MenubarPayload.swift | 7 +- .../CodeBurnMenubar/Data/QuotaSummary.swift | 11 +- .../Views/CapacityDockView.swift | 103 ++++++++-- .../CodeBurnMenubar/Views/SettingsView.swift | 12 ++ .../CapacityDockPreferencesTests.swift | 31 +++ .../QuotaSummaryHeadlineTests.swift | 16 ++ 11 files changed, 622 insertions(+), 49 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 43d4075fb..a9a702a95 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -174,6 +174,10 @@ final class AppStore { var subscription: SubscriptionUsage? var subscriptionError: String? var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped + var capacityDockClaudeProfiles: [CapacityDockClaudeProfile] = [] + var claudeProfileSubscriptions: [String: SubscriptionUsage] = [:] + var claudeProfileLoadStates: [String: SubscriptionLoadState] = [:] + var claudeProfileTodayPayloads: [String: CurrentBlock] = [:] var capacityEstimates: [String: CapacityEstimate] = [:] @ObservationIgnored var earlyQuotaResetMonitor = EarlyQuotaResetMonitor() @ObservationIgnored var quotaCrossingMonitor = QuotaCrossingMonitor() @@ -228,6 +232,12 @@ final class AppStore { @ObservationIgnored var claudeQuotaFetcher: @Sendable () async throws -> SubscriptionUsage? = { try await ClaudeSubscriptionService.refreshIfBootstrapped() } + @ObservationIgnored var claudeProfileQuotaFetcher: @Sendable (CapacityDockClaudeProfile) async throws -> SubscriptionUsage? = { + try await ClaudeSubscriptionService.refreshProfile($0) + } + @ObservationIgnored var claudeProfilesDiscoverer: @Sendable () -> [CapacityDockClaudeProfile] = { + ClaudeCredentialStore.discoverProfiles() + } @ObservationIgnored var codexQuotaFetcher: @Sendable () async throws -> CodexUsage? = { try await CodexSubscriptionService.refreshIfBootstrapped() } @@ -1517,6 +1527,7 @@ final class AppStore { subscription = usage subscriptionError = nil subscriptionLoadState = .loaded + await refreshClaudeProfiles() await captureSnapshots(for: usage, baselineIsTrusted: false) } catch let err as ClaudeSubscriptionService.FetchError { applyFetchError(err) @@ -1567,6 +1578,7 @@ final class AppStore { subscriptionError = nil subscriptionLoadState = .loaded finishClaudeQuotaRefresh(token) + await refreshClaudeProfiles() await captureSnapshots( for: usage, baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted @@ -1594,6 +1606,53 @@ final class AppStore { } } + func refreshClaudeProfiles() async { + let profiles = claudeProfilesDiscoverer() + capacityDockClaudeProfiles = profiles + guard profiles.count > 1 else { return } + let defaultDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude").standardizedFileURL.path + for profile in profiles { + let isDefault = profile.path.map { + URL(fileURLWithPath: $0).standardizedFileURL.path == defaultDir + } == true && !profile.id.hasPrefix("claude-org:") + if isDefault, let subscription { + claudeProfileSubscriptions[profile.id] = subscription + claudeProfileLoadStates[profile.id] = subscriptionLoadState + } else { + do { + if let usage = try await claudeProfileQuotaFetcher(profile) { + claudeProfileSubscriptions[profile.id] = usage + claudeProfileLoadStates[profile.id] = .loaded + } else if claudeProfileSubscriptions[profile.id] == nil { + claudeProfileLoadStates[profile.id] = .noCredentials + } + } catch let err as ClaudeSubscriptionService.FetchError { + if err.isTerminal { + claudeProfileLoadStates[profile.id] = .terminalFailure(reason: sanitizeForUI(err.errorDescription)) + } else if let retryAt = err.rateLimitRetryAt { + claudeProfileLoadStates[profile.id] = .transientFailure(retryAt: retryAt) + } else { + claudeProfileLoadStates[profile.id] = .failed + } + } catch { + claudeProfileLoadStates[profile.id] = .failed + } + } + let key = PayloadCacheKey( + scope: .local, + period: .today, + provider: .all, + day: nil, + claudeConfigSourceId: profile.id + ) + if await refreshQuietly(key: key, includeOptimize: false, qualityOfService: .utility), + let current = cache[key]?.payload.current { + claudeProfileTodayPayloads[profile.id] = current + } + } + } + /// User-initiated disconnect — clears CodeBurn continuity state, any legacy /// private cache, and the bootstrap flag, /// plus all derived state so a reconnect (potentially under a different @@ -2259,6 +2318,11 @@ final class AppStore { /// "none running". func capacityDockLiveSessions(for provider: CapacityDockProvider) -> [LiveSession]? { guard let block = menubarPayload?.liveSessions else { return nil } + if let profileID = provider.claudeProfileID { + return block.sessions.filter { + $0.provider == "claude" && $0.claudeConfigSourceId == profileID + } + } // The CLI names providers by their own ids ("kimicode", "cursor-agent"), // not by the dock's raw identifiers ("kimi"); match the payload ids. let ids = provider.payloadProviderIDs @@ -2335,6 +2399,32 @@ final class AppStore { /// from a breakdown that IS present had no usage today, which is a real /// zero, not missing data. func capacityDockToday(for provider: CapacityDockProvider) -> ProviderDetail? { + if let profileID = provider.claudeProfileID { + let scopedKey = PayloadCacheKey( + scope: .local, + period: .today, + provider: .all, + day: nil, + claudeConfigSourceId: profileID + ) + guard let block = claudeProfileTodayPayloads[profileID] ?? cache[scopedKey]?.payload.current else { + return nil + } + if let claudeDetail = block.providerDetails.first(where: { $0.id == "claude" }) { + return claudeDetail + } + return ProviderDetail( + id: "claude", + label: "Claude", + cost: block.cost, + calls: block.calls, + hasUsage: block.calls > 0 || block.cost > 0, + inputTokens: block.inputTokens, + outputTokens: block.outputTokens, + sessions: block.sessions, + cacheReadTokens: block.cacheReadTokens + ) + } guard let current = capacityDockToday, !current.providerDetails.isEmpty else { return nil } let ids = Set(provider.payloadProviderIDs) let rows = current.providerDetails.filter { ids.contains($0.id) } @@ -2373,6 +2463,9 @@ final class AppStore { } func capacityDockQuotaSummary(for provider: CapacityDockProvider) -> QuotaSummary? { + if let profileID = provider.claudeProfileID { + return claudeProfileQuotaSummary(profileID: profileID) + } if let filter = provider.legacyFilter { return quotaSummary(for: filter) } @@ -2591,17 +2684,39 @@ final class AppStore { } private func claudeQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { - if case .notBootstrapped = subscriptionLoadState { return nil } - if case .bootstrapping = subscriptionLoadState { return nil } - if case .noCredentials = subscriptionLoadState { return nil } - let usageIsFresh = QuotaSummary.isFresh(fetchedAt: subscription?.fetchedAt) + buildClaudeQuotaSummary( + filter: filter, + usage: subscription, + loadState: subscriptionLoadState + ) + } + + private func claudeProfileQuotaSummary(profileID: String) -> QuotaSummary? { + let usage = claudeProfileSubscriptions[profileID] ?? ( + capacityDockClaudeProfiles.first?.id == profileID ? subscription : nil + ) + let loadState = claudeProfileLoadStates[profileID] ?? ( + capacityDockClaudeProfiles.first?.id == profileID ? subscriptionLoadState : .dormant + ) + return buildClaudeQuotaSummary(filter: .claude, usage: usage, loadState: loadState) + } + + private func buildClaudeQuotaSummary( + filter: ProviderFilter, + usage: SubscriptionUsage?, + loadState: SubscriptionLoadState + ) -> QuotaSummary? { + if case .notBootstrapped = loadState { return nil } + if case .bootstrapping = loadState { return nil } + if case .noCredentials = loadState { return nil } + let usageIsFresh = QuotaSummary.isFresh(fetchedAt: usage?.fetchedAt) let connection: QuotaSummary.Connection = { - switch subscriptionLoadState { + switch loadState { case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected - case .loading: return subscription == nil ? .loading : .stale + case .loading: return usage == nil ? .loading : .stale case .loaded: return usageIsFresh ? .connected : .stale - case .failed: return subscription == nil ? .loading : .stale + case .failed: return usage == nil ? .loading : .stale case let .terminalFailure(reason): return .terminalFailure(reason: reason) case .transientFailure: return .transientFailure } @@ -2609,15 +2724,16 @@ final class AppStore { var primary: QuotaSummary.Window? var details: [QuotaSummary.Window] = [] - if let usage = subscription { + if let usage { // Claude's rate-limit windows are fixed lengths, so each row // carries its validated duration for pace presentation. if let pct = usage.fiveHourPercent { - details.append(.init( + let fiveHour = QuotaSummary.Window( label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt, windowSeconds: QuotaPacePresentation.claudeFiveHourSeconds, fetchedAt: usage.fetchedAt - )) + ) + details.append(fiveHour) } if let pct = usage.sevenDayPercent { let weekly = QuotaSummary.Window( @@ -2628,6 +2744,9 @@ final class AppStore { primary = weekly details.append(weekly) } + if primary == nil { + primary = details.first + } if let pct = usage.sevenDayOpusPercent { details.append(.init( label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt, @@ -2650,7 +2769,7 @@ final class AppStore { )) } } - let plan = subscription?.tier.displayName + let plan = usage?.tier.displayName return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: []) } diff --git a/mac/Sources/CodeBurnMenubar/CapacityDockController.swift b/mac/Sources/CodeBurnMenubar/CapacityDockController.swift index 683b8c49c..515c0bfb6 100644 --- a/mac/Sources/CodeBurnMenubar/CapacityDockController.swift +++ b/mac/Sources/CodeBurnMenubar/CapacityDockController.swift @@ -139,6 +139,13 @@ final class CapacityDockController { func refreshQuotaPresentation() { guard model.preferences.isEnabled else { return } + let nextProfiles = store.capacityDockClaudeProfiles.isEmpty + ? ClaudeCredentialStore.discoverProfiles() + : store.capacityDockClaudeProfiles + if model.claudeProfiles != nextProfiles { + model.claudeProfiles = nextProfiles + layoutRail(animate: false) + } if let provider = model.hoveredProvider { model.detailHeight = glanceDetailHeight(for: provider) layoutDetail(for: provider, transaction: .immediate) @@ -169,13 +176,16 @@ final class CapacityDockController { private func preferencesDidChange() { let snapshot = CapacityDockPreferences.load(defaults: defaults) model.preferences = snapshot + model.claudeProfiles = store.capacityDockClaudeProfiles.isEmpty + ? ClaudeCredentialStore.discoverProfiles() + : store.capacityDockClaudeProfiles if !model.interaction.isDragging { model.dockedEdge = snapshot.dockedEdge model.attachmentEdge = snapshot.attachmentEdge } if let hovered = model.hoveredProvider, - !snapshot.selectedProviders.contains(hovered) { + !model.effectiveSelectedProviders.contains(hovered) { hideDetail(animated: false) } else if let hovered = model.hoveredProvider { model.detailHeight = glanceDetailHeight(for: hovered) @@ -581,8 +591,8 @@ final class CapacityDockController { expansionWork?.cancel() collapseWork?.cancel() - if provider != model.preferences.preferredProvider { - CapacityDockPreferences.setPreferredProvider(provider, defaults: defaults) + if provider != model.effectivePreferredProvider { + CapacityDockPreferences.setPreferredProvider(provider.baseProvider, defaults: defaults) if !model.interaction.isPinned { model.interaction.togglePinned() } @@ -650,7 +660,7 @@ final class CapacityDockController { private func showDetail(for provider: CapacityDockProvider) { guard model.preferences.isEnabled, - model.preferences.selectedProviders.contains(provider) else { return } + model.effectiveSelectedProviders.contains(provider) else { return } let wasShowingDetail = detailPanel?.isVisible == true && model.hoveredProvider != nil detailIsDismissing = false model.hoveredProvider = provider diff --git a/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift b/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift index ea9f134d4..770800205 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift @@ -1,5 +1,35 @@ import Foundation +struct CapacityDockClaudeProfile: Equatable, Hashable, Sendable, Identifiable { + let id: String + let label: String + let path: String? + let orgUuid: String? + + init(id: String, label: String, path: String? = nil, orgUuid: String? = nil) { + self.id = id + self.label = label + self.path = path + self.orgUuid = orgUuid + } + + var caption: String { + label == "Default Claude" ? L("Default") : label + } +} + +enum CapacityDockClaudeProfilesMode: String, CaseIterable, Sendable { + case combined + case separate + + var displayName: String { + switch self { + case .combined: L("Combined") + case .separate: L("Separate rings") + } + } +} + /// Stable Capacity Dock identity backed by the audited provider catalog. /// /// This is a value type rather than a 69-case enum so the provider inventory has @@ -7,6 +37,7 @@ import Foundation /// `ProviderFilter` bridge; the remaining providers are fetched through the /// CodeBurn-owned provider adapter at runtime. struct CapacityDockProvider: RawRepresentable, CaseIterable, Identifiable, Hashable, Sendable { + private static let claudeProfilePrefix = "claude-profile:" let rawValue: String init?(rawValue: String) { @@ -20,6 +51,23 @@ struct CapacityDockProvider: RawRepresentable, CaseIterable, Identifiable, Hasha self.rawValue = rawValue } + static func claudeProfile(id: String) -> CapacityDockProvider { + CapacityDockProvider(known: "\(claudeProfilePrefix)\(id)") + } + + var isClaudeProfile: Bool { + rawValue.hasPrefix(Self.claudeProfilePrefix) + } + + var claudeProfileID: String? { + guard isClaudeProfile else { return nil } + return String(rawValue.dropFirst(Self.claudeProfilePrefix.count)) + } + + var baseProvider: CapacityDockProvider { + isClaudeProfile ? .claude : self + } + var id: String { rawValue } static var allCases: [CapacityDockProvider] { @@ -34,11 +82,12 @@ struct CapacityDockProvider: RawRepresentable, CaseIterable, Identifiable, Hasha static let antigravity = CapacityDockProvider(known: "antigravity") var catalogEntry: ProviderConnectionCatalogEntry { - ProviderConnectionCatalog.providers.first { $0.id == rawValue }! + let lookup = baseProvider.rawValue + return ProviderConnectionCatalog.providers.first { $0.id == lookup }! } var legacyFilter: ProviderFilter? { - switch rawValue { + switch baseProvider.rawValue { case Self.claude.rawValue: .claude case Self.codex.rawValue: .codex case Self.gemini.rawValue: .gemini @@ -56,7 +105,7 @@ struct CapacityDockProvider: RawRepresentable, CaseIterable, Identifiable, Hasha /// dock has no legacy bridge; where it has one the bridge is the authority, /// because the dock's `kimi` entry is Kimi Code, which the CLI calls /// `kimicode` (its `kimi` is a different provider). - var payloadProviderID: String { legacyFilter?.cliArg ?? rawValue } + var payloadProviderID: String { legacyFilter?.cliArg ?? baseProvider.rawValue } /// Every payload provider this one tile stands for. A tile is an account the /// user recognises; the CLI splits some accounts across more than one provider @@ -79,13 +128,13 @@ struct CapacityDockProvider: RawRepresentable, CaseIterable, Identifiable, Hasha } var iconName: String { - switch rawValue { + switch baseProvider.rawValue { case Self.codex.rawValue, "openai", "azureopenai": "codex" case Self.gemini.rawValue: "gemini" case Self.copilot.rawValue: "copilot" case "alibabatokenplan": "alibaba" case "moonshot": "kimi" - default: rawValue + default: baseProvider.rawValue } } } @@ -184,6 +233,7 @@ enum CapacityDockPreferences { static let gaugeShapeKey = "CodeBurnCapacityDockGaugeShape" static let glanceWindowsKey = "CodeBurnCapacityDockGlanceWindows" static let manualSelectionKey = "CodeBurnCapacityDockManualSelection" + static let claudeProfilesKey = "CodeBurnCapacityDockClaudeProfiles" static let defaultProvider: CapacityDockProvider = .codex static let supportedProviders = CapacityDockProvider.allCases @@ -206,9 +256,13 @@ enum CapacityDockPreferences { /// id. Sparse: an absent provider uses the billing horizon, which is /// what the dock has always shown. let glanceWindows: [String: CapacityDockGlanceWindowKind] + /// How Claude is drawn when more than one account/profile is present. + var claudeProfiles: CapacityDockClaudeProfilesMode = .combined func glanceWindow(for provider: CapacityDockProvider) -> CapacityDockGlanceWindowKind { - glanceWindows[provider.rawValue] ?? .billing + glanceWindows[provider.rawValue] + ?? glanceWindows[provider.baseProvider.rawValue] + ?? .billing } } @@ -270,7 +324,9 @@ enum CapacityDockPreferences { .flatMap(CapacityDockGaugeShape.init(rawValue:)) ?? .squircle, glanceWindows: normalizedGlanceWindows( defaults.dictionary(forKey: glanceWindowsKey) - ) + ), + claudeProfiles: defaults.string(forKey: claudeProfilesKey) + .flatMap(CapacityDockClaudeProfilesMode.init(rawValue:)) ?? .combined ) } @@ -428,6 +484,14 @@ enum CapacityDockPreferences { notifyChanged() } + static func setClaudeProfiles( + _ mode: CapacityDockClaudeProfilesMode, + defaults: UserDefaults = .standard + ) { + defaults.set(mode.rawValue, forKey: claudeProfilesKey) + notifyChanged() + } + /// Records which horizon one provider's gauge reports. Stored per provider /// rather than as one dock-wide switch because the providers are /// independent questions: a user can watch Claude's 5-hour window while @@ -462,7 +526,9 @@ enum CapacityDockPreferences { guard let stored else { return [:] } var normalized: [String: CapacityDockGlanceWindowKind] = [:] for (identifier, value) in stored { - guard CapacityDockProvider(rawValue: identifier) != nil, + let validID = CapacityDockProvider(rawValue: identifier) != nil + || identifier.hasPrefix("claude-profile:") + guard validID, let rawValue = value as? String, let kind = CapacityDockGlanceWindowKind(rawValue: rawValue) else { continue } normalized[identifier] = kind diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index 391e7efb8..7346113e0 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Security @@ -263,12 +264,190 @@ enum ClaudeCredentialStore { } private static func readClaudeSourceSilently() -> CredentialRecord? { - if let fromFile = try? readClaudeFile() { return fromFile } + let fromFile = try? readClaudeFile() + if let fromFile, (fromFile.expiresAt ?? .distantFuture) > Date() { + return fromFile + } // An overridden home is an isolation boundary (tests and explicit // sandboxed probes). Falling through here would read the operator's // login Keychain when the isolated fixture has no Claude file. - guard homeDirectoryOverride == nil else { return nil } - if let fromKeychain = try? readClaudeKeychain(allowUI: false) { return fromKeychain } + guard homeDirectoryOverride == nil else { return fromFile } + if let fromKeychain = try? readClaudeKeychain(allowUI: false) { + if let fromFile, + let fileExp = fromFile.expiresAt, + let kcExp = fromKeychain.expiresAt, + fileExp >= kcExp { + return fromFile + } + return fromKeychain + } + return fromFile + } + + // MARK: - Multi-profile & organization discovery + + static func sha256Hex(_ input: String, prefixLength: Int) -> String { + let digest = SHA256.hash(data: Data(input.utf8)) + return digest.map { String(format: "%02x", $0) }.joined().prefix(prefixLength).description + } + + static func claudeConfigSourceId(forPath path: String) -> String { + let resolved = URL(fileURLWithPath: path).standardizedFileURL.path + return "claude-config:\(sha256Hex(resolved, prefixLength: 16))" + } + + static func keychainService(forConfigDir path: String) -> String { + let resolved = URL(fileURLWithPath: path).standardizedFileURL.path + let defaultDir = homeDirectory.appendingPathComponent(".claude").standardizedFileURL.path + if resolved == defaultDir { return claudeKeychainService } + let suffix = sha256Hex(resolved.precomposedStringWithCanonicalMapping, prefixLength: 8) + return "\(claudeKeychainService)-\(suffix)" + } + + static func discoverProfiles() -> [CapacityDockClaudeProfile] { + let configuredDirs = CLIClaudeConfig.load() + let defaultDir = homeDirectory.appendingPathComponent(".claude").standardizedFileURL.path + let rawDirs = configuredDirs.isEmpty ? [defaultDir] : configuredDirs.map { dir -> String in + if dir == "~" { return homeDirectory.path } + if dir.hasPrefix("~/") { + return homeDirectory.appendingPathComponent(String(dir.dropFirst(2))).standardizedFileURL.path + } + return URL(fileURLWithPath: dir).standardizedFileURL.path + } + let existingDirs = rawDirs.filter { FileManager.default.fileExists(atPath: $0) } + let dirs = existingDirs.isEmpty ? [defaultDir] : existingDirs + + let cacheDir = URL(fileURLWithPath: CodeBurnCacheDirectory.resolve(homeDirectory: homeDirectory)) + let orgProfilesURL = cacheDir.appendingPathComponent("claude-org-profiles.json") + var cachedOrgs: [String: [String: Any]] = [:] + if let data = try? SafeFile.read(from: orgProfilesURL.path, maxBytes: maxCredentialBytes), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] { + cachedOrgs = json + } + + // Also scan Claude Desktop's claude-code-sessions// + let codeSessionsRoot = homeDirectory + .appendingPathComponent("Library/Application Support/Claude/claude-code-sessions") + var desktopOrgPaths: [String: String] = [:] + if let acctDirs = try? FileManager.default.contentsOfDirectory(atPath: codeSessionsRoot.path) { + for acct in acctDirs where acct.count == 36 { + let acctPath = codeSessionsRoot.appendingPathComponent(acct) + guard let orgDirs = try? FileManager.default.contentsOfDirectory(atPath: acctPath.path) else { continue } + for org in orgDirs where org.count == 36 { + let orgPath = acctPath.appendingPathComponent(org) + let files = (try? FileManager.default.contentsOfDirectory(atPath: orgPath.path)) ?? [] + if files.contains(where: { $0.hasPrefix("local_") && $0.hasSuffix(".json") }) { + desktopOrgPaths[org.lowercased()] = orgPath.path + } + } + } + } + + let defaultOrgUuid: String? = { + if let entry = cachedOrgs.values.first(where: { ($0["isDefaultConfigOrg"] as? Bool) == true }), + let uuid = entry["orgUuid"] as? String { + return uuid.lowercased() + } + if let entry = cachedOrgs.values.first(where: { + let t = ($0["organizationType"] as? String)?.lowercased() + return t == "max" || t == "pro" || ($0["label"] as? String) == "Personal" + }), let uuid = entry["orgUuid"] as? String { + return uuid.lowercased() + } + return nil + }() + + var allOrgs = Set(cachedOrgs.keys.map { $0.lowercased() }).union(desktopOrgPaths.keys) + if let defaultOrgUuid { allOrgs.remove(defaultOrgUuid) } + + var profiles: [CapacityDockClaudeProfile] = dirs.map { path in + let isDef = path == defaultDir + let baseLabel: String = { + if isDef { + if !allOrgs.isEmpty { + if let defaultOrgUuid, + let cachedLabel = cachedOrgs[defaultOrgUuid]?["label"] as? String, + !cachedLabel.isEmpty { + return cachedLabel + } + return "Personal" + } + return "Default Claude" + } + let name = URL(fileURLWithPath: path).lastPathComponent.replacingOccurrences(of: ".", with: "") + return name.isEmpty ? path : name + }() + return CapacityDockClaudeProfile( + id: claudeConfigSourceId(forPath: path), + label: baseLabel, + path: path, + orgUuid: isDef ? defaultOrgUuid : nil + ) + } + + var extraProfiles: [CapacityDockClaudeProfile] = [] + for orgUuid in allOrgs { + let cached = cachedOrgs[orgUuid] + let label = (cached?["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedLabel = (label?.isEmpty == false) ? label! : "Team" + let path = desktopOrgPaths[orgUuid] ?? defaultDir + extraProfiles.append( + CapacityDockClaudeProfile( + id: "claude-org:\(orgUuid)", + label: resolvedLabel, + path: path, + orgUuid: orgUuid + ) + ) + } + extraProfiles.sort { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending } + profiles.append(contentsOf: extraProfiles) + return profiles + } + + static func credentialForProfile(_ profile: CapacityDockClaudeProfile) -> CredentialRecord? { + let cacheDir = URL(fileURLWithPath: CodeBurnCacheDirectory.resolve(homeDirectory: homeDirectory)) + let orgCredsURL = cacheDir.appendingPathComponent("claude-org-credentials.json") + if let orgUuid = profile.orgUuid?.lowercased(), + let data = try? SafeFile.read(from: orgCredsURL.path, maxBytes: maxCredentialBytes * 4), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]], + let row = json[orgUuid], + let token = row["accessToken"] as? String, !token.isEmpty { + let expiresAt = (row["expiresAt"] as? Double).map { Date(timeIntervalSince1970: $0 / 1000.0) } + let orgRecord = CredentialRecord( + accessToken: token, + refreshToken: row["refreshToken"] as? String, + expiresAt: expiresAt, + rateLimitTier: row["rateLimitTier"] as? String, + subscriptionType: row["subscriptionType"] as? String + ) + if profile.id.hasPrefix("claude-org:") { + return orgRecord + } + if let defRecord = try? currentRecord(), + (defRecord.expiresAt ?? .distantFuture) > (orgRecord.expiresAt ?? .distantPast) { + return defRecord + } + return orgRecord + } + + if let path = profile.path { + let defaultDir = homeDirectory.appendingPathComponent(".claude").standardizedFileURL.path + if URL(fileURLWithPath: path).standardizedFileURL.path == defaultDir { + return try? currentRecord() + } + let fileURL = URL(fileURLWithPath: path).appendingPathComponent(".credentials.json") + if FileManager.default.fileExists(atPath: fileURL.path), + let data = try? SafeFile.read(from: fileURL.path, maxBytes: maxCredentialBytes), + let record = try? parseClaudeBlob(data: sanitizeClaudeBlob(data)), + (record.expiresAt ?? .distantFuture) > Date() { + return record + } + guard homeDirectoryOverride == nil else { return nil } + let service = keychainService(forConfigDir: path) + return readClaudeKeychainSilently(service: service, account: NSUserName()) + ?? readClaudeKeychainSilently(service: service, account: nil) + } return nil } @@ -343,10 +522,13 @@ enum ClaudeCredentialStore { /// Reads Claude's keychain entry via `/usr/bin/security`, which never raises /// the partition-list prompt. Returns nil on any failure so the caller falls /// back to the cached token. - private static func readClaudeKeychainSilently(account: String?) -> CredentialRecord? { + private static func readClaudeKeychainSilently( + service: String = claudeKeychainService, + account: String? + ) -> CredentialRecord? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/security") - var args = ["find-generic-password", "-s", claudeKeychainService] + var args = ["find-generic-password", "-s", service] if let account { args += ["-a", account] } args.append("-w") process.arguments = args diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift index 27ae2a8c7..8d3aa412c 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -98,6 +98,64 @@ enum ClaudeSubscriptionService { } } + /// Background refresh for a specific Claude profile (Personal or Team organization, + /// or secondary config directory). + static func refreshProfile(_ profile: CapacityDockClaudeProfile) async throws -> SubscriptionUsage? { + let blockKey = "\(usageBlockedUntilKey).\(profile.id)" + if let until = UserDefaults.standard.object(forKey: blockKey) as? Date, until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + guard let record = ClaudeCredentialStore.credentialForProfile(profile) else { + return nil + } + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.setValue("Bearer \(record.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw FetchError.network(error) + } + guard let http = response as? HTTPURLResponse else { + throw FetchError.usageHTTPError(-1, nil) + } + switch http.statusCode { + case 200: + UserDefaults.standard.removeObject(forKey: blockKey) + do { + return try parseUsage( + data, + rawTier: record.rateLimitTier, + subscriptionType: record.subscriptionType + ) + } catch { + throw FetchError.usageDecodeFailed + } + case 429: + let body = String(data: data, encoding: .utf8) + let retryAfter = parseRetryAfter( + header: http.value(forHTTPHeaderField: "Retry-After"), + body: body + ) + let until = rateLimitBlockUntil( + existingUntil: UserDefaults.standard.object(forKey: blockKey) as? Date, + now: Date(), + retryAfterSeconds: retryAfter + ) + UserDefaults.standard.set(until, forKey: blockKey) + throw FetchError.rateLimited(retryAt: until) + default: + throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8)) + } + } + /// Reset everything — used on user-initiated disconnect. /// Returns the delete outcome so callers only tear down UI state once the /// credential material is actually gone. A failed delete leaves the usage diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 860b0c89e..dc46a8505 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -115,6 +115,9 @@ struct LiveSession: Codable, Sendable, Identifiable { let id: String /// Provider catalog id, matching the dock ring the session runs under. let provider: String + /// For Claude sessions, the config directory or organization profile id + /// the transcript belongs to. Absent on non-Claude providers. + var claudeConfigSourceId: String? = nil let project: String let branch: String? let model: String? @@ -339,6 +342,7 @@ struct CurrentBlock: Codable, Sendable { let oneShotRate: Double? let inputTokens: Int let outputTokens: Int + var cacheReadTokens: Int? = nil let cacheHitPercent: Double /// Codex credits consumed in the period (nil on payloads from older builds). let codexCredits: Double? @@ -395,7 +399,7 @@ struct PullRequestRow: Codable, Sendable { extension CurrentBlock { enum CodingKeys: String, CodingKey { case label, cost, calls, sessions, sessionCountBasis, oneShotRate, inputTokens, outputTokens, - cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, providerDetails, topProjects, + cacheReadTokens, cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, providerDetails, topProjects, modelEfficiency, topSessions, retryTax, routingWaste, tools, skills, subagents, mcpServers, workflow, topReworkedFiles, pullRequests, unpricedModels @@ -410,6 +414,7 @@ extension CurrentBlock { oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate) inputTokens = try c.decode(Int.self, forKey: .inputTokens) outputTokens = try c.decode(Int.self, forKey: .outputTokens) + cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0 codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits) topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? [] diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index 500ff28d4..a0ecd6ef0 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -119,11 +119,14 @@ struct QuotaSummary: Equatable { if let primary, !candidates.contains(primary) { candidates.append(primary) } - func firstMatching(_ needle: String) -> Window? { - candidates.first { $0.label.range(of: needle, options: .caseInsensitive) != nil } + func firstUnscopedMatching(_ needle: String) -> Window? { + candidates.first { + !$0.label.contains("·") + && $0.label.range(of: needle, options: .caseInsensitive) != nil + } } - if let weekly = firstMatching("week") { return weekly } - if let monthly = firstMatching("month") { return monthly } + if let weekly = firstUnscopedMatching("week") { return weekly } + if let monthly = firstUnscopedMatching("month") { return monthly } return candidates.max { lhs, rhs in lhs.percent < rhs.percent } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift index d6cc3e0ee..a6d47e910 100644 --- a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift @@ -44,6 +44,9 @@ enum CapacityDockMetrics { private static let baseRingLabelSpacing: CGFloat = 6 private static let baseProviderIconSize: CGFloat = 26 private static let basePercentageTextSize: CGFloat = 17 + private static let baseCaptionTextSize: CGFloat = 10 + private static let baseCaptionLine: CGFloat = 12 + private static let baseCaptionGap: CGFloat = 2 private static let baseDetailWidth: CGFloat = 350 /// Every dock dimension lands on a whole point. Fractional sizes (85% @@ -59,7 +62,9 @@ enum CapacityDockMetrics { static func horizontalRailWidth(scale: CGFloat) -> CGFloat { points(baseHorizontalRailWidth, scale) } static func edgeFlareWidth(scale: CGFloat) -> CGFloat { points(baseEdgeFlareWidth, scale) } static func edgeShoulderDepth(scale: CGFloat) -> CGFloat { points(baseEdgeShoulderDepth, scale) } - static func rowHeight(scale: CGFloat) -> CGFloat { points(baseRowHeight, scale) } + static func rowHeight(scale: CGFloat, captioned: Bool = false) -> CGFloat { + points(baseRowHeight, scale) + (captioned ? captionGap(scale: scale) + captionLine(scale: scale) : 0) + } static func rowSpacing(scale: CGFloat) -> CGFloat { points(baseRowSpacing, scale) } static func railAlongPad(scale: CGFloat) -> CGFloat { points(baseRailAlongPad, scale) } static func railCrossPad(scale: CGFloat) -> CGFloat { points(baseRailCrossPad, scale) } @@ -68,12 +73,15 @@ enum CapacityDockMetrics { static func ringLabelSpacing(scale: CGFloat) -> CGFloat { points(baseRingLabelSpacing, scale) } static func providerIconSize(scale: CGFloat) -> CGFloat { points(baseProviderIconSize, scale) } static func percentageTextSize(scale: CGFloat) -> CGFloat { points(basePercentageTextSize, scale) } + static func captionTextSize(scale: CGFloat) -> CGFloat { points(baseCaptionTextSize, scale) } + static func captionLine(scale: CGFloat) -> CGFloat { points(baseCaptionLine, scale) } + static func captionGap(scale: CGFloat) -> CGFloat { points(baseCaptionGap, scale) } static func detailWidth(scale: CGFloat) -> CGFloat { points(baseDetailWidth, scale) } - static func railHeight(providerCount: Int, alongPad: CGFloat, scale: CGFloat) -> CGFloat { + static func railHeight(providerCount: Int, alongPad: CGFloat, scale: CGFloat, captioned: Bool = false) -> CGFloat { let count = max(providerCount, 1) return alongPad - + CGFloat(count) * rowHeight(scale: scale) + + CGFloat(count) * rowHeight(scale: scale, captioned: captioned) + CGFloat(max(0, count - 1)) * rowSpacing(scale: scale) + alongPad } @@ -411,6 +419,7 @@ enum CapacityDockGlance { @Observable final class CapacityDockViewModel { var preferences: CapacityDockPreferences.Snapshot + var claudeProfiles: [CapacityDockClaudeProfile] = [] var interaction = CapacityDockInteractionState() var hoveredProvider: CapacityDockProvider? var detailHeight: CGFloat = 164 @@ -423,28 +432,74 @@ final class CapacityDockViewModel { var detailTailPosition: CGFloat = 0.5 var expansionAnchor: CapacityDockExpansionAnchor = .start - init(preferences: CapacityDockPreferences.Snapshot) { + init(preferences: CapacityDockPreferences.Snapshot, claudeProfiles: [CapacityDockClaudeProfile] = []) { self.preferences = preferences + self.claudeProfiles = claudeProfiles self.dockedEdge = preferences.dockedEdge self.attachmentEdge = preferences.attachmentEdge self.attachmentProgress = preferences.dockedEdge == nil ? 0 : 1 } + var usesSeparateClaudeProfiles: Bool { + preferences.claudeProfiles == .separate + && claudeProfiles.count > 1 + && preferences.selectedProviders.contains(.claude) + } + + var effectiveSelectedProviders: [CapacityDockProvider] { + guard usesSeparateClaudeProfiles else { return preferences.selectedProviders } + let profileRows = claudeProfiles.map { CapacityDockProvider.claudeProfile(id: $0.id) } + return preferences.selectedProviders.flatMap { provider in + provider == .claude ? profileRows : [provider] + } + } + + var effectivePreferredProvider: CapacityDockProvider { + if usesSeparateClaudeProfiles && preferences.preferredProvider == .claude { + if let first = claudeProfiles.first { + return .claudeProfile(id: first.id) + } + } + return preferences.preferredProvider + } + + func caption(for provider: CapacityDockProvider) -> String? { + guard usesSeparateClaudeProfiles, + let profileID = provider.claudeProfileID, + let profile = claudeProfiles.first(where: { $0.id == profileID }) + else { return nil } + return profile.caption + } + + func detailTitle(for provider: CapacityDockProvider) -> String { + if let profileID = provider.claudeProfileID, + let profile = claudeProfiles.first(where: { $0.id == profileID }) { + return "\(provider.displayName) · \(profile.label)" + } + return provider.displayName + } + var displayedProviders: [CapacityDockProvider] { - guard isRailPresentationExpanded else { return [preferences.preferredProvider] } - let preferred = preferences.preferredProvider - let providers = [preferred] + preferences.selectedProviders.filter { $0 != preferred } + let preferred = effectivePreferredProvider + guard isRailPresentationExpanded else { return [preferred] } + let providers = [preferred] + effectiveSelectedProviders.filter { $0 != preferred } return expansionAnchor == .start ? providers : providers.reversed() } var restingBodyLength: CGFloat { - CapacityDockMetrics.railHeight(providerCount: 1, alongPad: railAlongPad, scale: scale) + CapacityDockMetrics.railHeight( + providerCount: 1, + alongPad: railAlongPad, + scale: scale, + captioned: usesSeparateClaudeProfiles + ) } var expandedBodyLength: CGFloat { CapacityDockMetrics.railHeight( - providerCount: preferences.selectedProviders.count, + providerCount: effectiveSelectedProviders.count, alongPad: railAlongPad, - scale: scale + scale: scale, + captioned: usesSeparateClaudeProfiles ) } var targetBodyLength: CGFloat { @@ -485,7 +540,9 @@ final class CapacityDockViewModel { ? CGSize(width: railWidth, height: bodyLength) : CGSize(width: bodyLength, height: railWidth) } - var rowHeight: CGFloat { CapacityDockMetrics.rowHeight(scale: scale) } + var rowHeight: CGFloat { + CapacityDockMetrics.rowHeight(scale: scale, captioned: usesSeparateClaudeProfiles) + } var rowSpacing: CGFloat { CapacityDockMetrics.rowSpacing(scale: scale) } // Along-axis content padding: small when floating, plus the docked concave // flare depth so content never crowds a necked edge. Cross-axis is a small @@ -509,7 +566,7 @@ final class CapacityDockViewModel { var detailWidth: CGFloat { CapacityDockMetrics.detailWidth(scale: detailScale) } func presentationOpacity(for provider: CapacityDockProvider) -> CGFloat { - provider == preferences.preferredProvider ? 1 : railPresentationProgress + provider == effectivePreferredProvider ? 1 : railPresentationProgress } } @@ -539,6 +596,7 @@ struct CapacityDockView: View { CapacityDockProviderRow( provider: provider, quota: quota(provider), + caption: model.caption(for: provider), scale: model.scale, gaugeShape: model.preferences.gaugeShape, glanceWindow: model.preferences.glanceWindow(for: provider), @@ -551,10 +609,10 @@ struct CapacityDockView: View { ) .opacity(model.presentationOpacity(for: provider)) .offset( - x: model.isVertical || provider == model.preferences.preferredProvider + x: model.isVertical || provider == model.effectivePreferredProvider ? 0 : -8 * model.scale * (1 - model.railPresentationProgress), - y: !model.isVertical || provider == model.preferences.preferredProvider + y: !model.isVertical || provider == model.effectivePreferredProvider ? 0 : -8 * model.scale * (1 - model.railPresentationProgress) ) @@ -642,6 +700,7 @@ struct CapacityDockView: View { private struct CapacityDockProviderRow: View { let provider: CapacityDockProvider let quota: QuotaSummary? + let caption: String? let scale: CGFloat let gaugeShape: CapacityDockGaugeShape let glanceWindow: CapacityDockGlanceWindowKind @@ -700,12 +759,24 @@ private struct CapacityDockProviderRow: View { .monospacedDigit() .foregroundStyle(headlinePercentColor) .contentTransition(.numericText()) + + if let caption { + Text(caption) + .font(.system( + size: CapacityDockMetrics.captionTextSize(scale: scale), + weight: .medium + )) + .foregroundStyle(Color.capacityDockText.opacity(0.58)) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: CapacityDockMetrics.captionLine(scale: scale)) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(L("%@ usage", provider.displayName)) + .accessibilityLabel(L("%@ usage", caption.map { "\(provider.displayName) \($0)" } ?? provider.displayName)) .accessibilityValue(accessibilityValue) .accessibilityHint(accessibilityHint) // The gauge is the switch, and a click is a pointer gesture. Keyboard @@ -998,7 +1069,7 @@ struct CapacityDockDetailView: View { // mark reads as its equal rather than as a bullet. .frame(width: 18 * s, height: 18 * s) } - Text(provider.displayName) + Text(model.detailTitle(for: provider)) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Color.capacityDockText) Spacer(minLength: 6) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 0777be3c6..f2f98ae26 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -775,6 +775,18 @@ private struct CapacityDockSettingsSection: View { } .pickerStyle(.menu) + if store.capacityDockClaudeProfiles.count > 1 { + Picker(L("Claude profiles"), selection: Binding( + get: { snapshot.claudeProfiles }, + set: { CapacityDockPreferences.setClaudeProfiles($0) } + )) { + ForEach(CapacityDockClaudeProfilesMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.menu) + } + Text(L("Dock providers")) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift index 412cb3b7f..32e2e46af 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift @@ -251,4 +251,35 @@ struct CapacityDockPreferencesTests { #expect(selected.contains(.codex)) #expect(selected.contains(zcode)) } + + @MainActor + @Test("separate Claude profiles mode expands the Claude ring into one captioned ring per profile") + func separateClaudeProfileRings() { + let (defaults, suiteName) = defaults() + defer { TestDefaults.forget(suiteName) } + + CapacityDockPreferences.setSelectedProviders([.codex, .claude, .zcode], defaults: defaults) + CapacityDockPreferences.setPreferredProvider(.claude, defaults: defaults) + CapacityDockPreferences.setClaudeProfiles(.separate, defaults: defaults) + + let profiles = [ + CapacityDockClaudeProfile(id: "claude-config:0e4378b7c9974e40", label: "Personal"), + CapacityDockClaudeProfile(id: "claude-org:64cceafd-5d4b-4fdd-928c-91c807474898", label: "Codeburn"), + ] + let snapshot = CapacityDockPreferences.load(defaults: defaults) + #expect(snapshot.claudeProfiles == .separate) + + let model = CapacityDockViewModel(preferences: snapshot, claudeProfiles: profiles) + #expect(model.usesSeparateClaudeProfiles) + #expect(model.effectiveSelectedProviders == [ + .claudeProfile(id: "claude-config:0e4378b7c9974e40"), + .claudeProfile(id: "claude-org:64cceafd-5d4b-4fdd-928c-91c807474898"), + .codex, + .zcode, + ]) + #expect(model.effectivePreferredProvider == .claudeProfile(id: "claude-config:0e4378b7c9974e40")) + #expect(model.caption(for: .claudeProfile(id: "claude-config:0e4378b7c9974e40")) == "Personal") + #expect(model.caption(for: .claudeProfile(id: "claude-org:64cceafd-5d4b-4fdd-928c-91c807474898")) == "Codeburn") + #expect(model.detailTitle(for: .claudeProfile(id: "claude-org:64cceafd-5d4b-4fdd-928c-91c807474898")) == "Claude · Codeburn") + } } diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaSummaryHeadlineTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaSummaryHeadlineTests.swift index 42d0b6eba..4997eae63 100644 --- a/mac/Tests/CodeBurnMenubarTests/QuotaSummaryHeadlineTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/QuotaSummaryHeadlineTests.swift @@ -91,4 +91,20 @@ struct QuotaSummaryHeadlineTests { #expect(QuotaSummary.severity(for: 0.94) == .critical) #expect(QuotaSummary.severity(for: 0.95) == .danger) } + + @Test("model-scoped Weekly · Fable does not shadow 5-hour when unscoped Weekly is absent") + func scopedWeeklyDoesNotShadowSessionWindow() { + let fiveHour = QuotaSummary.Window(label: "5-hour", percent: 0.20, resetsAt: nil) + let scoped = QuotaSummary.Window(label: "Weekly · Fable", percent: 0.0, resetsAt: nil) + let summary = QuotaSummary( + providerFilter: .claude, + connection: .connected, + primary: fiveHour, + details: [fiveHour, scoped], + planLabel: "Team Premium", + footerLines: [] + ) + + #expect(summary.headlineWindow == fiveHour) + } } From b9c4c0c6dd330980617df7258a4e0951bc8a3380 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:38:13 +0300 Subject: [PATCH 2/3] test(menubar): resolve zcode provider via rawValue in CapacityDockPreferencesTests --- .../CodeBurnMenubarTests/CapacityDockPreferencesTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift index 32e2e46af..bd54ac583 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift @@ -258,7 +258,8 @@ struct CapacityDockPreferencesTests { let (defaults, suiteName) = defaults() defer { TestDefaults.forget(suiteName) } - CapacityDockPreferences.setSelectedProviders([.codex, .claude, .zcode], defaults: defaults) + let zcode = CapacityDockProvider(rawValue: "zcode")! + CapacityDockPreferences.setSelectedProviders([.codex, .claude, zcode], defaults: defaults) CapacityDockPreferences.setPreferredProvider(.claude, defaults: defaults) CapacityDockPreferences.setClaudeProfiles(.separate, defaults: defaults) @@ -275,7 +276,7 @@ struct CapacityDockPreferencesTests { .claudeProfile(id: "claude-config:0e4378b7c9974e40"), .claudeProfile(id: "claude-org:64cceafd-5d4b-4fdd-928c-91c807474898"), .codex, - .zcode, + zcode, ]) #expect(model.effectivePreferredProvider == .claudeProfile(id: "claude-config:0e4378b7c9974e40")) #expect(model.caption(for: .claudeProfile(id: "claude-config:0e4378b7c9974e40")) == "Personal") From e822e1556a7a8aebd653e573d2e5935623171d95 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:42:52 +0300 Subject: [PATCH 3/3] fix(menubar): localize Claude profile dock strings and match product provider order in test --- .../CodeBurnMenubar/Resources/en.lproj/Localizable.strings | 3 +++ .../CodeBurnMenubar/Resources/fr.lproj/Localizable.strings | 3 +++ .../CodeBurnMenubar/Resources/ja.lproj/Localizable.strings | 3 +++ .../CodeBurnMenubar/Resources/ko.lproj/Localizable.strings | 3 +++ .../Resources/zh-Hans.lproj/Localizable.strings | 3 +++ .../Resources/zh-Hant.lproj/Localizable.strings | 3 +++ .../CodeBurnMenubarTests/CapacityDockPreferencesTests.swift | 2 +- 7 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings index 536b01c68..958777702 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings @@ -744,3 +744,6 @@ "Downloads your own usage export with the Cursor app's login, at most once an hour." = "Downloads your own usage export with the Cursor app's login, at most once an hour."; "Turned off by CODEBURN_CURSOR_SYNC=0" = "Turned off by CODEBURN_CURSOR_SYNC=0"; "Couldn't save the setting: config.json is invalid or locked" = "Couldn't save the setting: config.json is invalid or locked"; +"Claude profiles" = "Claude profiles"; +"Separate rings" = "Separate rings"; +"Default" = "Default"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/fr.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/fr.lproj/Localizable.strings index abea12f07..e2444593f 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/fr.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/fr.lproj/Localizable.strings @@ -737,3 +737,6 @@ "Downloads your own usage export with the Cursor app's login, at most once an hour." = "Télécharge votre propre export d'utilisation avec la connexion de l'app Cursor, au plus une fois par heure."; "Turned off by CODEBURN_CURSOR_SYNC=0" = "Désactivé par CODEBURN_CURSOR_SYNC=0"; "Couldn't save the setting: config.json is invalid or locked" = "Impossible d'enregistrer le réglage : config.json est invalide ou verrouillé"; +"Claude profiles" = "Profils Claude"; +"Separate rings" = "Anneaux séparés"; +"Default" = "Par défaut"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/ja.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/ja.lproj/Localizable.strings index 4b8fdef47..6c0bcd50b 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/ja.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/ja.lproj/Localizable.strings @@ -737,3 +737,6 @@ "Downloads your own usage export with the Cursor app's login, at most once an hour." = "Cursorアプリのサインインで自分の使用量エクスポートをダウンロードします(1時間に最大1回)。"; "Turned off by CODEBURN_CURSOR_SYNC=0" = "CODEBURN_CURSOR_SYNC=0によりオフになっています"; "Couldn't save the setting: config.json is invalid or locked" = "設定を保存できませんでした。config.jsonが無効か、ロックされています"; +"Claude profiles" = "Claude プロファイル"; +"Separate rings" = "個別のリング"; +"Default" = "デフォルト"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/ko.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/ko.lproj/Localizable.strings index 6be419886..a4a72afd7 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/ko.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/ko.lproj/Localizable.strings @@ -737,3 +737,6 @@ "Downloads your own usage export with the Cursor app's login, at most once an hour." = "Cursor 앱의 로그인으로 내 사용량 내보내기를 다운로드합니다(최대 1시간에 1회)."; "Turned off by CODEBURN_CURSOR_SYNC=0" = "CODEBURN_CURSOR_SYNC=0으로 꺼져 있음"; "Couldn't save the setting: config.json is invalid or locked" = "설정을 저장할 수 없습니다. config.json이 잘못되었거나 잠겨 있습니다"; +"Claude profiles" = "Claude 프로필"; +"Separate rings" = "개별 링"; +"Default" = "기본"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings index 80274aac6..9a04b4911 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings @@ -735,3 +735,6 @@ "Downloads your own usage export with the Cursor app's login, at most once an hour." = "使用 Cursor 应用的登录下载你自己的用量导出,每小时最多一次。"; "Turned off by CODEBURN_CURSOR_SYNC=0" = "已被 CODEBURN_CURSOR_SYNC=0 关闭"; "Couldn't save the setting: config.json is invalid or locked" = "无法保存设置:config.json 无效或已被锁定"; +"Claude profiles" = "Claude 配置档案"; +"Separate rings" = "独立圆环"; +"Default" = "默认"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/zh-Hant.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/zh-Hant.lproj/Localizable.strings index 4fd526d80..dbff17705 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/zh-Hant.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/zh-Hant.lproj/Localizable.strings @@ -733,3 +733,6 @@ "Downloads your own usage export with the Cursor app's login, at most once an hour." = "使用 Cursor 應用的登入下載你自己的用量匯出,每小時最多一次。"; "Turned off by CODEBURN_CURSOR_SYNC=0" = "已被 CODEBURN_CURSOR_SYNC=0 關閉"; "Couldn't save the setting: config.json is invalid or locked" = "無法儲存設定:config.json 無效或已被鎖定"; +"Claude profiles" = "Claude 設定檔"; +"Separate rings" = "獨立圓環"; +"Default" = "預設"; diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift index bd54ac583..2787447ad 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift @@ -273,9 +273,9 @@ struct CapacityDockPreferencesTests { let model = CapacityDockViewModel(preferences: snapshot, claudeProfiles: profiles) #expect(model.usesSeparateClaudeProfiles) #expect(model.effectiveSelectedProviders == [ + .codex, .claudeProfile(id: "claude-config:0e4378b7c9974e40"), .claudeProfile(id: "claude-org:64cceafd-5d4b-4fdd-928c-91c807474898"), - .codex, zcode, ]) #expect(model.effectivePreferredProvider == .claudeProfile(id: "claude-config:0e4378b7c9974e40"))