diff --git a/macos-bar/Package.swift b/macos-bar/Package.swift index 8e8a642f..1e06fdc1 100644 --- a/macos-bar/Package.swift +++ b/macos-bar/Package.swift @@ -10,7 +10,7 @@ import PackageDescription // the core is verified. let package = Package( name: "CCSBar", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .executable(name: "CCSBar", targets: ["CCSBarApp"]), .executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]), diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 80017740..91a60b71 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -36,6 +36,11 @@ struct BarMenuView: View { /// monotonically (ContentHeightKey.reduce takes the max), so the frame never /// thrashes downward. @State private var contentHeight: CGFloat = 0 + /// Multi-profile carousel: which provider page is currently visible. Resets to + /// first provider on popover re-open (KISS — no UserDefaults persistence needed). + // Per-provider carousel position: provider -> selected profile row id. Each + // provider has its own profile carousel, so selection is tracked per provider. + @State private var selectedProfileByProvider: [String: String] = [:] // MARK: - Screen cap @@ -164,6 +169,9 @@ struct BarMenuView: View { viewModel.onOpen() // Disarm quit on every popover open so a stale armed state never persists. quitArmed = false + // Reset each provider's carousel to its first profile on every open — KISS, + // no persistence needed. + selectedProfileByProvider = [:] } } @@ -234,9 +242,67 @@ struct BarMenuView: View { BarRowView(row: row, viewModel: viewModel) } } else { + // Per-provider profile carousels: group subscription rows by provider, and + // render each provider as its OWN horizontally-paged carousel of PROFILE + // cards — one profile visible at a time, swipe left/right between that + // provider's profiles, page dots indicate count. Provider sections stack + // vertically. A provider with a single profile shows just its card (no + // carousel, no dots). + let groups = Dictionary( + grouping: orderedSubscriptions(parts.subscriptions), by: { $0.provider }) + let providers = groups.keys.sorted() // stable: "claude-code" < "codex" + let multiProvider = providers.count > 1 subscriptionsHeader(parts.subscriptions) - ForEach(orderedSubscriptions(parts.subscriptions)) { row in - BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() }) + ForEach(providers, id: \.self) { prov in + let rows = groups[prov] ?? [] + VStack(alignment: .leading, spacing: 4) { + // Provider caption to delineate sections when more than one provider. + if multiProvider { + Text(BarFormatting.providerLabel(prov)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + } + if rows.count <= 1 { + // Single profile — render the card directly, no carousel. + if let row = rows.first { + BarSubscriptionCard( + row: row, isParked: row.paused, + onRefresh: { viewModel.forceRefresh() }) + } + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(rows) { row in + BarSubscriptionCard( + row: row, isParked: row.paused, + onRefresh: { viewModel.forceRefresh() }) + // 340 leaves 10px side padding inside the 360-wide popover. + .frame(width: 340, alignment: .leading) + .padding(.horizontal, 2) + .id(row.id) + } + } + // scrollTargetLayout on the HStack (the paging container) snaps to + // each profile card on .paging behavior. + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition(id: Binding( + get: { selectedProfileByProvider[prov] ?? rows.first?.id }, + set: { selectedProfileByProvider[prov] = $0 ?? rows.first?.id })) + .frame(height: carouselHeight(rows)) + // Page dots — one per profile in this provider's carousel. + HStack(spacing: 6) { + ForEach(rows) { row in + let isCurrent = (selectedProfileByProvider[prov] ?? rows.first?.id) == row.id + Circle() + .fill(isCurrent ? theme.subscription : Color.secondary.opacity(0.3)) + .frame(width: 6, height: 6) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + } } } } @@ -295,6 +361,19 @@ struct BarMenuView: View { } } + /// Estimates the height of a single profile carousel so its frame does not + /// collapse. Only ONE card is visible at a time, so the height is the tallest + /// single card in the carousel: a title row plus one bar per quota window, or a + /// compact parked/empty card when there are no windows. + private func carouselHeight(_ rows: [BarSummaryRow]) -> CGFloat { + let maxWindows = rows.map { $0.quotaWindows?.count ?? 0 }.max() ?? 0 + if maxWindows == 0 { + return 80 // parked / reauth card: title row + status line only. + } + // ~34pt title row + ~28pt per window bar + vertical padding. + return 44 + CGFloat(maxWindows) * 30 + } + private var header: some View { HStack(spacing: 8) { Image(nsImage: MenuBarIcon.headerImage()) diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift index cf96fdde..f1f77d55 100644 --- a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -13,6 +13,9 @@ import CCSBarCore struct BarSubscriptionCard: View { @Environment(\.barTheme) private var theme let row: BarSummaryRow + /// When true this profile is parked (not the active/default profile for the + /// surface). The card is dimmed to 50% opacity to signal it is not live. + var isParked: Bool = false /// Injected clock — defaults to live Date() in production, pinned in previews /// and tests so countdown math is deterministic. var now: Date = Date() @@ -44,20 +47,28 @@ struct BarSubscriptionCard: View { .background( theme.cardSurface, in: RoundedRectangle(cornerRadius: 9)) + // Dim parked (non-active) profiles so the active profile clearly dominates. + .opacity(isParked ? 0.5 : 1.0) } // MARK: Title row - /// Health dot + product name + reauth chip + tier chip. No pause toggle — - /// subscriptions are not routable pool accounts. + /// Health dot + profile name (or provider label for legacy rows) + surface chip + /// + reauth chip + tier chip. No pause toggle — subscriptions are not routable + /// pool accounts. private var titleRow: some View { HStack(spacing: 8) { Circle() .fill(healthColor) .frame(width: 8, height: 8) - Text(BarFormatting.providerLabel(row.provider)) + // Multi-profile: show profile name when available; fall back to provider label. + Text(row.profile ?? BarFormatting.providerLabel(row.provider)) .font(.system(.body, design: .default).weight(.semibold)) .lineLimit(1) + // Surface tag chip ("ccs · work", "ccsx · personal") for multi-profile rows. + if let tag = BarFormatting.surfaceProfileLabel(row) { + Chip(tag, tint: theme.subscription.opacity(isParked ? 0.5 : 1)) + } if row.needsReauth { Chip("reauth", tint: theme.bandRed) } diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 0c9d5833..36ac3e67 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1686,6 +1686,156 @@ do { check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected") } +// MARK: Multi-profile fields decode (GH-1595) +// +// Three new optional wire keys: surface, profile, is_subscription. +// Native rows carry all three; CLIProxy pool rows omit them — legacy decoders +// must not fail and must yield nil for the absent fields. + +let multiProfileJSON = """ +[ + { + "account_id": "ccs:work", + "provider": "claude-code", + "surface": "ccs", + "profile": "work", + "is_subscription": true, + "displayName": "work", + "tier": "max", + "paused": false, + "quota_percentage": 62, + "quotaStatus": "ok", + "next_reset": "2026-06-24T01:00:00.000Z", + "is_default": true, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": false, + "fetchedAt": "2026-06-23T20:40:00.000Z", + "needsReauth": false + }, + { + "account_id": "ccsx:ck", + "provider": "codex", + "surface": "ccsx", + "profile": "ck", + "is_subscription": true, + "displayName": "ck", + "tier": "pro", + "paused": true, + "quota_percentage": 80, + "quotaStatus": "ok", + "next_reset": null, + "is_default": false, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": true, + "fetchedAt": "2026-06-23T16:19:00.000Z", + "needsReauth": false, + "stale_as_of": "2026-06-23T16:19:00.000Z" + }, + { + "account_id": "alice@example.com", + "provider": "agy", + "displayName": "Alice (Ultra)", + "tier": "ultra", + "paused": false, + "quota_percentage": 70, + "quotaStatus": "ok", + "next_reset": null, + "is_default": false, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": true, + "fetchedAt": "2026-06-23T20:00:00.000Z", + "needsReauth": false + } +] +""" + +do { + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(multiProfileJSON.utf8)) + check(rows.count == 3, "mp: decodes 3 rows (2 native + 1 legacy CLIProxy)") + + // (MP1) Native Claude active row + let claudeRow = rows[0] + check(claudeRow.accountId == "ccs:work", "mp: native Claude account_id = 'ccs:work'") + check(claudeRow.surface == "ccs", "mp: native Claude surface = 'ccs'") + check(claudeRow.profile == "work", "mp: native Claude profile = 'work'") + check(claudeRow.isSubscription == true, "mp: native Claude is_subscription = true") + check(claudeRow.paused == false, "mp: active Claude profile is not paused") + + // (MP2) Parked Codex row + let codexRow = rows[1] + check(codexRow.accountId == "ccsx:ck", "mp: parked Codex account_id = 'ccsx:ck'") + check(codexRow.surface == "ccsx", "mp: parked Codex surface = 'ccsx'") + check(codexRow.profile == "ck", "mp: parked Codex profile = 'ck'") + check(codexRow.isSubscription == true, "mp: parked Codex is_subscription = true") + check(codexRow.paused == true, "mp: parked Codex row has paused = true") + + // (MP3) Legacy CLIProxy row — new fields decode to nil without failure + let legacyRow = rows[2] + check(legacyRow.surface == nil, "mp: CLIProxy row surface decodes to nil (backward compat)") + check(legacyRow.profile == nil, "mp: CLIProxy row profile decodes to nil (backward compat)") + check(legacyRow.isSubscription == nil, "mp: CLIProxy row is_subscription decodes to nil") + + // (MP4) isNativeSubscription uses is_subscription flag when present + check( + BarFormatting.isNativeSubscription(claudeRow), + "mp: isNativeSubscription true when is_subscription=true (new flag path)") + check( + BarFormatting.isNativeSubscription(codexRow), + "mp: isNativeSubscription true for parked Codex row (is_subscription=true)") + check( + !BarFormatting.isNativeSubscription(legacyRow), + "mp: isNativeSubscription false for CLIProxy pool row (no is_subscription)") + + // (MP5) Legacy rows without is_subscription use fallback heuristic + let legacyClaudeRow = BarSummaryRow(accountId: "claude-code", provider: "claude-code") + check( + BarFormatting.isNativeSubscription(legacyClaudeRow), + "mp: legacy claude-code row uses heuristic fallback -> is native") + let legacyCodexRow = BarSummaryRow(accountId: "codex", provider: "codex") + check( + BarFormatting.isNativeSubscription(legacyCodexRow), + "mp: legacy codex row uses heuristic fallback -> is native") + let poolCodexRow = BarSummaryRow(accountId: "pool-codex-oauth-1", provider: "codex") + check( + !BarFormatting.isNativeSubscription(poolCodexRow), + "mp: CLIProxy codex pool row with no is_subscription -> not native (heuristic)") + + // (MP6) partitionSubscriptions correctly splits with new flag + let parts = BarFormatting.partitionSubscriptions(rows) + check(parts.subscriptions.count == 2, "mp: partition yields 2 native subscriptions") + check(parts.pool.count == 1, "mp: partition yields 1 CLIProxy pool row") + check( + parts.subscriptions.map { $0.accountId } == ["ccs:work", "ccsx:ck"], + "mp: subscriptions keep backend order") + + // (MP7) surfaceProfileLabel helper + check( + BarFormatting.surfaceProfileLabel(claudeRow) == "ccs · work", + "mp: surfaceProfileLabel for ccs:work -> 'ccs · work'") + check( + BarFormatting.surfaceProfileLabel(codexRow) == "ccsx · ck", + "mp: surfaceProfileLabel for ccsx:ck -> 'ccsx · ck'") + check( + BarFormatting.surfaceProfileLabel(legacyRow) == nil, + "mp: surfaceProfileLabel for CLIProxy row (no profile) -> nil") + + // (MP8) surfaceProfileLabel falls back to provider when surface is absent + let noSurfaceRow = BarSummaryRow( + accountId: "ccsx:personal", provider: "codex", + surface: nil, profile: "personal", isSubscription: true) + check( + BarFormatting.surfaceProfileLabel(noSurfaceRow) == "codex · personal", + "mp: surfaceProfileLabel falls back to provider when surface is nil") +} catch { + check(false, "mp: multi-profile JSON decode failed: \(error)") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift index e996c9bf..4bea7a92 100644 --- a/macos-bar/Sources/CCSBarCore/BarFormatting.swift +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -165,11 +165,25 @@ public enum BarFormatting { /// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives /// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota", /// not one of the rotating pool credentials. + /// + /// Prefers the explicit `is_subscription` flag from the server (multi-profile + /// feature). Falls back to the legacy heuristic (`accountId == provider`) so + /// old single-profile payloads still work without `is_subscription`. public static func isNativeSubscription(_ row: BarSummaryRow) -> Bool { - (row.provider == "claude-code" && row.accountId == "claude-code") + if let s = row.isSubscription { return s } + // Legacy fallback for old payloads that omit is_subscription. + return (row.provider == "claude-code" && row.accountId == "claude-code") || (row.provider == "codex" && row.accountId == "codex") } + /// Surface + profile chip label for multi-profile cards, e.g. "ccs · work" or + /// "ccsx · personal". Returns nil for CLIProxy pool rows that have no profile. + public static func surfaceProfileLabel(_ row: BarSummaryRow) -> String? { + guard let p = row.profile else { return nil } + let s = row.surface ?? row.provider + return "\(s) · \(p)" + } + /// Friendly product label for a provider key. Native subscription keys read as /// products ("Claude Code", "Codex"); any other provider passes through verbatim /// (so "agy"/"ghcp"/"kiro" keep their established short chip text). diff --git a/macos-bar/Sources/CCSBarCore/BarSummary.swift b/macos-bar/Sources/CCSBarCore/BarSummary.swift index 930f06ed..f76bd5f8 100644 --- a/macos-bar/Sources/CCSBarCore/BarSummary.swift +++ b/macos-bar/Sources/CCSBarCore/BarSummary.swift @@ -65,6 +65,16 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { public let cached: Bool public let fetchedAt: String? public let needsReauth: Bool + /// Multi-profile: which CCS surface owns this profile ("ccs" = Claude via + /// `ccs auth`, "ccsx" = Codex via `ccsx auth`). nil on CLIProxy pool rows. + public let surface: String? + /// Multi-profile: the profile name (e.g. "work", "ck", "personal"). nil on + /// CLIProxy pool rows. + public let profile: String? + /// Explicit native-subscription flag. true on all native rows (Claude/Codex + /// own subscription), nil/false on CLIProxy pool rows. Replaces the brittle + /// `accountId == "claude-code"` heuristic. + public let isSubscription: Bool? /// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, /// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows" /// entirely — so legacy payloads decode unchanged (backward compatible). @@ -93,6 +103,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { case cached case fetchedAt case needsReauth + case surface + case profile + case isSubscription = "is_subscription" case quotaWindows = "quota_windows" case staleAsOf = "stale_as_of" } @@ -113,6 +126,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { cached: Bool = false, fetchedAt: String? = nil, needsReauth: Bool = false, + surface: String? = nil, + profile: String? = nil, + isSubscription: Bool? = nil, quotaWindows: [QuotaWindowDetail]? = nil, staleAsOf: String? = nil ) { @@ -131,6 +147,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { self.cached = cached self.fetchedAt = fetchedAt self.needsReauth = needsReauth + self.surface = surface + self.profile = profile + self.isSubscription = isSubscription self.quotaWindows = quotaWindows self.staleAsOf = staleAsOf } @@ -156,6 +175,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { cached = try c.decode(Bool.self, forKey: .cached) fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt) needsReauth = try c.decode(Bool.self, forKey: .needsReauth) + surface = try c.decodeIfPresent(String.self, forKey: .surface) + profile = try c.decodeIfPresent(String.self, forKey: .profile) + isSubscription = try c.decodeIfPresent(Bool.self, forKey: .isSubscription) quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows) staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf) } diff --git a/macos-bar/VERSION b/macos-bar/VERSION index a8fdfda1..f8e233b2 100644 --- a/macos-bar/VERSION +++ b/macos-bar/VERSION @@ -1 +1 @@ -1.8.1 +1.9.0