diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index e31d3558..86e1e01a 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -41,21 +41,7 @@ struct BarMenuView: View { } } - VStack(alignment: .leading, spacing: 6) { - SectionLabel("Accounts") - if let error = viewModel.lastError { - ErrorBanner(message: error) - } - if viewModel.rows.isEmpty { - Text("No accounts configured") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(viewModel.rows) { row in - BarRowView(row: row, viewModel: viewModel) - } - } - } + accountsSection } .padding(12) } @@ -76,6 +62,41 @@ struct BarMenuView: View { } } + /// Accounts list. Native first-party subscriptions (Claude Code / Codex) render + /// in a top "Subscriptions" group so the user's own plan quota reads apart from + /// the rotating CLIProxy "Pool accounts". The split is suppressed when there are + /// no subscriptions (or no pool), so the established single "Accounts" header is + /// kept for the common CLIProxy-only setup. + @ViewBuilder private var accountsSection: some View { + let parts = BarFormatting.partitionSubscriptions(viewModel.rows) + VStack(alignment: .leading, spacing: 6) { + if let error = viewModel.lastError { + ErrorBanner(message: error) + } + if viewModel.rows.isEmpty { + SectionLabel("Accounts") + Text("No accounts configured") + .font(.caption) + .foregroundStyle(.secondary) + } else if parts.subscriptions.isEmpty || parts.pool.isEmpty { + // Only one kind present: keep the single established header. + SectionLabel("Accounts") + ForEach(viewModel.rows) { row in + BarRowView(row: row, viewModel: viewModel) + } + } else { + SectionLabel("Subscriptions") + ForEach(parts.subscriptions) { row in + BarRowView(row: row, viewModel: viewModel) + } + SectionLabel("Pool accounts") + ForEach(parts.pool) { row in + BarRowView(row: row, viewModel: viewModel) + } + } + } + } + private var header: some View { HStack(spacing: 8) { Image(nsImage: MenuBarIcon.headerImage()) @@ -164,6 +185,12 @@ struct BarRowView: View { let row: BarSummaryRow @ObservedObject var viewModel: BarViewModel + /// A native first-party subscription (Claude Code / Codex) — drives the + /// distinct "subscription" badge + indigo provider chip. + private var isNativeSubscription: Bool { + BarFormatting.isNativeSubscription(provider: row.provider) + } + var body: some View { HStack(alignment: .top, spacing: 9) { Circle() @@ -186,9 +213,14 @@ struct BarRowView: View { if row.needsReauth { Chip("reauth", tint: .red) } + if isNativeSubscription { + Chip("subscription", tint: BarTheme.subscription) + } } HStack(spacing: 6) { - Chip(row.provider, tint: BarTheme.accent) + Chip( + BarFormatting.providerLabel(row.provider), + tint: isNativeSubscription ? BarTheme.subscription : BarTheme.accent) if let tier = row.tier { Chip(tier, tint: .secondary) } QuotaGaugeView( percentage: row.quotaPercentage, diff --git a/macos-bar/Sources/CCSBarApp/Sparkline.swift b/macos-bar/Sources/CCSBarApp/Sparkline.swift index 81e64476..fe45e2c1 100644 --- a/macos-bar/Sources/CCSBarApp/Sparkline.swift +++ b/macos-bar/Sources/CCSBarApp/Sparkline.swift @@ -25,4 +25,8 @@ struct Sparkline: View { /// Shared visual tokens for the menu. The accent matches the CCS logo orange. enum BarTheme { static let accent = Color(red: 0.886, green: 0.451, blue: 0.137) // ~#E2732A + /// Distinct tint for native first-party subscription rows (Claude Code / Codex) + /// so the user's own plan reads apart from CLIProxy pool accounts. A cool indigo + /// contrasts with the warm orange accent used for everything else. + static let subscription = Color(red: 0.357, green: 0.388, blue: 0.851) // ~#5B63D9 } diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 7dec7c72..38aed6be 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -785,6 +785,145 @@ do { "disabled quota rule never fires") } +// MARK: Native subscription rows (Claude Code / Codex) +// +// The backend folds first-party subscription quota into /api/bar/summary as +// ordinary BarSummaryRow values (provider "claude-code" / "codex"). They must +// decode, drive the Tier-1 gauge + alert engine with ZERO new engine code, and +// group/label distinctly from CLIProxy pool accounts. + +// (N1) Decode the exact native-row payload the collector emits. +let nativeJSON = """ +[ + { + "account_id": "claude-code", "provider": "claude-code", "displayName": "Claude Code", + "tier": "max", "paused": false, "quota_percentage": 15, "quotaStatus": "ok", + "next_reset": "2026-06-09T20:00:00.000Z", "is_default": false, "last_activity_at": null, + "today_cost": null, "health": "ok", "cached": true, "fetchedAt": "2026-06-09T13:00:00.000Z", + "needsReauth": false + }, + { + "account_id": "codex", "provider": "codex", "displayName": "Codex", + "tier": "pro", "paused": false, "quota_percentage": 52, "quotaStatus": "ok", + "next_reset": "2026-06-09T19:00:00.000Z", "is_default": false, "last_activity_at": null, + "today_cost": null, "health": "ok", "cached": false, "fetchedAt": "2026-06-09T13:00:00.000Z", + "needsReauth": false + } +] +""" +do { + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(nativeJSON.utf8)) + check(rows.count == 2, "native: decodes claude-code + codex rows") + check(rows[0].provider == "claude-code", "native: claude-code provider decodes") + check(rows[0].id == "claude-code:claude-code", "native: claude-code stable id") + check(rows[0].tier == "max", "native: claude-code tier decodes (max)") + check(rows[0].quotaStatus == "ok", "native: claude-code quotaStatus ok") + check(rows[0].todayCost == nil, "native: today_cost null decodes to nil (honest, not fake $0)") + check(rows[1].provider == "codex", "native: codex provider decodes") + check(rows[1].tier == "pro", "native: codex tier decodes (pro)") +} + +// (N2) Native rows drive the Tier-1 gauge with no new code: an "ok" claude-code +// row at 15% lands in the orange band with a 0.15 fill. +do { + let claude = BarSummaryRow( + accountId: "claude-code", provider: "claude-code", displayName: "Claude Code", + tier: "max", quotaPercentage: 15, quotaStatus: "ok", + nextReset: "2026-06-09T20:00:00Z") + check( + BarQuotaGauge.band(percentage: claude.quotaPercentage, status: claude.quotaStatus) == .orange, + "native: claude-code 15% -> orange gauge band") + check( + BarQuotaGauge.fillFraction(percentage: claude.quotaPercentage, status: claude.quotaStatus) + == 0.15, + "native: claude-code 15% -> 0.15 gauge fill") +} + +// (N3) Native rows feed the alert engine with no new code: a claude-code row at 8% +// with levels [20,10,0] fires quotaRemainingBelow scoped to its id. +do { + let rows = [ + BarSummaryRow( + accountId: "claude-code", provider: "claude-code", displayName: "Claude Code", + tier: "max", quotaPercentage: 8, quotaStatus: "ok", + nextReset: "2026-06-09T20:00:00Z") + ] + let ev = BarAlertEngine.evaluate( + rows: rows, analytics: nil, prefs: BarAlertPrefs(quotaLevels: [20, 10, 0]), + priorFiredKeys: [], now: engineNow, calendar: utc) + let quota = ev.toDeliver.filter { $0.kind == .quotaRemainingBelow } + check(quota.count == 1, "native: claude-code low quota fires exactly one alert") + check( + quota.first?.id.contains("|claude-code:claude-code|") == true, + "native: quota alert scoped to claude-code:claude-code") + check(quota.first?.body.contains("Claude Code") == true, "native: alert names 'Claude Code'") +} + +// (N4) Native reauth: a claude-code row that needs reauth fires reauthNeeded +// (the 401-from-usage-endpoint path on the backend). +do { + let rows = [ + BarSummaryRow( + accountId: "claude-code", provider: "claude-code", displayName: "Claude Code", + quotaStatus: "error", health: "error", needsReauth: true) + ] + let ev = BarAlertEngine.evaluate( + rows: rows, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: [], now: engineNow, + calendar: utc) + check( + ev.toDeliver.contains { $0.kind == .reauthNeeded }, + "native: claude-code needsReauth fires reauthNeeded alert") +} + +// (N5) Friendly provider labels + subscription classification. +check( + BarFormatting.providerLabel("claude-code") == "Claude Code", + "native: providerLabel maps claude-code -> 'Claude Code'") +check( + BarFormatting.providerLabel("codex") == "Codex", "native: providerLabel maps codex -> 'Codex'") +check(BarFormatting.providerLabel("agy") == "agy", "native: providerLabel passes CLIProxy keys through") +check( + BarFormatting.isNativeSubscription(provider: "claude-code"), + "native: claude-code is a native subscription") +check( + BarFormatting.isNativeSubscription(provider: "codex"), "native: codex is a native subscription") +check( + !BarFormatting.isNativeSubscription(provider: "agy"), + "native: agy (CLIProxy pool) is NOT a native subscription") + +// (N6) Grouping: a mixed list splits into native subscriptions (top) and pool +// accounts, preserving backend order within each group. +do { + let mixed = [ + BarSummaryRow(accountId: "pool-a", provider: "agy", quotaPercentage: 80, quotaStatus: "ok"), + BarSummaryRow( + accountId: "claude-code", provider: "claude-code", quotaPercentage: 40, quotaStatus: "ok"), + BarSummaryRow(accountId: "pool-b", provider: "ghcp", quotaStatus: "unsupported"), + BarSummaryRow(accountId: "codex", provider: "codex", quotaPercentage: 52, quotaStatus: "ok"), + ] + let parts = BarFormatting.partitionSubscriptions(mixed) + check(parts.subscriptions.count == 2, "native: partition pulls 2 subscriptions") + check(parts.pool.count == 2, "native: partition leaves 2 pool accounts") + check( + parts.subscriptions.map { $0.provider } == ["claude-code", "codex"], + "native: subscriptions keep backend order (claude-code, codex)") + check( + parts.pool.map { $0.provider } == ["agy", "ghcp"], + "native: pool keeps backend order (agy, ghcp)") +} + +// (N7) Pool-only list does NOT get split (single "Accounts" header path): both +// groups non-empty is the only trigger for the two-section render. +do { + let poolOnly = [ + BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 70, quotaStatus: "ok"), + BarSummaryRow(accountId: "b", provider: "ghcp", quotaStatus: "unsupported"), + ] + let parts = BarFormatting.partitionSubscriptions(poolOnly) + check(parts.subscriptions.isEmpty, "native: pool-only list has no subscriptions to split out") + check(parts.pool.count == 2, "native: pool-only list keeps all rows in pool group") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift index fa0b6b03..57ecd896 100644 --- a/macos-bar/Sources/CCSBarCore/BarFormatting.swift +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -161,6 +161,43 @@ public enum BarFormatting { return "Last active \(fmt.string(from: date))" } + /// True when a row is a native first-party subscription (the user's own Claude + /// 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. + public static func isNativeSubscription(provider: String) -> Bool { + provider == "claude-code" || provider == "codex" + } + + /// 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). + public static func providerLabel(_ provider: String) -> String { + switch provider { + case "claude-code": return "Claude Code" + case "codex": return "Codex" + default: return provider + } + } + + /// Partition rows into (native subscriptions, CLIProxy pool accounts) while + /// preserving the backend's order within each group. Used by the dropdown to + /// render subscriptions above the pool. Pure so it is testable in Core. + public static func partitionSubscriptions( + _ rows: [BarSummaryRow] + ) -> (subscriptions: [BarSummaryRow], pool: [BarSummaryRow]) { + var subs: [BarSummaryRow] = [] + var pool: [BarSummaryRow] = [] + for row in rows { + if isNativeSubscription(provider: row.provider) { + subs.append(row) + } else { + pool.append(row) + } + } + return (subs, pool) + } + /// Parse an ISO-8601 timestamp (with or without fractional seconds). static func isoDate(_ iso: String) -> Date? { let withFraction = ISO8601DateFormatter()