diff --git a/Tests/StackNudgePanelCoreTests/AntigravityUsageTests.swift b/Tests/StackNudgePanelCoreTests/AntigravityUsageTests.swift new file mode 100644 index 0000000..3552e7c --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/AntigravityUsageTests.swift @@ -0,0 +1,47 @@ +import XCTest + +@testable import StackNudgePanelCore + +// classify is the not-in-use / broken / ok split the Usage tab keys its error +// state on: agy not running stays silent, but agy answering with a body we can't +// read surfaces an error, the same way Claude tells a missing CLI apart from a +// failed one. +final class AntigravityUsageTests: XCTestCase { + + private func data(_ json: String) -> Data { Data(json.utf8) } + + // A minimal GetUserStatus body with one model quota — enough for parse to + // build a snapshot. + private let validBody = """ + {"userStatus":{"cascadeModelConfigData":{"clientModelConfigs":[ + {"label":"Claude Opus 4.6","quotaInfo":{"remainingFraction":0.6}} + ]}}} + """ + + func test_nilData_isUnreachable() { + XCTAssertEqual(AntigravityUsageProbe.classify(nil), .unreachable) + } + + // agy answered, but the shape isn't what we expect — a real break, not + // "not in use". + func test_respondedButUnparseable_isUnparseable() { + XCTAssertEqual(AntigravityUsageProbe.classify(data("{\"unexpected\":true}")), .unparseable) + XCTAssertEqual(AntigravityUsageProbe.classify(data("not json")), .unparseable) + } + + // A body with the envelope but no models parses to nothing, which is still + // "responded but nothing usable" — unparseable, not unreachable. + func test_respondedWithNoModels_isUnparseable() { + let empty = "{\"userStatus\":{\"cascadeModelConfigData\":{\"clientModelConfigs\":[]}}}" + XCTAssertEqual(AntigravityUsageProbe.classify(data(empty)), .unparseable) + } + + func test_validBody_isOk() { + guard case .ok(let snapshot) = AntigravityUsageProbe.classify(data(validBody)) else { + return XCTFail("expected .ok") + } + XCTAssertEqual(snapshot.models.count, 1) + XCTAssertEqual(snapshot.models.first?.label, "Claude Opus 4.6") + XCTAssertEqual(snapshot.models.first?.tier.utilization, 40) // (1 - 0.6) * 100 + } +} diff --git a/Tests/StackNudgePanelCoreTests/QuotaResetTests.swift b/Tests/StackNudgePanelCoreTests/QuotaResetTests.swift index 5e49b0f..d571322 100644 --- a/Tests/StackNudgePanelCoreTests/QuotaResetTests.swift +++ b/Tests/StackNudgePanelCoreTests/QuotaResetTests.swift @@ -49,6 +49,73 @@ final class QuotaResetTests: XCTestCase { XCTAssertNil(QuotaReset.relativeLabel(until: ahead(-2 * 3600), now: now)) } + // MARK: - absoluteLabel + + // Pinned to a fixed instant and an explicit timezone: the whole point of + // this label is that a Codex unix timestamp and an Antigravity ISO 8601 + // string come out in the same shape Claude's CLI prints, so the exact + // characters are the contract. + private let london = TimeZone(identifier: "Europe/London")! + private let noon = Date(timeIntervalSince1970: 1_719_748_800) // 30 Jun 2024, 13:00 BST + + func testAbsoluteLabelWithMinutes() { + let resets = Date(timeIntervalSince1970: 1_719_769_800) + XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon, timeZone: london), + "Jun 30 at 6:50pm") + } + + // Claude drops ":00" on the hour; so do we. + func testAbsoluteLabelOnTheHour() { + let resets = Date(timeIntervalSince1970: 1_720_058_400) + XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon, timeZone: london), + "Jul 4 at 3am") + } + + func testAbsoluteLabelAtMidnight() { + let resets = Date(timeIntervalSince1970: 1_722_466_800) + XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon, timeZone: london), + "Aug 1 at 12am") + } + + // Same instant, two timezones. Claude's CLI reports the reset in the + // timezone on the account; we render every client's in the machine's. + func testAbsoluteLabelRendersInTheGivenTimezone() { + let resets = Date(timeIntervalSince1970: 1_719_769_800) + XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon, + timeZone: TimeZone(identifier: "UTC")!), + "Jun 30 at 5:50pm") + } + + func testAbsoluteLabelIsNilOnceElapsed() { + XCTAssertNil(QuotaReset.absoluteLabel(until: now, now: now, timeZone: london)) + XCTAssertNil(QuotaReset.absoluteLabel(until: ahead(-60), now: now, timeZone: london)) + } + + // What we render, ClaudeCliQuotaProbe reads back. Rendered in the host's + // timezone because that's what parseResetsAt assumes when the line carries + // no "(Europe/London)" suffix of its own. + func testAbsoluteLabelRoundTripsThroughTheClaudeParser() { + let resets = Date(timeIntervalSince1970: 1_719_769_800) + let label = QuotaReset.absoluteLabel(until: resets, now: noon) + XCTAssertEqual(ClaudeCliQuotaProbe.parseResetsAt(label ?? "", now: noon), resets) + } + + // MARK: - fullLabel + + func testFullLabelPairsCountdownWithClockTime() { + let resets = Date(timeIntervalSince1970: 1_719_769_800) + let label = QuotaReset.fullLabel(until: resets, now: noon, timeZone: london) + // The countdown half is RelativeDateTimeFormatter's, so its wording is + // the host's locale; only the pairing and the clock half are ours. + XCTAssertEqual(label?.hasSuffix(" \u{00B7} Jun 30 at 6:50pm"), true) + let countdown = QuotaReset.relativeLabel(until: resets, now: noon) + XCTAssertEqual(label?.hasPrefix(countdown ?? "#"), true) + } + + func testFullLabelIsNilOnceElapsed() { + XCTAssertNil(QuotaReset.fullLabel(until: ahead(-60), now: now, timeZone: london)) + } + // MARK: - remaining func testRemainingIsNilAtAndAfterTheDeadline() { diff --git a/Tests/StackNudgePanelCoreTests/QuotaSnapshotTests.swift b/Tests/StackNudgePanelCoreTests/QuotaSnapshotTests.swift new file mode 100644 index 0000000..e7c57e0 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/QuotaSnapshotTests.swift @@ -0,0 +1,61 @@ +import XCTest + +@testable import StackNudgePanelCore + +// hasTier is the one question two callers have to answer identically: which +// clients get a row in the Usage tab, and whether an error replaces a client's +// bars or sits above them. They used to disagree — the view asked "is the +// snapshot non-nil", which is a weaker question than "did anything parse into a +// tier" — and a snapshot that landed with no recognised tier drew a stale +// warning above an empty pane, swallowing the message saying what had failed. +final class QuotaSnapshotTests: XCTestCase { + + private func tier(_ used: Double) -> QuotaTier { + QuotaTier(utilization: used, resetsAt: nil) + } + + // MARK: - Claude + + private func claude(five: QuotaTier? = nil, week: QuotaTier? = nil, + opus: QuotaTier? = nil, sonnet: QuotaTier? = nil) -> QuotaSnapshot { + QuotaSnapshot(fiveHour: five, sevenDay: week, + sevenDayOpus: opus, sevenDaySonnet: sonnet, planType: "max") + } + + func test_claude_anySingleTierCounts() { + XCTAssertTrue(claude(five: tier(2)).hasTier) + XCTAssertTrue(claude(week: tier(23)).hasTier) + XCTAssertTrue(claude(opus: tier(12)).hasTier) + XCTAssertTrue(claude(sonnet: tier(0)).hasTier) + } + + // The shape parseResultText produces from a plan whose only bucket line is + // one we don't map yet ("Current month (experimental)"): .ok, a plan type, + // and not a single tier behind it. + func test_claude_snapshotWithNoRecognisedTier() { + XCTAssertFalse(claude().hasTier) + } + + // MARK: - Codex + + func test_codex_eitherWindowCounts() { + XCTAssertTrue(CodexQuotaSnapshot(primary: tier(20), secondary: nil, planType: "plus").hasTier) + XCTAssertTrue(CodexQuotaSnapshot(primary: nil, secondary: tier(4), planType: "plus").hasTier) + } + + // A rollout older than its own rate-limit window: both windows are dropped + // as rolled-over, and what's left parses to a snapshot with nothing in it. + func test_codex_expiredWindowsLeaveNothing() { + XCTAssertFalse(CodexQuotaSnapshot(primary: nil, secondary: nil, planType: "plus").hasTier) + } + + // MARK: - Antigravity + + func test_antigravity_countsItsModels() { + let model = AntigravityQuotaSnapshot.ModelQuota(label: "Claude Opus 4.6", tier: tier(40)) + XCTAssertTrue(AntigravityQuotaSnapshot(planType: "pro", models: [model], + promptCredits: nil, flowCredits: nil).hasTier) + XCTAssertFalse(AntigravityQuotaSnapshot(planType: "pro", models: [], + promptCredits: nil, flowCredits: nil).hasTier) + } +} diff --git a/Tests/StackNudgePanelCoreTests/UsageAvailabilityTests.swift b/Tests/StackNudgePanelCoreTests/UsageAvailabilityTests.swift new file mode 100644 index 0000000..6c09c33 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/UsageAvailabilityTests.swift @@ -0,0 +1,97 @@ +import XCTest + +@testable import StackNudgePanelCore + +// availableUsageClients decides which clients get a row in the Usage tab. The +// rule used to be "has a non-empty snapshot"; a client that failed simply +// vanished, and the only error the tab could show was Claude's global one. Now a +// client with an error but no snapshot lists itself so the error renders on its +// own detail pane — these pin that, and pin that a Claude error never drops +// Claude just because another client happens to have data. +@MainActor +final class UsageAvailabilityTests: XCTestCase { + + private func claudeSnapshot(five: Double? = 10) -> QuotaSnapshot { + QuotaSnapshot(fiveHour: five.map { QuotaTier(utilization: $0, resetsAt: nil) }, + sevenDay: nil, sevenDayOpus: nil, sevenDaySonnet: nil, planType: nil) + } + + private func codexSnapshot() -> CodexQuotaSnapshot { + CodexQuotaSnapshot(primary: QuotaTier(utilization: 20, resetsAt: nil), + secondary: nil, planType: nil) + } + + func test_claudeWithTiers_isListed() { + let nav = PanelNav() + nav.quota = claudeSnapshot() + XCTAssertEqual(nav.availableUsageClients, [.claude]) + } + + // The new behaviour: an error with no snapshot to fall back on still lists + // the client, so the note has a pane to render on instead of falling through + // to the tab's cold-load empty state. + func test_claudeWithErrorButNoSnapshot_isListed() { + let nav = PanelNav() + nav.quotaErrors[.claude] = "Rate-limited — retrying shortly." + XCTAssertEqual(nav.availableUsageClients, [.claude]) + } + + func test_claudeWithNeitherSnapshotNorError_isNotListed() { + let nav = PanelNav() + XCTAssertTrue(nav.availableUsageClients.isEmpty) + } + + // An all-nil snapshot isn't a usable client, and without an error it stays + // out of the list — matching the pre-existing "empty snapshot isn't a + // client" rule. + func test_emptyClaudeSnapshotWithoutError_isNotListed() { + let nav = PanelNav() + nav.quota = claudeSnapshot(five: nil) + XCTAssertTrue(nav.availableUsageClients.isEmpty) + } + + // Regression guard: a Claude error must not drop Claude out of the list just + // because Codex has data. Both are listed; the failing client keeps its row. + func test_claudeErrorWithCodexPresent_listsBoth() { + let nav = PanelNav() + nav.quotaErrors[.claude] = "Couldn't refresh — run `claude /usage` to check your session." + nav.codexQuota = codexSnapshot() + XCTAssertEqual(nav.availableUsageClients, [.claude, .codex]) + } + + // A held-stale Claude snapshot (error present, but the last-good bars are + // still there) keeps its row too — the error marks it stale rather than + // removing it. + func test_claudeHeldStaleSnapshot_staysListed() { + let nav = PanelNav() + nav.quota = claudeSnapshot() + nav.quotaErrors[.claude] = "Couldn't refresh — run `claude /usage` to check your session." + XCTAssertEqual(nav.availableUsageClients, [.claude]) + } + + // The predicate is uniform: an error lists its client whichever client it + // is, not only Claude. Antigravity is the one that actually populates an + // error today (agy running but unparseable); Codex is checked the same way + // so a future Codex error could display without another wiring change. + func test_antigravityErrorButNoSnapshot_isListed() { + let nav = PanelNav() + nav.quotaErrors[.antigravity] = "Couldn't read Antigravity usage." + XCTAssertEqual(nav.availableUsageClients, [.antigravity]) + } + + func test_codexErrorButNoSnapshot_isListed() { + let nav = PanelNav() + nav.quotaErrors[.codex] = "something failed" + XCTAssertEqual(nav.availableUsageClients, [.codex]) + } + + // Listing order follows UsageClient.allCases (claude, codex, antigravity), + // so a mix of data and errors stays in a stable order. + func test_ordering_followsDeclarationOrder() { + let nav = PanelNav() + nav.quotaErrors[.antigravity] = "err" + nav.codexQuota = codexSnapshot() + nav.quota = claudeSnapshot() + XCTAssertEqual(nav.availableUsageClients, [.claude, .codex, .antigravity]) + } +} diff --git a/panel/AntigravityUsage.swift b/panel/AntigravityUsage.swift index 78fe9c6..cb520c0 100644 --- a/panel/AntigravityUsage.swift +++ b/panel/AntigravityUsage.swift @@ -20,18 +20,42 @@ struct AntigravityQuotaSnapshot: Equatable { let available: Int let monthly: Int } + + // See QuotaSnapshot.hasTier. `parse` already refuses to build a snapshot with + // no models, so this holds by construction; stated anyway so all three + // clients answer the question the same way. + var hasTier: Bool { !models.isEmpty } } final class AntigravityUsageProbe { + // Outcome of one probe. `unreachable` and `unparseable` are deliberately + // distinct: agy not running (loopback refused) is "not in use" and stays + // silent, whereas agy answering with a body we can't read is a genuine break + // worth surfacing in the Usage tab. Mirrors ClaudeCliQuotaProbe's + // cliMissing-vs-hardFail split. + enum FetchResult: Equatable { + case ok(AntigravityQuotaSnapshot) + case unreachable + case unparseable + } + // Calls completion on the main queue. The loopback request runs off-main. - func fetch(completion: @escaping (AntigravityQuotaSnapshot?) -> Void) { + func fetch(completion: @escaping (FetchResult) -> Void) { DispatchQueue.global(qos: .utility).async { - let result = AntigravityLocalServer.call("GetUserStatus").flatMap(Self.parse) + let result = Self.classify(AntigravityLocalServer.call("GetUserStatus")) DispatchQueue.main.async { completion(result) } } } + // Split out so the not-in-use / broken / ok mapping is testable without a + // live agy on the loopback port. nil data = the call never connected. + static func classify(_ data: Data?) -> FetchResult { + guard let data else { return .unreachable } + guard let snapshot = parse(data) else { return .unparseable } + return .ok(snapshot) + } + private static let iso: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] diff --git a/panel/ClaudeCliQuotaProbe.swift b/panel/ClaudeCliQuotaProbe.swift index 5a04d55..70f41a9 100644 --- a/panel/ClaudeCliQuotaProbe.swift +++ b/panel/ClaudeCliQuotaProbe.swift @@ -19,6 +19,11 @@ final class ClaudeCliQuotaProbe { // Main-queue only (mirrors QuotaProbe's threading model). private(set) var lastProbeFailed = false + // True when the last fetch found no `claude` on PATH, as distinct from the + // CLI running but failing to parse. Lets the caller stay silent for a + // non-Claude user instead of surfacing a "usage unavailable" error, and hold + // a prior snapshot on a genuine hard-fail rather than dropping it. + private(set) var cliMissing = false private var retryAfterUntil: Date? private var lastSubscriptionType: String? private var subscriptionFetched = false @@ -32,12 +37,16 @@ final class ClaudeCliQuotaProbe { private let probeQueue = DispatchQueue(label: "stack-nudge.claude-cli-quota") func fetch(completion: @escaping (QuotaSnapshot?) -> Void) { + // Reset before the early-return gates so a rate-limited tick reports the + // flag from its own run, not a stale value from an earlier missing-CLI one. + cliMissing = false if isRateLimited { completion(nil) return } guard let path = ProcessOutput.claude() else { lastProbeFailed = true + cliMissing = true completion(nil) return } diff --git a/panel/CodexUsage.swift b/panel/CodexUsage.swift index 6f6d896..adcdd36 100644 --- a/panel/CodexUsage.swift +++ b/panel/CodexUsage.swift @@ -8,6 +8,11 @@ struct CodexQuotaSnapshot: Equatable { let primary: QuotaTier? let secondary: QuotaTier? let planType: String? + + // See QuotaSnapshot.hasTier. Reachable here too: `tier` drops a window whose + // reset has already passed, so a rollout left over from last week parses into + // a snapshot with both windows nil. + var hasTier: Bool { primary != nil || secondary != nil } } // Reads Codex's account-level rate limits from the newest rollout JSONL under diff --git a/panel/Formatting.swift b/panel/Formatting.swift index d2995b9..b2f6840 100644 --- a/panel/Formatting.swift +++ b/panel/Formatting.swift @@ -50,11 +50,66 @@ enum QuotaReset { return "\(max(1, seconds / 60))m" // sub-minute is genuinely about to reset } - // Usage tab and banners: "in 2 hours". + // Countdown half of the reset line: "in 2 hours". static func relativeLabel(until date: Date, now: Date = Date()) -> String? { guard remaining(until: date, now: now) != nil else { return nil } return RelativeTime.string(date, style: .full, relativeTo: now) } + + // Clock half, in the shape Claude Code's own /usage prints: "Jun 30 at + // 6:50pm", or "Jul 4 at 3am" when the reset lands on the hour. Codex + // reports its reset as a unix timestamp and Antigravity as ISO 8601, so + // normalising here is what makes one client's reset read like another's. + // + // The locale and the two formats are parseResetsAt's, run in reverse: what + // this renders, that parser reads back. + // + // Rendered in `timeZone`, the machine's by default. Claude's CLI prints the + // timezone held on the account instead, so the two disagree while + // travelling; local is the timezone the countdown is measured against. + static func absoluteLabel(until date: Date, + now: Date = Date(), + timeZone: TimeZone = .current) -> String? { + guard remaining(until: date, now: now) != nil else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + // Built fresh per call rather than mutating a shared cached formatter: + // DateFormatter mutation isn't thread-safe, and stamping timeZone into a + // static on the way through would hand the next (possibly off-main) + // caller a quietly wrong string. Two calls per render is cheap. + let formatter = calendar.component(.minute, from: date) == 0 + ? absoluteFormatter("MMM d 'at' ha") + : absoluteFormatter("MMM d 'at' h:mma") + formatter.timeZone = timeZone + return formatter.string(from: date) + } + + // Both halves: "in 2 hours · Jun 30 at 6:50pm". The countdown says how long + // you're blocked, the clock time says when to come back; the Usage tab and + // the quota banner both want the pair. + static func fullLabel(until date: Date, + now: Date = Date(), + timeZone: TimeZone = .current) -> String? { + guard let relative = relativeLabel(until: date, now: now), + let absolute = absoluteLabel(until: date, now: now, timeZone: timeZone) + else { return nil } + return "\(relative) · \(absolute)" + } + + // Claude prints "3am" on the hour and "6:50pm" otherwise, so matching it + // takes two formats. absoluteLabel picks the format and builds one per call + // so nothing shared is mutated; the caller stamps the timezone in. + private static func absoluteFormatter(_ format: String) -> DateFormatter { + let formatter = DateFormatter() + // Fixed English, like the CLI line this mirrors and like the parser + // that reads it back. The symbols have to be set after the locale, + // which stamps its own over them. + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = format + formatter.amSymbol = "am" + formatter.pmSymbol = "pm" + return formatter + } } // Shared relative-time strings ("5m ago", "in 3 days") with per-style cached diff --git a/panel/Panel.swift b/panel/Panel.swift index 38f719e..fe29e1e 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -1742,42 +1742,75 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, self.nav.quotaSyncing = false if let snapshot { self.nav.quota = snapshot - self.nav.quotaError = nil + self.nav.quotaErrors[.claude] = nil self.nav.quotaLastUpdated = Date() self.nav.quotaClaudeLastUpdated = Date() self.evaluateQuotaThresholds(snapshot) + } else if self.claudeCliQuotaProbe.cliMissing { + // `claude` didn't resolve on PATH. With no prior snapshot, treat + // that as "not a Claude user" and stay silent — a Codex- or + // Antigravity-only user never sees a phantom Claude row. But one + // failed resolution isn't proof of that: PATH under a launched + // .app differs from a shell's, so a snapshot we already have is + // better evidence than a single miss. Hold it and mark it stale + // like every other failure here rather than discarding good data. + if self.nav.quota == nil { + self.nav.quotaClaudeLastUpdated = nil + self.nav.quotaErrors[.claude] = nil + } else { + self.nav.quotaErrors[.claude] = "Couldn't refresh — run `claude /usage` to check your session." + } } else if self.claudeCliQuotaProbe.isRateLimited { // Soft-fail: hold any prior snapshot. On a cold first probe // (no snapshot yet) surface a rate-limit note so the tab shows // that instead of sitting on a bare "Loading…" spinner until // the backoff clears. if self.nav.quota == nil { - self.nav.quotaError = "Claude usage rate-limited — retrying shortly." + self.nav.quotaErrors[.claude] = "Rate-limited — retrying shortly." } } else { - // Hard-fail: the CLI couldn't run or its output didn't parse. - // Drop any stale snapshot so the Usage tab surfaces the error - // state instead of rendering old bars as if they were current. - self.nav.quota = nil - self.nav.quotaLastUpdated = nil - self.nav.quotaClaudeLastUpdated = nil - self.nav.quotaError = "Claude usage unavailable — run `claude /usage` to check your session." + // Hard-fail: the CLI ran but timed out or its output didn't + // parse. Hold the last-good snapshot — the error marks it stale + // in the pane — rather than nulling it, so a single bad tick + // doesn't drop Claude out of the client list and flicker it back + // on the next success. quotaClaudeLastUpdated is left untouched so + // the pane can still say how old the held data is. + self.nav.quotaErrors[.claude] = "Couldn't refresh — run `claude /usage` to check your session." } } // 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. + // failing/absent doesn't suppress the other. No error branch: a nil here + // means no rollout, or API-key auth (Codex emits no rate_limits) — both + // "nothing to show", not a failure. There's no signal that distinguishes + // a genuine Codex break from simply not using it, so surfacing an error + // would only invent phantom rows for non-Codex users. 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() + // (localhost only, no auth). Independent of the probes above. Unlike + // Codex, agy has a real error to tell apart: not running (unreachable) + // is silent, but answering with a body we can't parse is a break worth + // surfacing. + antigravityUsageProbe.fetch { [weak self] result in + guard let self else { return } + switch result { + case .ok(let snapshot): + self.nav.antigravityQuota = snapshot + self.nav.quotaErrors[.antigravity] = nil + self.nav.quotaLastUpdated = Date() + case .unparseable: + self.nav.quotaErrors[.antigravity] = + "Couldn't read Antigravity usage — its local endpoint returned something unexpected." + case .unreachable: + // agy isn't running. Not in use, not an error — clear any prior + // note and hold the last snapshot, matching the other probes' + // "a dropped tick shouldn't flip the UI". + self.nav.quotaErrors[.antigravity] = nil + } } } @@ -1922,7 +1955,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private func postQuotaBanner(label: String, percent: Int, resetsAt: Date?) { let body: String - if let resetsAt, let resetLabel = QuotaReset.relativeLabel(until: resetsAt) { + if let resetsAt, let resetLabel = QuotaReset.fullLabel(until: resetsAt) { body = "\(percent)% used. Resets \(resetLabel)." } else { body = "\(percent)% used." diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index fdc18a8..0fd8d67 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -300,11 +300,17 @@ final class PanelNav: ObservableObject { // True while a probe is in-flight. Set by PanelController around the // fetch call so the UI can swap the footer status to "Syncing…". @Published var quotaSyncing: Bool = false - // Set when a probe had a token but the request/parse failed (vs. simply - // having no Claude Code session). Drives the Usage tab's "quota unavailable" - // state so a silently-changed endpoint isn't read as "still loading". - // Cleared on the next successful probe. - @Published var quotaError: String? + // Per-client probe failure, keyed by the client that produced it. Set when a + // client's probe ran but couldn't return a usable snapshot; cleared on that + // client's next success. Rendered inline on that client's own Usage detail + // pane, so the message names whichever client is actually failing rather than + // always implicating Claude. A client with an error but no snapshot to fall + // back on still lists itself (see availableUsageClients) so the note has + // somewhere to render instead of falling through to the tab's empty state. + // Invalidates widgetQuotaCache like the snapshots do: it feeds + // availableUsageClients, so a change here can move the selected client out + // from under a cached pill even when no snapshot changed. + @Published var quotaErrors: [UsageClient: String] = [:] { didSet { widgetQuotaCache = nil } } // Set when the event socket failed to bind at startup — the panel is then // deaf to every agent notification. Drives the banner at the top of the // Events tab so the failure isn't silent. Cleared when the socket binds. @@ -577,19 +583,22 @@ final class PanelNav: ObservableObject { // 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) + UsageClient.allCases.filter(isUsageClientAvailable) + } + + // A client earns a row when it has a drawable tier, or when it has an error + // to report — a cold rate-limit or a failed refresh with no prior snapshot — + // so the note renders on its own detail pane rather than the tab's global + // empty state. A held-stale snapshot keeps a tier, so it also stays listed. + // Uniform across all three so any client that populates quotaErrors can + // surface it, not just Claude. + private func isUsageClientAvailable(_ client: UsageClient) -> Bool { + if quotaErrors[client] != nil { return true } + switch client { + case .claude: return quota?.hasTier == true + case .codex: return codexQuota?.hasTier == true + case .antigravity: return antigravityQuota?.hasTier == true } - 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 { @@ -628,7 +637,8 @@ final class PanelNav: ObservableObject { // Memoised because CompactView reads it from ~16 places per body pass and // the pill re-renders at 10Hz while an agent is busy — recomputing meant // rebuilding availableUsageClients every time. Invalidated by didSet on each - // of the four inputs below, so the cache can't outlive its sources. + // of the five inputs it reads (the three snapshots, usageClientIndex, and + // quotaErrors), so the cache can't outlive its sources. private var widgetQuotaCache: WidgetQuota? var widgetQuota: WidgetQuota { @@ -1400,7 +1410,7 @@ final class PanelNav: ObservableObject { quota = nil quotaLastUpdated = nil quotaClaudeLastUpdated = nil - quotaError = nil + quotaErrors.removeAll() } } diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 826d1d8..c0e99b5 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -25,6 +25,15 @@ struct QuotaSnapshot: Equatable { // 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? + + // Whether there is anything to draw. A snapshot can exist and still carry no + // tier: parseResultText returns .ok as soon as any "Current …" line parsed, + // including a bucket name we don't map yet, so "non-nil" is not the same + // question. Asked in two places that have to agree (which clients get a row, + // and whether an error replaces the bars or sits above them). + var hasTier: Bool { + fiveHour != nil || sevenDay != nil || sevenDayOpus != nil || sevenDaySonnet != nil + } } // A connected client shown in the Usage tab's left-hand list. Each renders its @@ -220,7 +229,21 @@ struct UsageView: View { private func quotaPane(for client: UsageClient) -> some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - tiers(for: client) + if let message = nav.quotaErrors[client] { + if hasTier(client) { + // Held-stale: mark the bars as not-current, but keep + // showing them so a single bad tick doesn't blank the pane. + staleNote(message) + tiers(for: client) + } else { + // No snapshot to fall back on — this pane stands in for the + // tab's old global error state, named for the client that + // actually failed rather than always saying Claude. + clientErrorState(client, message) + } + } else { + tiers(for: client) + } } .padding(.horizontal, 14) .padding(.vertical, 14) @@ -231,6 +254,58 @@ struct UsageView: View { .scrollIndicators(.visible) } + // Is there anything behind the error to keep showing? Deliberately the same + // predicate availableUsageClients lists on: a snapshot that parsed but holds + // no recognised tier would otherwise draw a stale-warning above zero bars, + // with nothing left to say what actually failed. + private func hasTier(_ client: UsageClient) -> Bool { + switch client { + case .claude: return nav.quota?.hasTier ?? false + case .codex: return nav.codexQuota?.hasTier ?? false + case .antigravity: return nav.antigravityQuota?.hasTier ?? false + } + } + + // Shown above a client's bars when its latest refresh failed but a prior + // snapshot is being held. Keeps the held numbers visible without letting them + // read as current. + private func staleNote(_ message: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + } + + // Full-pane error for a client that has nothing to fall back on. The message + // is client-specific (carried in quotaErrors), and the heading names the + // client, so the copy no longer hardcodes Claude regardless of what failed. + private func clientErrorState(_ client: UsageClient, _ message: String) -> some View { + VStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .font(.title2) + .foregroundStyle(.orange) + Text("\(client.displayName) usage unavailable") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Text(message) + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 24) + } + 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 @@ -479,7 +554,7 @@ struct UsageView: View { ProgressView(value: min(tier.utilization, 100), total: 100) .tint(barColor(tier.utilization)) // Hidden rather than "Resets 11 months ago" on a stale snapshot. - if let resets = tier.resetsAt, let label = QuotaReset.relativeLabel(until: resets) { + if let resets = tier.resetsAt, let label = QuotaReset.fullLabel(until: resets) { Text("Resets \(label)") .font(.caption2) .foregroundStyle(.tertiary) @@ -489,35 +564,23 @@ struct UsageView: View { } + // No client has any data yet. A probe failure isn't shown here any more — + // once a client can report an error it lists itself and renders that error on + // its own detail pane (see clientErrorState), which names the failing client + // rather than always pointing at Claude. So this is purely the cold-load case. private var emptyState: some View { VStack(spacing: 10) { - if let error = nav.quotaError { - Image(systemName: "exclamationmark.triangle") - .font(.title2) - .foregroundStyle(.orange) - Text(error) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Text("StackNudge reads your usage by running `claude /usage`. This clears on its own once the CLI is on PATH, signed in, and its output is parseable again.") - .font(.caption) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.center) - .frame(maxWidth: 280) - .fixedSize(horizontal: false, vertical: true) - } else { - ProgressView() - .controlSize(.small) - Text("Loading usage…") - .font(.subheadline) - .foregroundStyle(.secondary) - Text("Requires the `claude` CLI signed in (Claude reads usage via `claude /usage`), or a Codex session on a ChatGPT plan.") - .font(.caption) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.center) - .frame(maxWidth: 280) - .fixedSize(horizontal: false, vertical: true) - } + ProgressView() + .controlSize(.small) + Text("Loading usage…") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Requires the `claude` CLI signed in (Claude reads usage via `claude /usage`), or a Codex session on a ChatGPT plan.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 24)