mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 14:15:56 +00:00
Group native subscription rows by provider and render each provider as its own horizontally paged carousel of profile cards (one profile per page, swipe between a provider's profiles, page dots indicate count). Add a surface tag chip (ccs/ccsx) and dim parked profiles. Switch the native-vs-pool split to the is_subscription flag so pool codex accounts are not misclassified. Raise the deployment target to macOS 14 for the native scroll paging APIs and bump the Bar version to 1.9.0.
196 lines
7.4 KiB
Swift
196 lines
7.4 KiB
Swift
import Foundation
|
|
|
|
/// One quota window for a native subscription row (Claude/Codex).
|
|
///
|
|
/// Carries BOTH `usedPercent` and `remainingPercent` verbatim from the backend
|
|
/// so the bar never re-derives one from the other (a single source of truth
|
|
/// avoids rounding drift between the collapsed glance value and the per-window
|
|
/// pace math). `windowMinutes` is the window length (300 = 5h, 10080 = 7d) used
|
|
/// by the burn-rate projection; nil when the backend could not determine it.
|
|
///
|
|
/// JSON: the parent field serializes to "quota_windows" (snake_case), but the
|
|
/// inner keys stay camelCase to match the live serializer.
|
|
public struct QuotaWindowDetail: Codable, Sendable, Equatable, Identifiable {
|
|
public let key: String
|
|
public let label: String
|
|
public let usedPercent: Double
|
|
public let remainingPercent: Double
|
|
public let resetAt: String?
|
|
public let windowMinutes: Int?
|
|
|
|
/// SwiftUI list identity: the window key is unique within a row.
|
|
public var id: String { key }
|
|
|
|
public init(
|
|
key: String,
|
|
label: String,
|
|
usedPercent: Double,
|
|
remainingPercent: Double,
|
|
resetAt: String? = nil,
|
|
windowMinutes: Int? = nil
|
|
) {
|
|
self.key = key
|
|
self.label = label
|
|
self.usedPercent = usedPercent
|
|
self.remainingPercent = remainingPercent
|
|
self.resetAt = resetAt
|
|
self.windowMinutes = windowMinutes
|
|
}
|
|
}
|
|
|
|
/// One account row in the menu-bar glance.
|
|
///
|
|
/// Mirrors the `GET /api/bar/summary` payload exactly (mixed snake_case and
|
|
/// camelCase keys, matching the CCS web-server response).
|
|
public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
|
public let accountId: String
|
|
public let provider: String
|
|
public let displayName: String?
|
|
public let tier: String?
|
|
public let paused: Bool
|
|
public let quotaPercentage: Double?
|
|
/// Tri-state quota availability: "ok" (provider has a quota API and the fetch
|
|
/// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro),
|
|
/// or "error" (should report quota but the fetch failed/timed out/needs reauth).
|
|
/// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never
|
|
/// conflates the two.
|
|
public let quotaStatus: String
|
|
public let nextReset: String?
|
|
/// True when this is the provider's default account; drives the active/default badge.
|
|
public let isDefault: Bool
|
|
/// ISO timestamp this account was last used, null if never/unknown.
|
|
public let lastActivityAt: String?
|
|
public let todayCost: Double?
|
|
public let health: String
|
|
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).
|
|
public let quotaWindows: [QuotaWindowDetail]?
|
|
/// Native-only ISO mtime of the source session that supplied a STALE Codex
|
|
/// reading. Present only when the data is stale; nil otherwise. Drives the
|
|
/// "as of HH:mm (older session)" footnote without faking a "live" badge.
|
|
public let staleAsOf: String?
|
|
|
|
/// Stable identity for SwiftUI lists: provider-scoped account id.
|
|
public var id: String { "\(provider):\(accountId)" }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case accountId = "account_id"
|
|
case provider
|
|
case displayName
|
|
case tier
|
|
case paused
|
|
case quotaPercentage = "quota_percentage"
|
|
case quotaStatus
|
|
case nextReset = "next_reset"
|
|
case isDefault = "is_default"
|
|
case lastActivityAt = "last_activity_at"
|
|
case todayCost = "today_cost"
|
|
case health
|
|
case cached
|
|
case fetchedAt
|
|
case needsReauth
|
|
case surface
|
|
case profile
|
|
case isSubscription = "is_subscription"
|
|
case quotaWindows = "quota_windows"
|
|
case staleAsOf = "stale_as_of"
|
|
}
|
|
|
|
public init(
|
|
accountId: String,
|
|
provider: String,
|
|
displayName: String? = nil,
|
|
tier: String? = nil,
|
|
paused: Bool = false,
|
|
quotaPercentage: Double? = nil,
|
|
quotaStatus: String = "ok",
|
|
nextReset: String? = nil,
|
|
isDefault: Bool = false,
|
|
lastActivityAt: String? = nil,
|
|
todayCost: Double? = nil,
|
|
health: String = "ok",
|
|
cached: Bool = false,
|
|
fetchedAt: String? = nil,
|
|
needsReauth: Bool = false,
|
|
surface: String? = nil,
|
|
profile: String? = nil,
|
|
isSubscription: Bool? = nil,
|
|
quotaWindows: [QuotaWindowDetail]? = nil,
|
|
staleAsOf: String? = nil
|
|
) {
|
|
self.accountId = accountId
|
|
self.provider = provider
|
|
self.displayName = displayName
|
|
self.tier = tier
|
|
self.paused = paused
|
|
self.quotaPercentage = quotaPercentage
|
|
self.quotaStatus = quotaStatus
|
|
self.nextReset = nextReset
|
|
self.isDefault = isDefault
|
|
self.lastActivityAt = lastActivityAt
|
|
self.todayCost = todayCost
|
|
self.health = health
|
|
self.cached = cached
|
|
self.fetchedAt = fetchedAt
|
|
self.needsReauth = needsReauth
|
|
self.surface = surface
|
|
self.profile = profile
|
|
self.isSubscription = isSubscription
|
|
self.quotaWindows = quotaWindows
|
|
self.staleAsOf = staleAsOf
|
|
}
|
|
|
|
/// Resilient decode: the two native-only keys are decoded with
|
|
/// `decodeIfPresent` so a legacy payload (no "quota_windows"/"stale_as_of")
|
|
/// yields nil rather than a decode failure. All other keys keep synthesized
|
|
/// behavior.
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
accountId = try c.decode(String.self, forKey: .accountId)
|
|
provider = try c.decode(String.self, forKey: .provider)
|
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
|
tier = try c.decodeIfPresent(String.self, forKey: .tier)
|
|
paused = try c.decode(Bool.self, forKey: .paused)
|
|
quotaPercentage = try c.decodeIfPresent(Double.self, forKey: .quotaPercentage)
|
|
quotaStatus = try c.decode(String.self, forKey: .quotaStatus)
|
|
nextReset = try c.decodeIfPresent(String.self, forKey: .nextReset)
|
|
isDefault = try c.decode(Bool.self, forKey: .isDefault)
|
|
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
|
todayCost = try c.decodeIfPresent(Double.self, forKey: .todayCost)
|
|
health = try c.decode(String.self, forKey: .health)
|
|
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)
|
|
}
|
|
}
|
|
|
|
extension BarSummaryRow {
|
|
/// Health rendered as an ASCII-safe dot for the dropdown.
|
|
public var healthDot: String {
|
|
switch health {
|
|
case "error": return "X"
|
|
case "warning": return "!"
|
|
default: return "OK"
|
|
}
|
|
}
|
|
}
|