From 29ca6cd9c6d315fbe60a4a50564557caa4c636c9 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Mon, 8 Jun 2026 22:08:14 +0100 Subject: [PATCH] feat(usage): add codex and agy usage visibility --- build.sh | 2 + panel/AntigravityUsage.swift | 145 +++++++++++++++++++++ panel/CodexUsage.swift | 107 ++++++++++++++++ panel/Panel.swift | 110 +++++++++++----- panel/PanelNav.swift | 47 +++++++ panel/SessionUsage.swift | 237 ++++++++++++++++++++++++++++------- 6 files changed, 569 insertions(+), 79 deletions(-) create mode 100644 panel/AntigravityUsage.swift create mode 100644 panel/CodexUsage.swift diff --git a/build.sh b/build.sh index 0f5b421..6cf8fc4 100755 --- a/build.sh +++ b/build.sh @@ -236,6 +236,8 @@ build_app "$APP" "stack-nudge" \ panel/SessionPersistence.swift \ panel/SessionStore.swift \ panel/SessionUsage.swift \ + panel/CodexUsage.swift \ + panel/AntigravityUsage.swift \ panel/Sessions.swift \ panel/CompactView.swift \ panel/TranscriptStats.swift \ diff --git a/panel/AntigravityUsage.swift b/panel/AntigravityUsage.swift new file mode 100644 index 0000000..b3bf1e9 --- /dev/null +++ b/panel/AntigravityUsage.swift @@ -0,0 +1,145 @@ +import Foundation + +// Antigravity (agy) usage, read from the running CLI's local loopback +// Connect-RPC. `agy` serves a language-server `GetUserStatus` endpoint on two +// 127.0.0.1 ports (one HTTP, one HTTPS); we POST to the HTTP one — no TLS, no +// CSRF, no auth (the running agy is already signed in, and it's loopback only, +// so nothing leaves the machine). Per-model quota windows + plan credits. +// Source: github.com/steipete/CodexBar#1178. +struct AntigravityQuotaSnapshot: Equatable { + let planType: String? + let models: [ModelQuota] + let promptCredits: Credits? + let flowCredits: Credits? + + struct ModelQuota: Equatable { + let label: String // e.g. "Claude Opus 4.6 (Thinking)" + let tier: QuotaTier // utilization 0…100 + reset time + } + struct Credits: Equatable { + let available: Int + let monthly: Int + } +} + +final class AntigravityUsageProbe { + + private static let endpointPath = + "/exa.language_server_pb.LanguageServerService/GetUserStatus" + private static let requestBody = + #"{"metadata":{"ideName":"antigravity","extensionName":"antigravity","locale":"en"}}"# + + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 4 + cfg.timeoutIntervalForResource = 6 + session = URLSession(configuration: cfg) + } + + // Calls completion on the main queue. Discovery + request run off-main. + func fetch(completion: @escaping (AntigravityQuotaSnapshot?) -> Void) { + DispatchQueue.global(qos: .utility).async { [weak self] in + let result = self?.probe() ?? nil + DispatchQueue.main.async { completion(result) } + } + } + + private func probe() -> AntigravityQuotaSnapshot? { + for port in Self.listeningPorts() { + if let data = requestStatus(port: port), let snapshot = Self.parse(data) { + return snapshot + } + } + return nil + } + + // agy listens on two loopback ports; the plaintext one answers our HTTP + // POST, the TLS one returns 400 — so try each over HTTP and take the first + // that returns a 200 we can parse. + private func requestStatus(port: Int) -> Data? { + guard let url = URL(string: "http://127.0.0.1:\(port)\(Self.endpointPath)") else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + request.httpBody = Self.requestBody.data(using: .utf8) + + let semaphore = DispatchSemaphore(value: 0) + var payload: Data? + var status = 0 + let task = session.dataTask(with: request) { data, response, _ in + payload = data + status = (response as? HTTPURLResponse)?.statusCode ?? 0 + semaphore.signal() + } + task.resume() + _ = semaphore.wait(timeout: .now() + 5) + return status == 200 ? payload : nil + } + + // Loopback listen ports of the running `agy` process. Empty when agy isn't + // running (or has no listening socket yet). + private static func listeningPorts() -> [Int] { + let output = ProcessOutput.read( + "/usr/sbin/lsof", + ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-c", "agy"] + ) + var ports: Set = [] + for line in output.split(separator: "\n") { + guard let range = line.range(of: #"127\.0\.0\.1:\d+"#, options: .regularExpression), + let port = Int(line[range].split(separator: ":").last ?? "") + else { continue } + ports.insert(port) + } + return Array(ports) + } + + private static let iso: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static func parse(_ data: Data) -> AntigravityQuotaSnapshot? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let userStatus = json["userStatus"] as? [String: Any] + else { return nil } + + let planStatus = userStatus["planStatus"] as? [String: Any] + let planInfo = planStatus?["planInfo"] as? [String: Any] + + var models: [AntigravityQuotaSnapshot.ModelQuota] = [] + if let configs = (userStatus["cascadeModelConfigData"] as? [String: Any])?["clientModelConfigs"] + as? [[String: Any]] { + for config in configs { + guard let label = config["label"] as? String, + let quota = config["quotaInfo"] as? [String: Any], + let remaining = (quota["remainingFraction"] as? NSNumber)?.doubleValue + else { continue } + let utilization = max(0, min(100, (1 - remaining) * 100)) + let resetsAt = (quota["resetTime"] as? String).flatMap { iso.date(from: $0) } + models.append(.init(label: label, + tier: QuotaTier(utilization: utilization, resetsAt: resetsAt))) + } + } + guard !models.isEmpty else { return nil } + + return AntigravityQuotaSnapshot( + planType: planInfo?["planName"] as? String, + models: models, + promptCredits: credits(available: planStatus?["availablePromptCredits"], + monthly: planInfo?["monthlyPromptCredits"]), + flowCredits: credits(available: planStatus?["availableFlowCredits"], + monthly: planInfo?["monthlyFlowCredits"]) + ) + } + + private static func credits(available: Any?, monthly: Any?) -> AntigravityQuotaSnapshot.Credits? { + guard let available = (available as? NSNumber)?.intValue, + let monthly = (monthly as? NSNumber)?.intValue, monthly > 0 + else { return nil } + return .init(available: available, monthly: monthly) + } +} diff --git a/panel/CodexUsage.swift b/panel/CodexUsage.swift new file mode 100644 index 0000000..fc5f8dc --- /dev/null +++ b/panel/CodexUsage.swift @@ -0,0 +1,107 @@ +import Foundation + +// Codex (ChatGPT-plan) rate limits, mirroring the shape of Claude's quota so +// the Usage tab can render them with the same QuotaTier rows. `primary` is the +// 5-hour rolling window, `secondary` the weekly one. `planType` is the ChatGPT +// tier ("plus", "pro", …) when reported. +struct CodexQuotaSnapshot: Equatable { + let primary: QuotaTier? + let secondary: QuotaTier? + let planType: String? +} + +// Reads Codex's account-level rate limits from the newest rollout JSONL under +// ~/.codex/sessions. Codex records them on each `token_count` event at +// `payload.rate_limits` (primary = 5h, secondary = weekly), with `used_percent` +// on a 0–100 scale and a unix `resets_at`. Local-only — no network, no auth. +// +// The limits are account-wide (not per-session), so the most-recently-written +// rollout holds the freshest values. Returns nil for API-key auth (no +// rate_limits emitted) or when no rollout exists, which the Usage tab treats as +// "no Codex usage to show". +final class CodexQuotaProbe { + + private let sessionsDir = "\(NSHomeDirectory())/.codex/sessions" + + // Skip re-parsing a rollout we've already read at this path+size+mtime — + // the probe runs on the same 60s/5min cadence as the Claude one, and most + // ticks hit an unchanged file. + private var cacheKey: String? + private var cached: CodexQuotaSnapshot? + + // Calls completion on the main queue. File IO runs off-main. + func fetch(completion: @escaping (CodexQuotaSnapshot?) -> Void) { + let dir = sessionsDir + DispatchQueue.global(qos: .utility).async { [weak self] in + let result = self?.read(dir: dir) ?? nil + DispatchQueue.main.async { completion(result) } + } + } + + private func read(dir: String) -> CodexQuotaSnapshot? { + guard let newest = Self.newestRollout(in: dir) else { return nil } + let key = "\(newest.path)|\(newest.size)|\(newest.mtime)" + if key == cacheKey { return cached } + let snapshot = Self.parseLatestRateLimits(path: newest.path) + cacheKey = key + cached = snapshot + return snapshot + } + + // Newest rollout-*.jsonl by modification date anywhere under the sessions + // tree (it's nested YYYY/MM/DD). Enumeration is stat-only and runs at most + // once per poll tick. + private static func newestRollout(in dir: String) -> (path: String, size: Int, mtime: TimeInterval)? { + let base = URL(fileURLWithPath: dir) + let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey] + guard let enumerator = FileManager.default.enumerator( + at: base, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles] + ) else { return nil } + + var best: (path: String, size: Int, mtime: TimeInterval)? + for case let url as URL in enumerator { + guard url.lastPathComponent.hasPrefix("rollout-"), + url.pathExtension == "jsonl" else { continue } + let values = try? url.resourceValues(forKeys: Set(keys)) + let mtime = values?.contentModificationDate?.timeIntervalSince1970 ?? 0 + let size = values?.fileSize ?? 0 + if best == nil || mtime > best!.mtime { + best = (url.path, size, mtime) + } + } + return best + } + + // Scan newest-line-first for the latest token_count event carrying + // rate_limits, and map it to the snapshot. + private static func parseLatestRateLimits(path: String) -> CodexQuotaSnapshot? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe), + let text = String(data: data, encoding: .utf8) else { return nil } + + for line in text.split(separator: "\n", omittingEmptySubsequences: true).reversed() { + guard line.contains("rate_limits"), + let lineData = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], + let payload = obj["payload"] as? [String: Any], + let rateLimits = payload["rate_limits"] as? [String: Any] + else { continue } + + return CodexQuotaSnapshot( + primary: tier(rateLimits["primary"]), + secondary: tier(rateLimits["secondary"]), + planType: rateLimits["plan_type"] as? String + ) + } + return nil + } + + // `used_percent` is already on a 0–100 scale; `resets_at` is unix seconds. + // Both arrive as JSON numbers, so decode via NSNumber to tolerate int/double. + private static func tier(_ raw: Any?) -> QuotaTier? { + guard let dict = raw as? [String: Any], + let used = (dict["used_percent"] as? NSNumber)?.doubleValue else { return nil } + let resetsAt = (dict["resets_at"] as? NSNumber) + .map { Date(timeIntervalSince1970: $0.doubleValue) } + return QuotaTier(utilization: used, resetsAt: resetsAt) + } +} diff --git a/panel/Panel.swift b/panel/Panel.swift index 5f5f29e..df7ec04 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -504,6 +504,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private var updateChecker: UpdateChecker? private var updater: Updater? private let quotaProbe = QuotaProbe() + private let codexQuotaProbe = CodexQuotaProbe() + private let antigravityUsageProbe = AntigravityUsageProbe() private var quotaTimer: Timer? // Subscriptions to other ObservableObjects we react to from PanelController. // Currently: SessionStore.sessions → refresh transcript stats proactively @@ -1172,11 +1174,52 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, self.nav.quotaLastUpdated = Date() self.evaluateQuotaThresholds(snapshot) } + // Codex (ChatGPT-plan) rate limits — read locally from the newest + // rollout, no network. Independent of the Anthropic probe above so one + // failing/absent doesn't suppress the other. + codexQuotaProbe.fetch { [weak self] snapshot in + guard let self, let snapshot else { return } + self.nav.codexQuota = snapshot + self.nav.quotaLastUpdated = Date() + } + // Antigravity (agy) usage — read from the running CLI's loopback RPC + // (localhost only, no auth). Independent of the probes above. + antigravityUsageProbe.fetch { [weak self] snapshot in + guard let self, let snapshot else { return } + self.nav.antigravityQuota = snapshot + self.nav.quotaLastUpdated = Date() + } } // Public hook for the Usage tab's "Sync now" keystroke. func syncQuotaNow() { runQuotaProbe() } + // MARK: - Usage tab detail scrolling + + // SwiftUI's ScrollView has no programmatic delta-scroll API, so walk the + // AppKit hierarchy to the underlying NSScrollView and nudge its clip view. + // Only one ScrollView is rendered at a time (mode-gated), so the first + // match is the Usage detail pane (used when focus is stepped into it). + private func scrollUsageBy(_ dy: CGFloat) { + guard let scrollView = findScrollView(in: panel.contentView), + let doc = scrollView.documentView else { return } + let clip = scrollView.contentView + let maxY = max(0, doc.frame.height - clip.bounds.height) + var origin = clip.bounds.origin + origin.y = min(max(0, origin.y + dy), maxY) + clip.scroll(to: origin) + scrollView.reflectScrolledClipView(clip) + } + + private func findScrollView(in view: NSView?) -> NSScrollView? { + guard let view else { return nil } + if let sv = view as? NSScrollView { return sv } + for sub in view.subviews { + if let found = findScrollView(in: sub) { return found } + } + return nil + } + // User-triggered update check with transient row feedback. Sets // .checking immediately, swaps to .upToDate / .failed on response // (the .updateAvailable path doesn't need transient feedback — the @@ -1720,31 +1763,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } - // MARK: - Usage tab keyboard scrolling - - // SwiftUI's ScrollView doesn't expose a programmatic scroll API for - // arbitrary deltas, so walk the AppKit hierarchy to the underlying - // NSScrollView and nudge its clip view directly. Only one ScrollView - // is rendered at a time (mode-gated), so the first match is correct. - private func scrollUsageBy(_ dy: CGFloat) { - guard let scrollView = findScrollView(in: panel.contentView), - let doc = scrollView.documentView else { return } - let clip = scrollView.contentView - let maxY = max(0, doc.frame.height - clip.bounds.height) - var origin = clip.bounds.origin - origin.y = min(max(0, origin.y + dy), maxY) - clip.scroll(to: origin) - scrollView.reflectScrolledClipView(clip) - } - - private func findScrollView(in view: NSView?) -> NSScrollView? { - guard let view else { return nil } - if let sv = view as? NSScrollView { return sv } - for sub in view.subviews { - if let found = findScrollView(in: sub) { return found } - } - return nil - } // MARK: - Config file watcher @@ -2135,27 +2153,51 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } - // Usage tab: Esc hides, ↑/↓ nudge the ScrollView. No row-selection - // semantics here, so arrow keys drive scroll directly. Other keys - // are swallowed so they don't leak through to the events store. + // Usage tab: two focus levels. In the client list, ↑/↓ switch the + // connected client and →/Enter steps into the detail pane. Inside the + // detail, ↑/↓ scroll it and ←/Esc step back out. Other keys are + // swallowed so they don't leak through to the events store. if nav.mode == .usage { let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty guard plain else { return false } + if nav.usageDetailFocused { + switch event.keyCode { + case KeyCode.escape: + hidePanel() + case KeyCode.leftArrow: + nav.usageDetailFocused = false + case KeyCode.upArrow: + scrollUsageBy(-40) + case KeyCode.downArrow: + scrollUsageBy(40) + case KeyCode.rKey: + syncQuotaNow() + case KeyCode.pKey: + nav.toggleQuotaTracking() + if nav.quotaTrackingEnabled { syncQuotaNow() } + case KeyCode.mKey: + enterCompactMode() + default: + break + } + return true + } switch event.keyCode { case KeyCode.escape: hidePanel() case KeyCode.upArrow: - scrollUsageBy(-40) + nav.selectPrevUsageClient() case KeyCode.downArrow: - scrollUsageBy(40) + nav.selectNextUsageClient() + case KeyCode.rightArrow, KeyCode.returnKey, KeyCode.numpadEnter: + nav.usageDetailFocused = true case KeyCode.rKey: syncQuotaNow() case KeyCode.pKey: nav.toggleQuotaTracking() - // Immediate probe on resume so the user sees fresh data - // right after the keystroke — otherwise they'd wait for - // the next scheduled tick (up to the configured poll - // interval, which could be 30 min). + // Immediate probe on resume so the user sees fresh data right + // after the keystroke — otherwise they'd wait for the next + // scheduled tick (up to the configured poll interval). if nav.quotaTrackingEnabled { syncQuotaNow() } case KeyCode.mKey: enterCompactMode() diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index cc857a1..826e69a 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -159,6 +159,53 @@ final class PanelNav: ObservableObject { // so the Sessions/Compact views can resolve stats by PID instead of // scanning the prunable event list, and so the poll refresh can re-read it. @Published var transcriptRefByPID: [Int: TranscriptRef] = [:] + // Codex (ChatGPT-plan) rate limits for the Usage tab, populated by + // CodexQuotaProbe — the Codex analogue of `quota` above. + @Published var codexQuota: CodexQuotaSnapshot? + // Antigravity (agy) usage from the running CLI's loopback RPC, populated by + // AntigravityUsageProbe — the agy analogue of `quota`/`codexQuota`. + @Published var antigravityQuota: AntigravityQuotaSnapshot? + // Usage tab: which connected client's quota is shown (index into + // availableUsageClients). ↑/↓ move it; read through clampedUsageClientIndex + // so a client losing its data can't strand the selection out of range. + @Published var usageClientIndex: Int = 0 + // When true, keyboard focus is inside the Usage detail pane: ↑/↓ scroll it + // rather than switching client. →/Enter steps in; ←/Esc steps back out. + @Published var usageDetailFocused: Bool = false + + // Connected clients that currently have quota to show, in display order. + var availableUsageClients: [UsageClient] { + var clients: [UsageClient] = [] + if let claude = quota, + !(claude.fiveHour == nil && claude.sevenDay == nil + && claude.sevenDayOpus == nil && claude.sevenDaySonnet == nil) { + clients.append(.claude) + } + if let codex = codexQuota, codex.primary != nil || codex.secondary != nil { + clients.append(.codex) + } + if let agy = antigravityQuota, !agy.models.isEmpty { + clients.append(.antigravity) + } + return clients + } + + var clampedUsageClientIndex: Int { + let count = availableUsageClients.count + guard count > 0 else { return 0 } + return max(0, min(usageClientIndex, count - 1)) + } + + func selectNextUsageClient() { + let count = availableUsageClients.count + guard count > 1 else { return } + usageClientIndex = min(clampedUsageClientIndex + 1, count - 1) + } + + func selectPrevUsageClient() { + guard availableUsageClients.count > 1 else { return } + usageClientIndex = max(clampedUsageClientIndex - 1, 0) + } // Transient feedback for the "Check for updates…" action row. // Set by PanelController around UpdateChecker.check(); cleared // back to .idle a few seconds after a terminal result so the diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index f5785c7..8fd6e36 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -23,6 +23,25 @@ struct QuotaSnapshot: Equatable { let sevenDay: QuotaTier? let sevenDayOpus: QuotaTier? let sevenDaySonnet: QuotaTier? + // Subscription tier from the claudeAiOauth blob (e.g. "max", "pro"); nil + // when the field is absent. Shown next to the agent name in the Usage tab. + let planType: String? +} + +// A connected client shown in the Usage tab's left-hand list. Each renders its +// own quota tiers; ↑/↓ switches between the ones that currently have data. +enum UsageClient: String, CaseIterable, Hashable { + case claude + case codex + case antigravity + + var displayName: String { + switch self { + case .claude: return "Claude" + case .codex: return "Codex" + case .antigravity: return "Antigravity" + } + } } // Reads the Claude Code OAuth token and calls the (unofficial) @@ -54,6 +73,11 @@ final class QuotaProbe { // that didn't invalidate the token we already have. private var cachedToken: String? + // Subscription tier read from the same claudeAiOauth blob as the token + // (e.g. "max", "pro"). Set whenever we read the blob; persists while the + // token is cached. nil when the field isn't present. + private var lastSubscriptionType: String? + init() { let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = 10 @@ -100,7 +124,7 @@ final class QuotaProbe { } guard let data, code == 200, - let snapshot = Self.parse(data) else { + let snapshot = Self.parse(data, planType: self?.lastSubscriptionType ?? nil) else { if let code, code != 200 { FileHandle.standardError.write(Data( "stack-nudge: /api/oauth/usage returned \(code)\n".utf8)) @@ -129,11 +153,11 @@ final class QuotaProbe { guard FileManager.default.fileExists(atPath: path), let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let blob = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let oauth = blob["claudeAiOauth"] as? [String: Any], - let token = oauth["accessToken"] as? String, - !token.isEmpty else { + let oauth = blob["claudeAiOauth"] as? [String: Any] else { return nil } + lastSubscriptionType = oauth["subscriptionType"] as? String + guard let token = oauth["accessToken"] as? String, !token.isEmpty else { return nil } return token } @@ -154,11 +178,11 @@ final class QuotaProbe { return nil } guard let blob = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let oauth = blob["claudeAiOauth"] as? [String: Any], - let token = oauth["accessToken"] as? String, - !token.isEmpty else { + let oauth = blob["claudeAiOauth"] as? [String: Any] else { return nil } + lastSubscriptionType = oauth["subscriptionType"] as? String + guard let token = oauth["accessToken"] as? String, !token.isEmpty else { return nil } return token } @@ -175,7 +199,7 @@ final class QuotaProbe { return f }() - private static func parse(_ data: Data) -> QuotaSnapshot? { + private static func parse(_ data: Data, planType: String?) -> QuotaSnapshot? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } @@ -183,7 +207,8 @@ final class QuotaProbe { fiveHour: tier(json["five_hour"]), sevenDay: tier(json["seven_day"]), sevenDayOpus: tier(json["seven_day_opus"]), - sevenDaySonnet: tier(json["seven_day_sonnet"]) + sevenDaySonnet: tier(json["seven_day_sonnet"]), + planType: planType ) } @@ -213,39 +238,25 @@ struct UsageView: View { VStack(alignment: .leading, spacing: 0) { if !nav.quotaTrackingEnabled { trackingDisabledState - } else if let snapshot = nav.quota, !isAllNil(snapshot) { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - if let tier = snapshot.fiveHour { - section("Current session") { tierRow(tier) } - } - if let tier = snapshot.sevenDay { - section("Current week (all models)") { tierRow(tier) } - } - if let tier = snapshot.sevenDayOpus { - section("Current week (Opus only)") { tierRow(tier) } - } - if let tier = snapshot.sevenDaySonnet { - section("Current week (Sonnet only)") { tierRow(tier) } - } - } - .padding(.horizontal, 14) - .padding(.vertical, 14) - .background(ThinScrollers()) - } - // Explicit max-height claim so the ScrollView reliably - // bounds itself to the panel's available area instead of - // expanding to fit its content (which clipped behind the - // PageFooter without a visible scrollbar hint). Force the - // indicator visible so users can tell there's more to see. - .frame(maxHeight: .infinity) - .scrollIndicators(.visible) + } else if !nav.availableUsageClients.isEmpty { + clientSplit } else { emptyState } PageFooter { FooterHint(label: footerStatusLabel, keys: []) + if nav.usageDetailFocused { + FooterHint(label: "Scroll", keys: ["↑↓"]) + FooterHint(label: "Back", keys: ["←"]) + } else { + if nav.availableUsageClients.count > 1 { + FooterHint(label: "Switch", keys: ["↑↓"]) + } + if !nav.availableUsageClients.isEmpty { + FooterHint(label: "Enter", keys: ["→"]) + } + } if nav.quotaTrackingEnabled { FooterHint(label: "Sync now", keys: ["R"]) } @@ -255,6 +266,149 @@ struct UsageView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + // Always enter the tab in client-list focus, never stuck inside the pane. + .onAppear { nav.usageDetailFocused = false } + } + + // Left: one row per connected client (↑/↓ or click to select). Right: the + // selected client's quota tiers, scrollable in case a client has several. + private var clientSplit: some View { + let clients = nav.availableUsageClients + let selected = clients[min(nav.clampedUsageClientIndex, max(0, clients.count - 1))] + return HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + ForEach(clients, id: \.self) { client in + clientRow(client, isSelected: client == selected) + } + Spacer(minLength: 0) + } + .frame(width: 104) + .padding(.vertical, 10) + .padding(.leading, 6) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + tiers(for: selected) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ThinScrollers()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scrollIndicators(.visible) + // Focus ring when the user has stepped into the pane (↑/↓ scroll). + .overlay(alignment: .top) { + if nav.usageDetailFocused { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 2) + .padding(2) + .allowsHitTesting(false) + } + } + } + .frame(maxHeight: .infinity) + } + + private func clientRow(_ client: UsageClient, isSelected: Bool) -> some View { + // Selection is shown brightly only while the client list holds focus; + // once focus steps into the detail pane it's de-emphasised so it's clear + // ↑/↓ now scroll rather than switch. + let activeSelection = isSelected && !nav.usageDetailFocused + return VStack(alignment: .leading, spacing: 1) { + Text(client.displayName) + .font(.callout.weight(isSelected ? .semibold : .regular)) + .foregroundStyle(activeSelection ? Color.accentColor + : (isSelected ? .primary : .secondary)) + if let plan = planLabel(for: client) { + Text(plan) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isSelected + ? Color.accentColor.opacity(activeSelection ? 0.12 : 0.05) + : Color.clear) + ) + .contentShape(Rectangle()) + .onTapGesture { + if let idx = nav.availableUsageClients.firstIndex(of: client) { + nav.usageClientIndex = idx + nav.usageDetailFocused = false + } + } + } + + @ViewBuilder private func tiers(for client: UsageClient) -> some View { + switch client { + case .claude: + if let snapshot = nav.quota { + if let tier = snapshot.fiveHour { + section("Current session") { tierRow(tier) } + } + if let tier = snapshot.sevenDay { + section("Current week (all models)") { tierRow(tier) } + } + if let tier = snapshot.sevenDayOpus { + section("Current week (Opus only)") { tierRow(tier) } + } + if let tier = snapshot.sevenDaySonnet { + section("Current week (Sonnet only)") { tierRow(tier) } + } + } + case .codex: + if let codex = nav.codexQuota { + if let tier = codex.primary { + section("Current session (5h)") { tierRow(tier) } + } + if let tier = codex.secondary { + section("Current week") { tierRow(tier) } + } + } + case .antigravity: + if let agy = nav.antigravityQuota { + // One bar per model — agy reports a separate quota window per + // model, each with its own reset time. + ForEach(agy.models, id: \.label) { model in + section(model.label) { tierRow(model.tier) } + } + if agy.promptCredits != nil || agy.flowCredits != nil { + section("Credits") { creditsRow(agy) } + } + } + } + } + + private func creditsRow(_ agy: AntigravityQuotaSnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + if let prompt = agy.promptCredits { + Text("Prompt: \(prompt.available) / \(prompt.monthly)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + if let flow = agy.flowCredits { + Text("Flow: \(flow.available) / \(flow.monthly)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 6) + } + + // Subscription tier shown under the client name (e.g. "Max", "Plus"). + private func planLabel(for client: UsageClient) -> String? { + switch client { + case .claude: return nav.quota?.planType?.capitalized + case .codex: return nav.codexQuota?.planType?.capitalized + case .antigravity: return nav.antigravityQuota?.planType?.capitalized + } } private func section(_ title: String, @ViewBuilder content: () -> Content) -> some View { @@ -293,22 +447,15 @@ struct UsageView: View { .padding(.horizontal, 6) } - // Treat a snapshot with all tiers nil the same as no snapshot at all — - // user sees the empty state rather than a blank page. Shouldn't happen - // for a real subscription, but covers anomalous endpoint responses. - private func isAllNil(_ s: QuotaSnapshot) -> Bool { - s.fiveHour == nil && s.sevenDay == nil - && s.sevenDayOpus == nil && s.sevenDaySonnet == nil - } private var emptyState: some View { VStack(spacing: 10) { ProgressView() .controlSize(.small) - Text("Loading quota…") + Text("Loading usage…") .font(.subheadline) .foregroundStyle(.secondary) - Text("Requires Claude Code login. First read may prompt the system keychain.") + Text("Requires a signed-in Claude Code session, or a Codex session on a ChatGPT plan. The first Claude read may prompt the system keychain.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center)