mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(bar-app): render native subscription accounts with quota gauges
Show Claude Code and Codex subscription rows with the Tier 1 quota gauge and reset countdown, visually distinguished from CLIProxy provider accounts, and let them drive the existing quota alerts. Formatting + harness coverage for the native rows.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user