mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 00:16:38 +00:00
Merge pull request #1482 from kaitranntt/kai/feat/ccs-bar-subscription-cockpit
feat(bar): subscription-first cockpit — detailed quota card, Codex fix, scrollbar fix
This commit is contained in:
15 files changed
+1437
-172
No files matched your search
@@ -1,14 +1,23 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Usage analytics block: spend rollups, a 30-day cost sparkline, surface
|
||||
/// breakdown, and top models.
|
||||
/// Demoted spend strip + surface/model breakdown.
|
||||
///
|
||||
/// Pivots on `hasRecentData`: when the trailing 30 days carry no spend, the three
|
||||
/// dead Today/7d/30d cells read as "broken", so they collapse into one honest
|
||||
/// "No usage in N days" line with all-time + last-active promoted to the hero.
|
||||
/// Spend is informational pool context, NEVER the headline — so the loud 2×2
|
||||
/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses
|
||||
/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin
|
||||
/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and
|
||||
/// subordinate, below the pool accounts.
|
||||
///
|
||||
/// When the trailing 30 days carry no spend the strip reads the honest idle line
|
||||
/// ("No usage in N days") instead of three dead "$0.00" cells.
|
||||
struct BarAnalyticsView: View {
|
||||
let analytics: BarAnalytics
|
||||
/// Which slot of the dropdown this instance renders. `.spend` is the thin strip
|
||||
/// placed below the subscriptions cockpit; `.breakdown` is the by-surface /
|
||||
/// top-models detail placed below the pool accounts.
|
||||
enum Section { case spend, breakdown }
|
||||
var section: Section = .spend
|
||||
|
||||
private var lastActive: String? {
|
||||
BarFormatting.lastActiveLabel(
|
||||
@@ -16,20 +25,19 @@ struct BarAnalyticsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Usage")
|
||||
|
||||
if analytics.hasRecentData {
|
||||
recentGrid
|
||||
} else {
|
||||
idleHero
|
||||
}
|
||||
|
||||
sparklineBlock
|
||||
switch section {
|
||||
case .spend:
|
||||
spendStrip
|
||||
case .breakdown:
|
||||
breakdown
|
||||
}
|
||||
}
|
||||
|
||||
/// By-surface + top-models detail, tightened and subordinate.
|
||||
@ViewBuilder private var breakdown: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
|
||||
// the backend supplies at least one surface entry. Top 5 keeps the section
|
||||
// compact; the full list is available in the dashboard.
|
||||
// the backend supplies at least one surface entry. Top 5 keeps it compact.
|
||||
if !analytics.bySurface.isEmpty {
|
||||
SectionLabel("By surface")
|
||||
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
|
||||
@@ -50,71 +58,44 @@ struct BarAnalyticsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Live spend grid (only when recent windows actually carry data).
|
||||
private var recentGrid: some View {
|
||||
VStack(spacing: 5) {
|
||||
HStack(spacing: 5) {
|
||||
StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost))
|
||||
StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost))
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost))
|
||||
StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true)
|
||||
}
|
||||
}
|
||||
/// True when there's any surface/model detail worth a divider + section.
|
||||
var hasBreakdown: Bool {
|
||||
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
|
||||
}
|
||||
|
||||
/// Honest idle state: a single "No usage in N days" line, with all-time spend
|
||||
/// promoted to the hero and the last-active caption underneath.
|
||||
private var idleHero: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "moon.zzz")
|
||||
.font(.caption)
|
||||
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
|
||||
/// line, and a thin inline 30-day sparkline when there is real spend.
|
||||
private var spendStrip: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
SectionLabel("Spend")
|
||||
if analytics.hasRecentData {
|
||||
Text(spendCaption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(idleHeadline)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
StatCell(
|
||||
title: "All-time spend",
|
||||
value: BarFormatting.money(analytics.allTime.cost),
|
||||
accent: true)
|
||||
if let lastActive {
|
||||
Text(lastActive)
|
||||
if !sparklineIsEmpty {
|
||||
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 16)
|
||||
}
|
||||
} else {
|
||||
Text(idleCaption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var idleHeadline: String {
|
||||
if let d = analytics.daysSinceLastActivity {
|
||||
return "No usage in \(d) days"
|
||||
}
|
||||
return "No usage in 30 days"
|
||||
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
|
||||
private var spendCaption: String {
|
||||
"today \(BarFormatting.money(analytics.today.cost))"
|
||||
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
|
||||
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
|
||||
}
|
||||
|
||||
/// Sparkline over the 30-day series, with an honest placeholder when every day
|
||||
/// is zero (a flat line with no context reads as broken).
|
||||
private var sparklineBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if sparklineIsEmpty {
|
||||
Text(idleHeadline)
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
if let lastActive {
|
||||
Text(lastActive).font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Text("Last 30 days").font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(BarFormatting.count(analytics.last30d.requests)) req")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 28)
|
||||
}
|
||||
}
|
||||
/// Honest idle caption when there's no recent spend, folding in last-active.
|
||||
private var idleCaption: String {
|
||||
let headline =
|
||||
analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days"
|
||||
if let lastActive { return "\(headline) · \(lastActive.lowercased())" }
|
||||
return headline
|
||||
}
|
||||
|
||||
private var sparklineIsEmpty: Bool {
|
||||
@@ -157,30 +138,6 @@ private struct SurfaceBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small labelled stat tile.
|
||||
private struct StatCell: View {
|
||||
let title: String
|
||||
let value: String
|
||||
var accent: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.system(.callout, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(accent ? BarTheme.accent : .primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 9)
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// One top-model row: name + spend with a proportional accent bar behind.
|
||||
private struct ModelBar: View {
|
||||
let model: BarAnalytics.Model
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CCSBarCore
|
||||
|
||||
/// App-side date phrasing for the subscription card. Kept out of Core so the
|
||||
/// shared formatting contract there stays untouched. Parses ISO-8601 the same
|
||||
/// way Core does (with and without fractional seconds) so timestamp handling
|
||||
/// matches the rest of the bar.
|
||||
enum BarCardFormatting {
|
||||
/// Parse an ISO-8601 timestamp, tolerating an optional fractional-seconds
|
||||
/// component. Mirrors Core's parser, which is module-internal there.
|
||||
private static func isoDate(_ iso: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFraction.date(from: iso) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: iso)
|
||||
}
|
||||
|
||||
/// Compact reset form for the Opus/Sonnet split + secondary lines:
|
||||
/// <24h → "2h 18m"
|
||||
/// <7d → weekday, e.g. "Fri"
|
||||
/// >=7d → "Jun 14"
|
||||
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
|
||||
static func shortReset(iso: String?, now: Date) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
let secs = date.timeIntervalSince(now)
|
||||
if secs <= 0 { return "due" }
|
||||
if secs < 24 * 3600 {
|
||||
let total = Int(secs / 60)
|
||||
let h = total / 60
|
||||
let m = total % 60
|
||||
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
|
||||
}
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
if secs < 7 * 24 * 3600 {
|
||||
fmt.dateFormat = "EEE" // weekday, e.g. "Fri"
|
||||
} else {
|
||||
fmt.dateFormat = "MMM d" // e.g. "Jun 14"
|
||||
}
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
|
||||
/// Local wall-clock "HH:mm" for the Codex stale footnote, e.g. "13:42".
|
||||
/// Returns nil for a missing/unparseable timestamp.
|
||||
static func clockTime(iso: String?) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "HH:mm"
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,18 @@ struct BarMenuView: View {
|
||||
if viewModel.offline {
|
||||
offlineState.padding(14)
|
||||
} else {
|
||||
// Scrollbar is hidden: the panel chrome already constrains height and a
|
||||
// visible scrollbar track adds visual clutter in an always-on-screen widget.
|
||||
// Overflow is still fully scrollable — the indicator is just not shown.
|
||||
// The scroll indicator is suppressed (.never, not just .hidden) AND the
|
||||
// enclosing NSScrollView's scroller is hard-disabled via ScrollerHider:
|
||||
// inside a MenuBarExtra popover the SwiftUI preference alone is sometimes
|
||||
// ignored and a scroller track steals width + misaligns content. With the
|
||||
// reorder + collapsed spend strip the important rows fit without scrolling
|
||||
// for the common 1-4 subscription setup; the scroll only engages for
|
||||
// genuine pool/model overflow.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let analytics = viewModel.analytics {
|
||||
BarAnalyticsView(analytics: analytics)
|
||||
}
|
||||
|
||||
// In-dropdown alert list: surfaces the conditions the engine flagged
|
||||
// this poll so users who deny system notifications still see them.
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// (1) ALERTS first — urgent quota crossings surface above everything.
|
||||
// Spend-cap alerts are opt-in OFF by default, so by default only
|
||||
// quota/reauth/cooldown conditions appear here.
|
||||
if !viewModel.activeAlerts.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionLabel("Alerts")
|
||||
@@ -41,14 +42,35 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// (2) SUBSCRIPTIONS — the dominant section, opens here.
|
||||
accountsSection
|
||||
|
||||
// (3) SPEND — demoted to a thin informational strip below the cockpit.
|
||||
if let analytics = viewModel.analytics {
|
||||
Divider()
|
||||
BarAnalyticsView(analytics: analytics, section: .spend)
|
||||
}
|
||||
|
||||
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
|
||||
poolSection
|
||||
|
||||
// (5) BY-SURFACE / TOP MODELS — tightened detail, below the pool.
|
||||
if let analytics = viewModel.analytics,
|
||||
BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown
|
||||
{
|
||||
BarAnalyticsView(analytics: analytics, section: .breakdown)
|
||||
}
|
||||
|
||||
// Zero-size AppKit bridge that disables the popover's NSScrollView
|
||||
// scroller at runtime (belt-and-suspenders with .scrollIndicators).
|
||||
ScrollerHider().frame(width: 0, height: 0)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
// 580 gives room for the full layout (2×2 grid + sparkline + surface section
|
||||
// + top models + accounts) without wasted whitespace on a typical 1-4 account
|
||||
// setup. The scroll still triggers gracefully when content overflows.
|
||||
.scrollIndicators(.never)
|
||||
// 580 fits the full reordered layout (subscription cards + spend strip +
|
||||
// pool rows + surface/models) without wasted whitespace for a typical
|
||||
// 1-4 account setup. Scroll engages gracefully only on real overflow.
|
||||
.frame(maxHeight: 580)
|
||||
}
|
||||
|
||||
@@ -62,11 +84,12 @@ 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.
|
||||
/// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed
|
||||
/// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first
|
||||
/// (closest to empty on top) so the window the user is about to run out of
|
||||
/// leads. CLIProxy pool accounts keep the compact generic `BarRowView` below,
|
||||
/// subordinate. The two-section split is suppressed when only one kind is
|
||||
/// present, preserving the single "Accounts" header for a CLIProxy-only setup.
|
||||
@ViewBuilder private var accountsSection: some View {
|
||||
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -78,17 +101,29 @@ struct BarMenuView: View {
|
||||
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.
|
||||
} else if parts.subscriptions.isEmpty {
|
||||
// CLIProxy-only setup: keep the single established header + generic rows.
|
||||
SectionLabel("Accounts")
|
||||
ForEach(viewModel.rows) { row in
|
||||
ForEach(parts.pool) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
}
|
||||
} else {
|
||||
SectionLabel("Subscriptions")
|
||||
ForEach(parts.subscriptions) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
subscriptionsHeader(parts.subscriptions)
|
||||
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
|
||||
BarSubscriptionCard(row: row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIProxy pool accounts as compact generic rows — subordinate, rendered below
|
||||
/// the spend strip. Suppressed entirely when there are no pool accounts, or
|
||||
/// when there are no subscriptions (the CLIProxy-only path renders pool rows
|
||||
/// under the single "Accounts" header in `accountsSection` instead).
|
||||
@ViewBuilder private var poolSection: some View {
|
||||
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
||||
if !parts.subscriptions.isEmpty && !parts.pool.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionLabel("Pool accounts")
|
||||
ForEach(parts.pool) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
@@ -97,6 +132,43 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// "SUBSCRIPTIONS" header, with a right-aligned cross-tool headroom hint
|
||||
/// ("most room: <X> NN%") when there are >=2 subscriptions with quota data.
|
||||
/// Falls back to the bare label otherwise.
|
||||
@ViewBuilder private func subscriptionsHeader(_ subs: [BarSummaryRow]) -> some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
SectionLabel("Subscriptions")
|
||||
Spacer()
|
||||
if let leader = BarQuotaGauge.headroomLeader(subs) {
|
||||
Text("most room: \(leader.label) \(Int(leader.remainingPercent.rounded()))%")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Order subscription cards by tightest binding window ascending (closest to
|
||||
/// empty on top). Rows with no binding window (error/reauth) sink to the bottom
|
||||
/// so the actionable quota always leads.
|
||||
private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] {
|
||||
subs.sorted { a, b in
|
||||
let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent
|
||||
let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent
|
||||
switch (ra, rb) {
|
||||
case let (.some(x), .some(y)):
|
||||
if x != y { return x < y }
|
||||
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
|
||||
case (.some, .none):
|
||||
return true // a has quota, b doesn't → a first
|
||||
case (.none, .some):
|
||||
return false
|
||||
case (.none, .none):
|
||||
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(nsImage: MenuBarIcon.headerImage())
|
||||
|
||||
@@ -20,8 +20,17 @@ struct BarPreferences {
|
||||
|
||||
/// Seed the registration domain so missing keys resolve to their real defaults
|
||||
/// rather than the type-zero value. Idempotent — safe to call on every launch.
|
||||
///
|
||||
/// The two pay-per-use spend-cap alerts default to OFF (opt-in): subscriptions
|
||||
/// are flat-rate, so a spend alert on them is meaningless, and pool spend is
|
||||
/// informational context, never a default-on alert. Quota / reauth / cooldown
|
||||
/// stay default-on (the quota-first alert set). The caps themselves keep Core's
|
||||
/// sane values so a user who opts in starts with $500 / $10000.
|
||||
func registerDefaults() {
|
||||
defaults.register(defaults: BarAlertPrefsStore.registrationDefaults)
|
||||
var d = BarAlertPrefsStore.registrationDefaults
|
||||
d[BarAlertPrefsStore.Key.dailyEnabled] = false
|
||||
d[BarAlertPrefsStore.Key.monthEnabled] = false
|
||||
defaults.register(defaults: d)
|
||||
}
|
||||
|
||||
/// Read the current preferences. Pulls each key into a plain dictionary and
|
||||
|
||||
@@ -77,16 +77,19 @@ struct BarPreferencesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pay-per-use spend caps. Opt-in (OFF by default) and labelled for pool
|
||||
/// accounts, NOT subscriptions: flat-rate subscription plans have no spend to
|
||||
/// cap, so these alerts only make sense for metered pool usage.
|
||||
private var spendSection: some View {
|
||||
Section("Spend") {
|
||||
Toggle("Alert on daily spend", isOn: $draft.dailySpendEnabled)
|
||||
Section("Opt-in · pay-per-use spend") {
|
||||
Toggle("Daily spend cap (pool accounts)", isOn: $draft.dailySpendEnabled)
|
||||
.onChange(of: draft.dailySpendEnabled) { _ in writeThrough() }
|
||||
capRow(label: "Daily cap", value: $draft.dailyCapUSD, enabled: draft.dailySpendEnabled)
|
||||
|
||||
Toggle("Alert on monthly spend", isOn: $draft.monthSpendEnabled)
|
||||
Toggle("Monthly spend cap (pool accounts)", isOn: $draft.monthSpendEnabled)
|
||||
.onChange(of: draft.monthSpendEnabled) { _ in writeThrough() }
|
||||
capRow(label: "Month cap", value: $draft.monthCapUSD, enabled: draft.monthSpendEnabled)
|
||||
Text("Monthly cap measures calendar month-to-date, so it resets at the start of each billing month.")
|
||||
Text("Subscriptions are flat-rate and unaffected. These caps only watch metered pay-per-use pool spend, and are off until you enable them.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Dedicated, detailed card for a native first-party subscription (Claude Code /
|
||||
/// Codex). Replaces the cramped generic `BarRowView` for native rows so the
|
||||
/// user's own plan quota reads as the cockpit hero: a single-column vertical
|
||||
/// stack, one fact per line, no 2-column flex wrap.
|
||||
///
|
||||
/// Why this exists separately from `BarRowView`: the generic row double-labelled
|
||||
/// the product, showed a confusing "no data" cost cell, and carried pause/solo +
|
||||
/// tier-lock controls that make no sense for a non-routable flat-rate
|
||||
/// subscription. Stripping those four elements and stacking the windows
|
||||
/// vertically is the de-cramping fix. All binding/burn math is pulled fresh at
|
||||
/// render from `BarQuotaGauge` (pure Core) so the pace clause reflects `now`.
|
||||
struct BarSubscriptionCard: View {
|
||||
let row: BarSummaryRow
|
||||
/// Injected so the pace/countdown math is deterministic; defaults to the live
|
||||
/// clock in production and is pinned in previews.
|
||||
var now: Date = Date()
|
||||
|
||||
/// Windows decoded from the row; empty for error/reauth rows (no hero gauge).
|
||||
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
|
||||
|
||||
/// The window the subscription runs out of first (lowest remaining). Drives the
|
||||
/// hero gauge; nil when there is no quota data (error/reauth row).
|
||||
private var binding: QuotaWindowDetail? {
|
||||
BarQuotaGauge.selectBindingWindow(windows)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
titleRow
|
||||
if let binding {
|
||||
heroBlock(binding)
|
||||
if let secondary = secondaryWindow(excluding: binding) {
|
||||
secondaryLine(secondary)
|
||||
}
|
||||
opusSonnetLines
|
||||
staleFootnote
|
||||
} else {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
BarTheme.subscription.opacity(0.07),
|
||||
in: RoundedRectangle(cornerRadius: 9))
|
||||
}
|
||||
|
||||
// MARK: Title
|
||||
|
||||
/// Product name + tier chip. No provider-chip echo, no "subscription" badge
|
||||
/// (the section header carries that), no pause toggle, no overflow menu —
|
||||
/// those four removals are the de-cramping fix.
|
||||
private var titleRow: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(BarFormatting.providerLabel(row.provider))
|
||||
.font(.system(.body, design: .default).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
if row.needsReauth {
|
||||
Chip("reauth", tint: .red)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let tier = row.tier {
|
||||
Chip(tier, tint: BarTheme.subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Hero (binding window)
|
||||
|
||||
private func heroBlock(_ w: QuotaWindowDetail) -> some View {
|
||||
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
|
||||
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
|
||||
return VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
heroGauge(fill: fill, color: color(for: band))
|
||||
Text("\(Int(w.remainingPercent.rounded()))% left")
|
||||
.font(.system(.callout, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(color(for: band))
|
||||
}
|
||||
Text(heroFacts(w))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-width hero gauge bar, filled by the remaining fraction and tinted by
|
||||
/// the severity band (reuses Core band/fill math).
|
||||
private func heroGauge(fill: Double, color: Color) -> some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.primary.opacity(0.12))
|
||||
Capsule()
|
||||
.fill(color)
|
||||
.frame(width: max(3, geo.size.width * fill))
|
||||
}
|
||||
}
|
||||
.frame(height: 7)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
/// One wrapped facts line: "<label> window · resets <countdown> · <pace>".
|
||||
/// When pace is nil the line ends after the reset countdown; when the limit is
|
||||
/// reached the pace clause replaces the bare countdown.
|
||||
private func heroFacts(_ w: QuotaWindowDetail) -> String {
|
||||
let pace = BarQuotaGauge.paceClause(
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: w.windowMinutes,
|
||||
status: row.quotaStatus,
|
||||
now: now)
|
||||
// The limit-reached pace clause already carries "resets in ...", so it
|
||||
// stands in for the countdown rather than appending to it.
|
||||
if let pace, pace.hasPrefix("limit reached") {
|
||||
return "\(w.label) window · \(pace)"
|
||||
}
|
||||
var parts = ["\(w.label) window"]
|
||||
if let countdown = BarQuotaGauge.resetCountdown(nextReset: w.resetAt, now: now) {
|
||||
parts.append(countdown)
|
||||
}
|
||||
if let pace { parts.append(pace) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
// MARK: Secondary window
|
||||
|
||||
/// The other core window (the non-binding of five_hour / seven_day) shown as a
|
||||
/// thin inline bar plus a compact remaining + reset line. Opus/Sonnet are NOT
|
||||
/// secondary candidates — they have their own split lines.
|
||||
private func secondaryWindow(excluding binding: QuotaWindowDetail) -> QuotaWindowDetail? {
|
||||
let core = windows.filter { $0.key == "five_hour" || $0.key == "seven_day" }
|
||||
return core.first { $0.key != binding.key }
|
||||
}
|
||||
|
||||
private func secondaryLine(_ w: QuotaWindowDetail) -> some View {
|
||||
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
|
||||
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
|
||||
return HStack(spacing: 6) {
|
||||
thinBar(fill: fill, color: color(for: band))
|
||||
Text("\(w.label) \(Int(w.remainingPercent.rounded()))%")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if let countdown = BarQuotaGauge.resetCountdown(nextReset: w.resetAt, now: now) {
|
||||
Text("· \(countdown)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Opus / Sonnet split (Claude Max only)
|
||||
|
||||
/// Indented Opus/Sonnet weekly sub-lines, present only when the row carries the
|
||||
/// seven_day_opus / seven_day_sonnet windows (Claude Max). Omitted gracefully
|
||||
/// for Pro / Codex.
|
||||
@ViewBuilder private var opusSonnetLines: some View {
|
||||
let opus = windows.first { $0.key == "seven_day_opus" }
|
||||
let sonnet = windows.first { $0.key == "seven_day_sonnet" }
|
||||
if opus != nil || sonnet != nil {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let opus { splitLine(title: "Opus", w: opus) }
|
||||
if let sonnet { splitLine(title: "Sonnet", w: sonnet) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func splitLine(title: String, w: QuotaWindowDetail) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text("└ \(title)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("\(Int(w.remainingPercent.rounded()))%")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if let short = BarCardFormatting.shortReset(iso: w.resetAt, now: now) {
|
||||
Text("· resets \(short)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Stale footnote (Codex only)
|
||||
|
||||
/// Muted "as of <HH:mm> (older session)" caption when the Codex reading came
|
||||
/// from an older session. The gauge/percent still render normally — the data is
|
||||
/// real, only its freshness is qualified. Never fakes a "live" badge.
|
||||
@ViewBuilder private var staleFootnote: some View {
|
||||
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("as of \(clock) (older session)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Empty / error
|
||||
|
||||
/// No quota windows (reauth / error row): no hero gauge, just the honest
|
||||
/// tri-state quota label. Never shows a "no data" cost cell.
|
||||
private var emptyState: some View {
|
||||
Text(
|
||||
row.needsReauth
|
||||
? "reauth needed"
|
||||
: BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// MARK: Shared visuals
|
||||
|
||||
private func thinBar(fill: Double, color: Color) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.primary.opacity(0.10))
|
||||
Capsule().fill(color).frame(width: max(2, 40 * fill))
|
||||
}
|
||||
.frame(width: 40, height: 4)
|
||||
}
|
||||
|
||||
private var healthColor: Color {
|
||||
switch row.health {
|
||||
case "error": return .red
|
||||
case "warning": return .orange
|
||||
default: return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for band: BarQuotaGauge.Band) -> Color {
|
||||
switch band {
|
||||
case .green: return .green
|
||||
case .yellow: return .yellow
|
||||
case .orange: return .orange
|
||||
case .red: return .red
|
||||
case .none: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Zero-size bridge that walks up to the enclosing `NSScrollView` at runtime and
|
||||
/// hard-disables both scrollers.
|
||||
///
|
||||
/// Why this exists on top of `.scrollIndicators(.never)`: inside a
|
||||
/// `MenuBarExtra` popover the SwiftUI indicator preference is sometimes ignored,
|
||||
/// and AppKit still draws a vertical scroller whose track steals horizontal width
|
||||
/// and shoves content out of alignment. Reaching the real `NSScrollView` and
|
||||
/// setting `hasVerticalScroller = false` (plus overlay/autohide) guarantees no
|
||||
/// scroller chrome regardless of how the popover hosts the SwiftUI content.
|
||||
struct ScrollerHider: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let probe = NSView(frame: .zero)
|
||||
// Defer the walk until the view is in the hierarchy; at make-time the
|
||||
// enclosing scroll view does not exist yet.
|
||||
DispatchQueue.main.async { hideScroller(from: probe) }
|
||||
return probe
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// The scroll view can be rebuilt on content changes inside the popover, so
|
||||
// re-apply on each update to keep the scroller suppressed.
|
||||
DispatchQueue.main.async { hideScroller(from: nsView) }
|
||||
}
|
||||
|
||||
/// Walk superviews until the first `NSScrollView`, then disable its scrollers.
|
||||
private func hideScroller(from view: NSView) {
|
||||
var current: NSView? = view.superview
|
||||
while let v = current {
|
||||
if let scroll = v as? NSScrollView {
|
||||
scroll.hasVerticalScroller = false
|
||||
scroll.hasHorizontalScroller = false
|
||||
scroll.scrollerStyle = .overlay
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.drawsBackground = false
|
||||
return
|
||||
}
|
||||
current = v.superview
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -924,6 +924,260 @@ do {
|
||||
check(parts.pool.count == 2, "native: pool-only list keeps all rows in pool group")
|
||||
}
|
||||
|
||||
// MARK: Per-window quota detail decode (resilient, backward compatible)
|
||||
|
||||
do {
|
||||
let nativeJSON = """
|
||||
[
|
||||
{
|
||||
"account_id": "claude-code",
|
||||
"provider": "claude-code",
|
||||
"displayName": "Claude Code",
|
||||
"tier": "max",
|
||||
"paused": false,
|
||||
"quota_percentage": 44,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": "2026-06-09T20:00:00.000Z",
|
||||
"is_default": false,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "ok",
|
||||
"cached": false,
|
||||
"fetchedAt": "2026-06-09T14:00:00.000Z",
|
||||
"needsReauth": false,
|
||||
"quota_windows": [
|
||||
{ "key": "five_hour", "label": "5h", "usedPercent": 56, "remainingPercent": 44, "resetAt": "2026-06-09T20:00:00.000Z", "windowMinutes": 300 },
|
||||
{ "key": "seven_day", "label": "week", "usedPercent": 30, "remainingPercent": 70, "resetAt": "2026-06-15T00:00:00.000Z", "windowMinutes": 10080 },
|
||||
{ "key": "seven_day_opus", "label": "Opus · week", "usedPercent": 25, "remainingPercent": 75, "resetAt": "2026-06-15T00:00:00.000Z", "windowMinutes": 10080 },
|
||||
{ "key": "seven_day_sonnet", "label": "Sonnet · week", "usedPercent": 60, "remainingPercent": 40, "resetAt": "2026-06-15T00:00:00.000Z", "windowMinutes": 10080 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"account_id": "codex",
|
||||
"provider": "codex",
|
||||
"displayName": "Codex",
|
||||
"tier": "pro",
|
||||
"paused": false,
|
||||
"quota_percentage": 70,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": "2026-06-09T19:00:00.000Z",
|
||||
"is_default": false,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "warning",
|
||||
"cached": false,
|
||||
"fetchedAt": "2026-06-09T14:00:00.000Z",
|
||||
"needsReauth": false,
|
||||
"quota_windows": [
|
||||
{ "key": "five_hour", "label": "5h", "usedPercent": 19, "remainingPercent": 81, "resetAt": "2026-06-09T19:00:00.000Z", "windowMinutes": 300 }
|
||||
],
|
||||
"stale_as_of": "2026-06-06T11:00:00.000Z"
|
||||
}
|
||||
]
|
||||
"""
|
||||
do {
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(nativeJSON.utf8))
|
||||
check(rows.count == 2, "qw: decodes two native rows")
|
||||
let claude = rows[0]
|
||||
check(claude.quotaWindows?.count == 4, "qw: claude decodes 4 windows")
|
||||
check(claude.quotaWindows?[0].key == "five_hour", "qw: first window key five_hour")
|
||||
check(claude.quotaWindows?[0].label == "5h", "qw: first window label 5h")
|
||||
check(claude.quotaWindows?[0].usedPercent == 56, "qw: usedPercent decodes")
|
||||
check(claude.quotaWindows?[0].remainingPercent == 44, "qw: remainingPercent decodes")
|
||||
check(claude.quotaWindows?[0].windowMinutes == 300, "qw: windowMinutes 300 decodes")
|
||||
check(
|
||||
claude.quotaWindows?[0].resetAt == "2026-06-09T20:00:00.000Z", "qw: resetAt decodes (ISO)")
|
||||
check(
|
||||
claude.quotaWindows?[2].key == "seven_day_opus", "qw: opus window present (Max)")
|
||||
check(
|
||||
claude.quotaWindows?[3].key == "seven_day_sonnet", "qw: sonnet window present (Max)")
|
||||
check(claude.staleAsOf == nil, "qw: claude (fresh) has nil staleAsOf")
|
||||
check(claude.quotaWindows?[0].id == "five_hour", "qw: window id is its key")
|
||||
|
||||
let codex = rows[1]
|
||||
check(codex.quotaWindows?.count == 1, "qw: codex decodes 1 window")
|
||||
check(codex.staleAsOf == "2026-06-06T11:00:00.000Z", "qw: codex staleAsOf decodes (stale)")
|
||||
} catch {
|
||||
check(false, "qw: native decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy payload WITHOUT the two native-only keys still decodes (quotaWindows
|
||||
// and staleAsOf -> nil). This is the backward-compatibility guarantee.
|
||||
do {
|
||||
let legacyJSON = """
|
||||
[
|
||||
{
|
||||
"account_id": "alice@example.com",
|
||||
"provider": "agy",
|
||||
"displayName": "Alice",
|
||||
"tier": "ultra",
|
||||
"paused": false,
|
||||
"quota_percentage": 80,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": null,
|
||||
"is_default": true,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "ok",
|
||||
"cached": true,
|
||||
"fetchedAt": "2026-06-07T19:00:00Z",
|
||||
"needsReauth": false
|
||||
}
|
||||
]
|
||||
"""
|
||||
do {
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(legacyJSON.utf8))
|
||||
check(rows.count == 1, "qw-legacy: legacy row decodes")
|
||||
check(rows[0].quotaWindows == nil, "qw-legacy: missing quota_windows -> nil")
|
||||
check(rows[0].staleAsOf == nil, "qw-legacy: missing stale_as_of -> nil")
|
||||
} catch {
|
||||
check(false, "qw-legacy: legacy decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: burnMinutesRemaining (linear single-window projection)
|
||||
|
||||
do {
|
||||
// Mid-window pace: a 300-min window resets in 150 min -> elapsed = 150 min.
|
||||
// usedPercent = 50 -> rate = 50/150 = 1/3 %/min -> remaining 50% / (1/3) = 150.
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let resetIn150 = now.addingTimeInterval(150 * 60)
|
||||
let t = BarQuotaGauge.burnMinutesRemaining(
|
||||
usedPercent: 50, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(t == 150, "burn: mid-window 50% over 150min elapsed -> 150 min remaining")
|
||||
|
||||
// Near-zero usage -> nil ("plenty"); avoids an absurd projection.
|
||||
let lowUse = BarQuotaGauge.burnMinutesRemaining(
|
||||
usedPercent: 0.5, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(lowUse == nil, "burn: near-zero usage -> nil (plenty)")
|
||||
|
||||
// Exhausted -> 0 ("limit reached").
|
||||
let exhausted = BarQuotaGauge.burnMinutesRemaining(
|
||||
usedPercent: 100, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(exhausted == 0, "burn: usedPercent>=100 -> 0 (limit reached)")
|
||||
|
||||
// Unknown window length or reset -> nil (omit pace).
|
||||
check(
|
||||
BarQuotaGauge.burnMinutesRemaining(
|
||||
usedPercent: 50, resetAt: resetIn150, windowMinutes: nil, now: now) == nil,
|
||||
"burn: nil windowMinutes -> nil")
|
||||
check(
|
||||
BarQuotaGauge.burnMinutesRemaining(
|
||||
usedPercent: 50, resetAt: nil, windowMinutes: 300, now: now) == nil,
|
||||
"burn: nil resetAt -> nil")
|
||||
|
||||
// elapsed <= 0 (reset already further out than the full window) -> nil.
|
||||
let resetTooFar = now.addingTimeInterval(400 * 60)
|
||||
check(
|
||||
BarQuotaGauge.burnMinutesRemaining(
|
||||
usedPercent: 50, resetAt: resetTooFar, windowMinutes: 300, now: now) == nil,
|
||||
"burn: elapsed<=0 -> nil")
|
||||
}
|
||||
|
||||
// MARK: selectBindingWindow
|
||||
|
||||
do {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
_ = now
|
||||
let fiveHour = QuotaWindowDetail(
|
||||
key: "five_hour", label: "5h", usedPercent: 56, remainingPercent: 44, windowMinutes: 300)
|
||||
let week = QuotaWindowDetail(
|
||||
key: "seven_day", label: "week", usedPercent: 30, remainingPercent: 70, windowMinutes: 10080)
|
||||
let opus = QuotaWindowDetail(
|
||||
key: "seven_day_opus", label: "Opus · week", usedPercent: 75, remainingPercent: 25,
|
||||
windowMinutes: 10080)
|
||||
|
||||
let binding = BarQuotaGauge.selectBindingWindow([fiveHour, week, opus])
|
||||
check(binding?.key == "seven_day_opus", "binding: lowest remaining (opus 25%) wins")
|
||||
|
||||
// Tie on remaining% -> shorter window first.
|
||||
let weekTie = QuotaWindowDetail(
|
||||
key: "seven_day", label: "week", usedPercent: 60, remainingPercent: 40, windowMinutes: 10080)
|
||||
let fiveTie = QuotaWindowDetail(
|
||||
key: "five_hour", label: "5h", usedPercent: 60, remainingPercent: 40, windowMinutes: 300)
|
||||
let tieBinding = BarQuotaGauge.selectBindingWindow([weekTie, fiveTie])
|
||||
check(tieBinding?.key == "five_hour", "binding: remaining tie -> shorter window (5h) wins")
|
||||
|
||||
check(BarQuotaGauge.selectBindingWindow([]) == nil, "binding: empty input -> nil")
|
||||
}
|
||||
|
||||
// MARK: paceClause phrasing
|
||||
|
||||
do {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
|
||||
|
||||
// Finite projection -> "~Hh Mm left at this pace".
|
||||
let finite = BarQuotaGauge.paceClause(
|
||||
usedPercent: 50, remainingPercent: 50, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(finite == "~2h 30m left at this pace", "pace: finite -> '~2h 30m left at this pace'")
|
||||
|
||||
// Lots of headroom -> "plenty at this pace".
|
||||
let plenty = BarQuotaGauge.paceClause(
|
||||
usedPercent: 10, remainingPercent: 90, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(plenty == "plenty at this pace", "pace: >=85% remaining -> 'plenty at this pace'")
|
||||
|
||||
// Near-zero usage -> "plenty at this pace" (burn nil but window known).
|
||||
let plentyLow = BarQuotaGauge.paceClause(
|
||||
usedPercent: 0.5, remainingPercent: 99.5, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(plentyLow == "plenty at this pace", "pace: near-zero usage -> 'plenty at this pace'")
|
||||
|
||||
// Exhausted -> "limit reached, resets in ...".
|
||||
let reached = BarQuotaGauge.paceClause(
|
||||
usedPercent: 100, remainingPercent: 0, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(
|
||||
reached?.hasPrefix("limit reached, resets in") == true,
|
||||
"pace: 0% remaining -> 'limit reached, resets in ...'")
|
||||
|
||||
// < 5 min floor -> limit-reached path, never a scary "~0m".
|
||||
// usedPercent 99 over 150min elapsed -> ~1.5 min remaining (<5).
|
||||
let nearFloor = BarQuotaGauge.paceClause(
|
||||
usedPercent: 99, remainingPercent: 1, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(
|
||||
nearFloor?.hasPrefix("limit reached") == true && !(nearFloor?.contains("~0m") ?? true),
|
||||
"pace: <5m floor -> limit-reached, never '~0m'")
|
||||
|
||||
// Unknown window -> nil (omit).
|
||||
let unknown = BarQuotaGauge.paceClause(
|
||||
usedPercent: 50, remainingPercent: 50, resetAt: nil, windowMinutes: nil, now: now)
|
||||
check(unknown == nil, "pace: unknown window -> nil (omit)")
|
||||
|
||||
// resetAt in the past -> nil pace.
|
||||
let pastReset = ISO8601DateFormatter().string(from: now.addingTimeInterval(-60))
|
||||
let past = BarQuotaGauge.paceClause(
|
||||
usedPercent: 50, remainingPercent: 50, resetAt: pastReset, windowMinutes: 300, now: now)
|
||||
check(past == nil, "pace: past resetAt -> nil pace")
|
||||
}
|
||||
|
||||
// MARK: headroomLeader
|
||||
|
||||
do {
|
||||
func subRow(_ provider: String, _ name: String, remaining: Double) -> BarSummaryRow {
|
||||
BarSummaryRow(
|
||||
accountId: provider, provider: provider, displayName: name, quotaStatus: "ok",
|
||||
quotaWindows: [
|
||||
QuotaWindowDetail(
|
||||
key: "five_hour", label: "5h", usedPercent: 100 - remaining,
|
||||
remainingPercent: remaining, windowMinutes: 300)
|
||||
])
|
||||
}
|
||||
let claude = subRow("claude-code", "Claude Code", remaining: 44)
|
||||
let codex = subRow("codex", "Codex", remaining: 81)
|
||||
let errorRow = BarSummaryRow(
|
||||
accountId: "broken", provider: "claude-code", displayName: "Broken", quotaStatus: "error")
|
||||
|
||||
let leader = BarQuotaGauge.headroomLeader([claude, codex, errorRow])
|
||||
check(leader?.label == "Codex", "headroom: highest-binding-remaining (Codex 81%) leads")
|
||||
check(leader?.remainingPercent == 81, "headroom: leader carries its remaining%")
|
||||
|
||||
// Error/reauth rows (no binding window) are excluded from the count.
|
||||
let single = BarQuotaGauge.headroomLeader([claude, errorRow])
|
||||
check(single == nil, "headroom: <2 eligible subscriptions -> nil")
|
||||
|
||||
check(BarQuotaGauge.headroomLeader([]) == nil, "headroom: empty -> nil")
|
||||
}
|
||||
|
||||
// cleanup
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
|
||||
|
||||
@@ -50,4 +50,151 @@ public enum BarQuotaGauge {
|
||||
if hours > 0 { return "resets in \(hours)h \(minutes)m" }
|
||||
return "resets in \(minutes)m"
|
||||
}
|
||||
|
||||
// MARK: Burn-rate projection (single-window, no history)
|
||||
|
||||
/// Project minutes-to-exhaustion for ONE quota window from a single snapshot.
|
||||
///
|
||||
/// Linear model, no smoothing, no cross-window inference: a window of length
|
||||
/// `windowMinutes` that resets at `resetAt` started at `resetAt - windowMinutes`
|
||||
/// and has been running `elapsed = windowMinutes - max(0, (resetAt - now)/60)`
|
||||
/// minutes. The window's OWN average burn rate is `usedPercent / elapsed`
|
||||
/// (%/min); minutes left to hit 100% is `(100 - usedPercent) / rate`, which
|
||||
/// simplifies to `(100 - usedPercent) * elapsed / usedPercent`.
|
||||
///
|
||||
/// Returns:
|
||||
/// - nil when any input is unknown (windowMinutes/resetAt nil, elapsed <= 0):
|
||||
/// the caller OMITS the pace clause rather than guessing.
|
||||
/// - nil when usage is near-zero (<= ~1%): burn is negligible, the caller
|
||||
/// renders "plenty at this pace" instead of an absurdly large projection.
|
||||
/// - 0 when already exhausted (usedPercent >= 100): "limit reached".
|
||||
/// - otherwise the projected whole minutes remaining at the current pace.
|
||||
public static func burnMinutesRemaining(
|
||||
usedPercent: Double, resetAt: Date?, windowMinutes: Int?, now: Date
|
||||
) -> Int? {
|
||||
guard let windowMinutes, let resetAt else { return nil }
|
||||
let minutesToReset = resetAt.timeIntervalSince(now) / 60
|
||||
let elapsed = Double(windowMinutes) - max(0, minutesToReset)
|
||||
guard elapsed > 0 else { return nil }
|
||||
if usedPercent >= 100 { return 0 }
|
||||
// Near-zero burn would project an effectively infinite runway; treat it as
|
||||
// "plenty" (nil) so the phrasing layer can say so honestly.
|
||||
guard usedPercent > 1.0 else { return nil }
|
||||
let remaining = (100 - usedPercent) * elapsed / usedPercent
|
||||
return Int(remaining)
|
||||
}
|
||||
|
||||
/// Pick the BINDING window: the one a subscription runs out of first, i.e. the
|
||||
/// lowest `remainingPercent` (closest to empty). Ties break to the shorter
|
||||
/// window first (5h before week), then by a stable key order so the choice is
|
||||
/// deterministic. Opus/Sonnet windows are eligible. Returns nil for empty
|
||||
/// input (error/reauth rows have no windows, so they get no hero gauge).
|
||||
public static func selectBindingWindow(_ windows: [QuotaWindowDetail]) -> QuotaWindowDetail? {
|
||||
guard !windows.isEmpty else { return nil }
|
||||
return windows.min { a, b in
|
||||
if a.remainingPercent != b.remainingPercent {
|
||||
return a.remainingPercent < b.remainingPercent
|
||||
}
|
||||
let am = a.windowMinutes ?? Int.max
|
||||
let bm = b.windowMinutes ?? Int.max
|
||||
if am != bm { return am < bm }
|
||||
return keyRank(a.key) < keyRank(b.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable ordering for window keys when remaining% and length tie.
|
||||
private static func keyRank(_ key: String) -> Int {
|
||||
switch key {
|
||||
case "five_hour": return 0
|
||||
case "seven_day": return 1
|
||||
case "seven_day_opus": return 2
|
||||
case "seven_day_sonnet": return 3
|
||||
default: return 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Trailing pace clause for a window's hero/footer line, or nil to OMIT it.
|
||||
///
|
||||
/// Phrasing rules (in order):
|
||||
/// - exhausted (remaining <= 0) or status "rejected" → "limit reached,
|
||||
/// resets in <countdown>". This REPLACES the bare reset countdown.
|
||||
/// - lots of headroom (remaining >= 85) or near-zero usage (burn == nil) →
|
||||
/// "plenty at this pace".
|
||||
/// - a finite projection m >= 5 → "~<Hh Mm> left at this pace".
|
||||
/// - m < 5 → routed to the limit-reached path (never a scary "~0m").
|
||||
/// - unknown window (windowMinutes/resetAt nil, elapsed <= 0) → nil (omit).
|
||||
/// - resetAt already in the past (clock skew / stale) → nil pace; the
|
||||
/// countdown elsewhere shows "reset due".
|
||||
public static func paceClause(
|
||||
usedPercent: Double,
|
||||
remainingPercent: Double,
|
||||
resetAt: String?,
|
||||
windowMinutes: Int?,
|
||||
status: String = "ok",
|
||||
now: Date
|
||||
) -> String? {
|
||||
let resetDate = resetAt.flatMap { BarFormatting.isoDate($0) }
|
||||
|
||||
if remainingPercent <= 0 || status == "rejected" {
|
||||
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
|
||||
// resetCountdown returns "resets in 3h 12m"; reuse just the duration.
|
||||
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
|
||||
return "limit reached, resets in \(duration)"
|
||||
}
|
||||
return "limit reached"
|
||||
}
|
||||
|
||||
// A reset in the past means our window math is unreliable; omit the pace.
|
||||
if let resetDate, resetDate.timeIntervalSince(now) <= 0 { return nil }
|
||||
|
||||
let burn = burnMinutesRemaining(
|
||||
usedPercent: usedPercent, resetAt: resetDate, windowMinutes: windowMinutes, now: now)
|
||||
|
||||
if remainingPercent >= 85 || burn == nil {
|
||||
// nil burn here is either unknown window (handled below) or near-zero use.
|
||||
if windowMinutes == nil || resetDate == nil { return nil }
|
||||
return "plenty at this pace"
|
||||
}
|
||||
|
||||
guard let m = burn else { return nil }
|
||||
if m < 5 {
|
||||
// Floor: anything under 5 minutes is effectively spent; say so plainly.
|
||||
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
|
||||
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
|
||||
return "limit reached, resets in \(duration)"
|
||||
}
|
||||
return "limit reached"
|
||||
}
|
||||
return "~\(compactDuration(minutes: m)) left at this pace"
|
||||
}
|
||||
|
||||
/// Compact "Hh Mm" / "Mm" duration for pace phrasing (e.g. "2h 5m", "35m").
|
||||
static func compactDuration(minutes: Int) -> String {
|
||||
let h = minutes / 60
|
||||
let m = minutes % 60
|
||||
if h > 0 { return "\(h)h \(m)m" }
|
||||
return "\(m)m"
|
||||
}
|
||||
|
||||
/// Header "most room" leader: among subscription rows that have a binding
|
||||
/// window, the one whose BINDING window has the HIGHEST remaining%. Rows with
|
||||
/// no binding window (error/reauth) are excluded. Tie-breaks alphabetically by
|
||||
/// display name (falling back to provider). Returns nil with fewer than two
|
||||
/// eligible subscriptions (the header is suppressed below that).
|
||||
public static func headroomLeader(_ rows: [BarSummaryRow]) -> (label: String, remainingPercent: Double)? {
|
||||
let eligible: [(label: String, remaining: Double)] = rows.compactMap { row in
|
||||
guard let binding = selectBindingWindow(row.quotaWindows ?? []) else { return nil }
|
||||
let label = row.displayName ?? row.provider
|
||||
return (label, binding.remainingPercent)
|
||||
}
|
||||
guard eligible.count >= 2 else { return nil }
|
||||
let leader = eligible.max { a, b in
|
||||
if a.remaining != b.remaining { return a.remaining < b.remaining }
|
||||
// Highest remaining wins; alphabetical tie-break (smaller name "wins" max
|
||||
// only when remaining is equal, so invert the name comparison).
|
||||
return a.label > b.label
|
||||
}
|
||||
guard let leader else { return nil }
|
||||
return (leader.label, leader.remaining)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,43 @@
|
||||
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
|
||||
@@ -27,6 +65,14 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
public let cached: Bool
|
||||
public let fetchedAt: String?
|
||||
public let needsReauth: 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)" }
|
||||
@@ -47,6 +93,8 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
case cached
|
||||
case fetchedAt
|
||||
case needsReauth
|
||||
case quotaWindows = "quota_windows"
|
||||
case staleAsOf = "stale_as_of"
|
||||
}
|
||||
|
||||
public init(
|
||||
@@ -64,7 +112,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
health: String = "ok",
|
||||
cached: Bool = false,
|
||||
fetchedAt: String? = nil,
|
||||
needsReauth: Bool = false
|
||||
needsReauth: Bool = false,
|
||||
quotaWindows: [QuotaWindowDetail]? = nil,
|
||||
staleAsOf: String? = nil
|
||||
) {
|
||||
self.accountId = accountId
|
||||
self.provider = provider
|
||||
@@ -81,6 +131,33 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
self.cached = cached
|
||||
self.fetchedAt = fetchedAt
|
||||
self.needsReauth = needsReauth
|
||||
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)
|
||||
quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows)
|
||||
staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,32 @@ import type { DailyUsage, HourlyUsage } from '../usage/types';
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Per-window quota detail for native subscription rows (Claude/Codex).
|
||||
*
|
||||
* Carries BOTH used and remaining percent so the macOS bar never re-derives
|
||||
* them. CLIProxy rows omit the parent `quotaWindows` field entirely, keeping
|
||||
* the payload backward compatible.
|
||||
*
|
||||
* JSON shape (decode test pins these exact keys): the inner object keys stay
|
||||
* camelCase (usedPercent/remainingPercent/resetAt/windowMinutes); only the
|
||||
* parent field name serializes to snake_case ("quota_windows").
|
||||
*/
|
||||
export interface QuotaWindowDetail {
|
||||
/** Stable key: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet". */
|
||||
key: string;
|
||||
/** Display label: "5h" | "week" | "Opus · week" | "Sonnet · week". */
|
||||
label: string;
|
||||
/** Used percentage (0-100). */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100). Carried explicitly, never derived in Swift. */
|
||||
remainingPercent: number;
|
||||
/** ISO timestamp when this window resets, null if unknown. */
|
||||
resetAt: string | null;
|
||||
/** Window length in minutes (300 | 10080), null if unknown. */
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Single account glance row returned by /api/bar/summary */
|
||||
export interface BarSummaryRow {
|
||||
/** Account identifier (email or custom name) */
|
||||
@@ -67,6 +93,18 @@ export interface BarSummaryRow {
|
||||
fetchedAt: string;
|
||||
/** True if account token is expired and needs re-authentication */
|
||||
needsReauth: boolean;
|
||||
/**
|
||||
* Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||
* Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode
|
||||
* tests and the Swift legacy path stay unaffected. Serialized as
|
||||
* "quota_windows".
|
||||
*/
|
||||
quotaWindows?: QuotaWindowDetail[];
|
||||
/**
|
||||
* Native-only ISO mtime of the source session that supplied a stale Codex
|
||||
* reading. Present only when stale; serialized as "stale_as_of".
|
||||
*/
|
||||
staleAsOf?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -177,6 +215,21 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a row to its wire shape. The native-only additions use snake_case parent
|
||||
* keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed
|
||||
* casing; inner QuotaWindowDetail keys stay camelCase and pass through as-is.
|
||||
* Rows without the native fields serialize byte-identically to before, so
|
||||
* CLIProxy decode/encode tests are unaffected.
|
||||
*/
|
||||
function serializeBarRow(row: BarSummaryRow): Record<string, unknown> {
|
||||
const { quotaWindows, staleAsOf, ...rest } = row;
|
||||
const wire: Record<string, unknown> = { ...rest };
|
||||
if (quotaWindows !== undefined) wire.quota_windows = quotaWindows;
|
||||
if (staleAsOf !== undefined && staleAsOf !== null) wire.stale_as_of = staleAsOf;
|
||||
return wire;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-account health derivation
|
||||
// ============================================================================
|
||||
@@ -496,7 +549,7 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
|
||||
const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? [];
|
||||
|
||||
res.json([...rows, ...nativeRows]);
|
||||
res.json([...rows, ...nativeRows].map(serializeBarRow));
|
||||
} catch (err) {
|
||||
console.error('[bar-routes] /summary error:', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -2,19 +2,41 @@
|
||||
* Codex local quota collector (zero network).
|
||||
*
|
||||
* Codex writes a `rate_limits` object into its rollout session logs
|
||||
* (~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl). We read the most recent
|
||||
* session's last non-null rate_limits to surface the user's Codex subscription
|
||||
* quota WITHOUT any network call — pure local file read.
|
||||
* (~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl). We surface the user's Codex
|
||||
* subscription quota WITHOUT any network call — pure local file read.
|
||||
*
|
||||
* Exec-mode sessions often never emit rate_limits (the field stays null); in
|
||||
* that case we return null so the bar simply omits the Codex row rather than
|
||||
* inventing a fake one.
|
||||
* Exec-mode sessions often never emit rate_limits (the field stays null). The
|
||||
* NEWEST session is frequently exec-mode, so reading only it would emit no row
|
||||
* even though an older interactive session still carries real quota. We instead
|
||||
* scan recent sessions newest-first and use the first one that yields a usable
|
||||
* rate_limits, marking the result stale when that source file is old. We only
|
||||
* return null when no scanned session has any rate_limits — then the bar omits
|
||||
* the row rather than inventing a fake one.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveCodexConfigPaths } from '../services/codex-dashboard-service';
|
||||
|
||||
/**
|
||||
* One Codex quota window normalized for the bar's per-window detail.
|
||||
* `key`/`label` map primary->five_hour/"5h", secondary->seven_day/"week".
|
||||
*/
|
||||
export interface CodexLocalQuotaWindow {
|
||||
/** Stable window key: "five_hour" | "seven_day". */
|
||||
key: string;
|
||||
/** Short display label: "5h" | "week". */
|
||||
label: string;
|
||||
/** Used percentage (0-100). */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100) = 100 - usedPercent. */
|
||||
remainingPercent: number;
|
||||
/** ISO timestamp when this window resets, null if unknown. */
|
||||
resetAt: string | null;
|
||||
/** Window length in minutes (300 / 10080), null if absent in the raw log. */
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Normalized Codex quota snapshot from a local session log. */
|
||||
export interface CodexLocalQuota {
|
||||
/** Remaining percentage (0-100): min across primary/secondary windows. */
|
||||
@@ -25,6 +47,13 @@ export interface CodexLocalQuota {
|
||||
tier: string | null;
|
||||
/** True when the source file is older than the freshness window. */
|
||||
stale: boolean;
|
||||
/**
|
||||
* ISO mtime of the session file that SUPPLIED this data, present only when
|
||||
* stale. Lets the bar render an "as of HH:mm (older session)" footnote.
|
||||
*/
|
||||
staleAsOf: string | null;
|
||||
/** Per-window detail (primary -> five_hour, secondary -> seven_day). */
|
||||
windows: CodexLocalQuotaWindow[];
|
||||
}
|
||||
|
||||
/** Injectable seams for deterministic tests (no real fs / no tail subprocess). */
|
||||
@@ -45,9 +74,17 @@ const TAIL_LINES = 200;
|
||||
/** A source older than this is reported stale (but still emitted). */
|
||||
const STALE_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cap on how many recent session files we scan newest-first before giving up.
|
||||
* Bounds the walk when Codex genuinely never reported (avoids touching the whole
|
||||
* history) while still reaching past several exec-mode sessions to a real one.
|
||||
*/
|
||||
const MAX_SESSIONS_SCANNED = 20;
|
||||
|
||||
interface CodexRateWindow {
|
||||
usedPercent: number;
|
||||
resetsAtSeconds: number | null;
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
interface CodexRateLimits {
|
||||
@@ -73,6 +110,7 @@ function parseWindow(value: unknown): CodexRateWindow | null {
|
||||
return {
|
||||
usedPercent,
|
||||
resetsAtSeconds: asFiniteNumber(obj['resets_at']),
|
||||
windowMinutes: asFiniteNumber(obj['window_minutes']),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,9 +200,52 @@ function computeNextReset(rate: CodexRateLimits): string | null {
|
||||
return new Date(Math.min(...resets) * 1000).toISOString();
|
||||
}
|
||||
|
||||
function windowDetail(window: CodexRateWindow, key: string, label: string): CodexLocalQuotaWindow {
|
||||
const usedPercent = Math.max(0, Math.min(100, window.usedPercent));
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
usedPercent,
|
||||
remainingPercent: 100 - usedPercent,
|
||||
resetAt:
|
||||
window.resetsAtSeconds !== null
|
||||
? new Date(window.resetsAtSeconds * 1000).toISOString()
|
||||
: null,
|
||||
windowMinutes: window.windowMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build per-window detail (primary -> 5h, secondary -> week), omitting absent. */
|
||||
function buildWindows(rate: CodexRateLimits): CodexLocalQuotaWindow[] {
|
||||
const windows: CodexLocalQuotaWindow[] = [];
|
||||
if (rate.primary) windows.push(windowDetail(rate.primary, 'five_hour', '5h'));
|
||||
if (rate.secondary) windows.push(windowDetail(rate.secondary, 'seven_day', 'week'));
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** Scan one session file's tail backward for the latest non-null rate_limits. */
|
||||
async function readRateLimitsFromFile(
|
||||
file: string,
|
||||
tailLinesImpl: (file: string, lines: number) => Promise<string[]>
|
||||
): Promise<CodexRateLimits | null> {
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = await tailLinesImpl(file, TAIL_LINES);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const found = extractRateLimits(lines[i]);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest Codex session's most recent rate_limits and normalize it.
|
||||
* Returns null when no session carries a usable rate_limits object.
|
||||
* Scan recent Codex sessions newest-first and normalize the first usable
|
||||
* rate_limits. Staleness is computed from the mtime of the file that SUPPLIED
|
||||
* the data (not the newest file), so a real-but-old session reads correctly as
|
||||
* stale. Returns null only when none of the scanned sessions carries quota.
|
||||
*/
|
||||
export async function getCodexLocalQuota(
|
||||
deps: CodexLocalQuotaDeps = {}
|
||||
@@ -182,31 +263,28 @@ export async function getCodexLocalQuota(
|
||||
const rolloutFiles = collectRolloutFiles(sessionsDir, existsImpl, readdirImpl);
|
||||
if (rolloutFiles.length === 0) return null;
|
||||
|
||||
// Filename carries an ISO timestamp, so the lexicographically-last file is
|
||||
// the most recent session.
|
||||
const latest = rolloutFiles[rolloutFiles.length - 1];
|
||||
// Filenames carry an ISO timestamp, so the lexicographic order is chronological.
|
||||
// Walk newest-first, bounded, until a session yields a usable rate_limits.
|
||||
const newestFirst = rolloutFiles.slice().reverse().slice(0, MAX_SESSIONS_SCANNED);
|
||||
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = await tailLinesImpl(latest, TAIL_LINES);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Iterate backwards for the most recent non-null rate_limits.
|
||||
let rate: CodexRateLimits | null = null;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const found = extractRateLimits(lines[i]);
|
||||
let sourceFile: string | null = null;
|
||||
for (const file of newestFirst) {
|
||||
const found = await readRateLimitsFromFile(file, tailLinesImpl);
|
||||
if (found) {
|
||||
rate = found;
|
||||
sourceFile = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rate) return null;
|
||||
if (!rate || !sourceFile) return null;
|
||||
|
||||
let stale = false;
|
||||
let staleAsOf: string | null = null;
|
||||
try {
|
||||
stale = now - statMtimeMsImpl(latest) > STALE_AFTER_MS;
|
||||
const mtimeMs = statMtimeMsImpl(sourceFile);
|
||||
stale = now - mtimeMs > STALE_AFTER_MS;
|
||||
if (stale) staleAsOf = new Date(mtimeMs).toISOString();
|
||||
} catch {
|
||||
// Unknown mtime -> treat as fresh; the data itself is still valid.
|
||||
stale = false;
|
||||
@@ -217,5 +295,7 @@ export async function getCodexLocalQuota(
|
||||
nextReset: computeNextReset(rate),
|
||||
tier: rate.planType,
|
||||
stale,
|
||||
staleAsOf,
|
||||
windows: buildWindows(rate),
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude';
|
||||
import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector';
|
||||
import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types';
|
||||
import type { BarSummaryRow } from '../routes/bar-routes';
|
||||
import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes';
|
||||
|
||||
// ============================================================================
|
||||
// Safety constants (concrete, named, module-level)
|
||||
@@ -174,7 +174,69 @@ function deriveClaudeNextReset(quota: ClaudeQuotaResult): string | null {
|
||||
return resets.length > 0 ? resets[0].iso : null;
|
||||
}
|
||||
|
||||
/** Window length in minutes by Claude rate-limit family. */
|
||||
const FIVE_HOUR_MINUTES = 300;
|
||||
const SEVEN_DAY_MINUTES = 10080;
|
||||
|
||||
/**
|
||||
* Build the per-window detail for a Claude subscription row.
|
||||
*
|
||||
* 5h + weekly come from coreUsage (the canonical core summary). Opus/Sonnet
|
||||
* weekly splits come from quota.windows[] and only exist on Max plans, so they
|
||||
* are omitted entirely when absent. Each window carries BOTH used and remaining
|
||||
* percent so the bar never re-derives them.
|
||||
*/
|
||||
function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] {
|
||||
const windows: QuotaWindowDetail[] = [];
|
||||
|
||||
const fiveHour = quota.coreUsage?.fiveHour;
|
||||
if (fiveHour) {
|
||||
windows.push({
|
||||
key: 'five_hour',
|
||||
label: '5h',
|
||||
usedPercent: 100 - fiveHour.remainingPercent,
|
||||
remainingPercent: fiveHour.remainingPercent,
|
||||
resetAt: fiveHour.resetAt,
|
||||
windowMinutes: FIVE_HOUR_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
const weekly = quota.coreUsage?.weekly;
|
||||
if (weekly) {
|
||||
windows.push({
|
||||
key: 'seven_day',
|
||||
label: 'week',
|
||||
usedPercent: 100 - weekly.remainingPercent,
|
||||
remainingPercent: weekly.remainingPercent,
|
||||
resetAt: weekly.resetAt,
|
||||
windowMinutes: SEVEN_DAY_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
// Opus/Sonnet weekly splits are Max-only; surface them when the API carries
|
||||
// them, otherwise omit so non-Max plans get exactly the two core windows.
|
||||
const splitLabels: Record<string, string> = {
|
||||
seven_day_opus: 'Opus · week',
|
||||
seven_day_sonnet: 'Sonnet · week',
|
||||
};
|
||||
for (const w of quota.windows) {
|
||||
const label = splitLabels[w.rateLimitType];
|
||||
if (!label) continue;
|
||||
windows.push({
|
||||
key: w.rateLimitType,
|
||||
label,
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: SEVEN_DAY_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow {
|
||||
const quotaWindows = buildClaudeQuotaWindows(quota);
|
||||
return {
|
||||
account_id: CLAUDE_PROVIDER,
|
||||
provider: CLAUDE_PROVIDER,
|
||||
@@ -191,10 +253,26 @@ function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: numb
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: false,
|
||||
// Omit the field entirely (rather than an empty array) when no windows
|
||||
// resolved, so the wire shape stays minimal.
|
||||
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Map the Codex local windows into the row's per-window detail shape. */
|
||||
function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] {
|
||||
return quota.windows.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: w.windowMinutes,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
|
||||
const quotaWindows = buildCodexQuotaWindows(quota);
|
||||
return {
|
||||
account_id: CODEX_PROVIDER,
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -213,6 +291,9 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: false,
|
||||
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
|
||||
// staleAsOf is only present (and serialized) when the source session is old.
|
||||
...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,25 @@ function tokenCountLine(rateLimits: unknown): string {
|
||||
});
|
||||
}
|
||||
|
||||
function writeRollout(name: string, lines: string[]): string {
|
||||
const day = path.join(sessionsDir, '2026', '06', '09');
|
||||
function writeRollout(sessions: string, name: string, lines: string[]): string {
|
||||
const day = path.join(sessions, '2026', '06', '09');
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const file = path.join(day, name);
|
||||
fs.writeFileSync(file, lines.join('\n') + '\n');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each test gets its OWN codex home so the multi-session scan never bleeds a
|
||||
* fixture from another test (the scan walks ALL recent sessions, not just one).
|
||||
*/
|
||||
function freshHome(slug: string): { env: NodeJS.ProcessEnv; sessions: string } {
|
||||
const home = path.join(tmpDir, `.codex-${slug}`);
|
||||
const sessions = path.join(home, 'sessions');
|
||||
fs.mkdirSync(sessions, { recursive: true });
|
||||
return { env: { CODEX_HOME: home }, sessions };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-local-quota-'));
|
||||
codexHome = path.join(tmpDir, '.codex');
|
||||
@@ -44,7 +55,8 @@ afterAll(() => {
|
||||
|
||||
describe('getCodexLocalQuota', () => {
|
||||
it('parses the last non-null rate_limits into a normalized quota', async () => {
|
||||
writeRollout('rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
const { env, sessions } = freshHome('parse');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
@@ -53,7 +65,7 @@ describe('getCodexLocalQuota', () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() });
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).not.toBeNull();
|
||||
// min(100-0, 100-48) = 52
|
||||
expect(quota?.quotaPercentage).toBe(52);
|
||||
@@ -61,20 +73,94 @@ describe('getCodexLocalQuota', () => {
|
||||
// soonest reset = min(1781033803, 1781192122) -> primary
|
||||
expect(quota?.nextReset).toBe(new Date(1781033803 * 1000).toISOString());
|
||||
expect(quota?.stale).toBe(false);
|
||||
expect(quota?.staleAsOf).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when every rate_limits is null (exec-mode session)', async () => {
|
||||
// A newer session (later filename) with only null rate_limits wins the sort.
|
||||
writeRollout('rollout-2026-06-09T11-00-00-bbbb.jsonl', [
|
||||
it('surfaces per-window detail incl. window_minutes (300 / 10080)', async () => {
|
||||
const { env, sessions } = freshHome('windows');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 19.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 30.0, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
]);
|
||||
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota?.windows).toHaveLength(2);
|
||||
|
||||
const five = quota?.windows.find((w) => w.key === 'five_hour');
|
||||
expect(five?.label).toBe('5h');
|
||||
expect(five?.usedPercent).toBe(19);
|
||||
expect(five?.remainingPercent).toBe(81);
|
||||
expect(five?.windowMinutes).toBe(300);
|
||||
expect(five?.resetAt).toBe(new Date(1781033803 * 1000).toISOString());
|
||||
|
||||
const week = quota?.windows.find((w) => w.key === 'seven_day');
|
||||
expect(week?.label).toBe('week');
|
||||
expect(week?.usedPercent).toBe(30);
|
||||
expect(week?.remainingPercent).toBe(70);
|
||||
expect(week?.windowMinutes).toBe(10080);
|
||||
expect(week?.resetAt).toBe(new Date(1781192122 * 1000).toISOString());
|
||||
});
|
||||
|
||||
it('scans an OLDER session when the newest is exec-mode (rate_limits:null)', async () => {
|
||||
const { env, sessions } = freshHome('fallback');
|
||||
// Older interactive session carries real quota.
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 48.0, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
]);
|
||||
// Newest session is exec-mode: only null rate_limits.
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine(null),
|
||||
]);
|
||||
const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() });
|
||||
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
// Skips the null-newest file, reads the older file's rate_limits.
|
||||
expect(quota).not.toBeNull();
|
||||
expect(quota?.quotaPercentage).toBe(52);
|
||||
expect(quota?.tier).toBe('pro');
|
||||
});
|
||||
|
||||
it('returns null when NO scanned session carries rate_limits (no fake row)', async () => {
|
||||
const { env, sessions } = freshHome('all-null');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [tokenCountLine(null)]);
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine(null),
|
||||
]);
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).toBeNull();
|
||||
});
|
||||
|
||||
it('flags stale when the source file mtime is older than 5 minutes', async () => {
|
||||
const file = writeRollout('rollout-2026-06-09T12-00-00-cccc.jsonl', [
|
||||
it('flags stale from the SOURCE file mtime and sets staleAsOf', async () => {
|
||||
const { env, sessions } = freshHome('stale');
|
||||
// Newest is exec-mode (null); the data comes from the older file, so stale
|
||||
// must reflect the OLDER file's mtime, not the newest's.
|
||||
const sourceFile = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'plus',
|
||||
}),
|
||||
]);
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [tokenCountLine(null)]);
|
||||
|
||||
const sourceMtime = fs.statSync(sourceFile).mtimeMs;
|
||||
const quota = await getCodexLocalQuota({ env, now: sourceMtime + 6 * 60 * 1000 });
|
||||
expect(quota?.stale).toBe(true);
|
||||
expect(quota?.staleAsOf).toBe(new Date(sourceMtime).toISOString());
|
||||
expect(quota?.tier).toBe('plus');
|
||||
});
|
||||
|
||||
it('is fresh (no staleAsOf) when the source file is recent', async () => {
|
||||
const { env, sessions } = freshHome('fresh');
|
||||
const file = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 },
|
||||
@@ -82,19 +168,14 @@ describe('getCodexLocalQuota', () => {
|
||||
}),
|
||||
]);
|
||||
const mtime = fs.statSync(file).mtimeMs;
|
||||
// now = mtime + 6 minutes -> stale
|
||||
const quota = await getCodexLocalQuota({
|
||||
env: { CODEX_HOME: codexHome },
|
||||
now: mtime + 6 * 60 * 1000,
|
||||
});
|
||||
expect(quota?.stale).toBe(true);
|
||||
expect(quota?.tier).toBe('plus');
|
||||
const quota = await getCodexLocalQuota({ env, now: mtime + 60 * 1000 });
|
||||
expect(quota?.stale).toBe(false);
|
||||
expect(quota?.staleAsOf).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there are no rollout files at all', async () => {
|
||||
const emptyHome = path.join(tmpDir, '.codex-empty');
|
||||
fs.mkdirSync(path.join(emptyHome, 'sessions'), { recursive: true });
|
||||
const quota = await getCodexLocalQuota({ env: { CODEX_HOME: emptyHome }, now: Date.now() });
|
||||
const { env } = freshHome('empty');
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,34 @@ function successQuota(): ClaudeQuotaResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** A Max-plan quota carrying the Opus/Sonnet weekly splits in windows[]. */
|
||||
function maxQuotaWithSplits(): ClaudeQuotaResult {
|
||||
const base = successQuota();
|
||||
return {
|
||||
...base,
|
||||
windows: [
|
||||
{
|
||||
rateLimitType: 'seven_day_opus',
|
||||
label: 'Opus weekly',
|
||||
status: 'allowed',
|
||||
utilization: 0.25,
|
||||
usedPercent: 25,
|
||||
remainingPercent: 75,
|
||||
resetAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_sonnet',
|
||||
label: 'Sonnet weekly',
|
||||
status: 'allowed',
|
||||
utilization: 0.6,
|
||||
usedPercent: 60,
|
||||
remainingPercent: 40,
|
||||
resetAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reauthQuota(): ClaudeQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
@@ -115,6 +143,51 @@ describe('Claude native row mapping', () => {
|
||||
expect(row?.needsReauth).toBe(false);
|
||||
});
|
||||
|
||||
it('emits quotaWindows with five_hour + seven_day from coreUsage (no opus/sonnet on non-Max)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => successQuota(), clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
|
||||
expect(row?.quotaWindows).toHaveLength(2);
|
||||
const five = row?.quotaWindows?.find((w) => w.key === 'five_hour');
|
||||
expect(five?.label).toBe('5h');
|
||||
expect(five?.remainingPercent).toBe(42);
|
||||
expect(five?.usedPercent).toBe(58); // 100 - 42
|
||||
expect(five?.windowMinutes).toBe(300);
|
||||
expect(five?.resetAt).toBe('2026-06-09T20:00:00.000Z');
|
||||
|
||||
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
|
||||
expect(week?.label).toBe('week');
|
||||
expect(week?.remainingPercent).toBe(70);
|
||||
expect(week?.usedPercent).toBe(30);
|
||||
expect(week?.windowMinutes).toBe(10080);
|
||||
|
||||
// Max-only splits absent here.
|
||||
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_opus')).toBeUndefined();
|
||||
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds seven_day_opus + seven_day_sonnet windows when present (Max plan)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => maxQuotaWithSplits(), clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
|
||||
// five_hour, seven_day, seven_day_opus, seven_day_sonnet
|
||||
expect(row?.quotaWindows).toHaveLength(4);
|
||||
const opus = row?.quotaWindows?.find((w) => w.key === 'seven_day_opus');
|
||||
expect(opus?.label).toBe('Opus · week');
|
||||
expect(opus?.usedPercent).toBe(25);
|
||||
expect(opus?.remainingPercent).toBe(75);
|
||||
expect(opus?.windowMinutes).toBe(10080);
|
||||
|
||||
const sonnet = row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet');
|
||||
expect(sonnet?.label).toBe('Sonnet · week');
|
||||
expect(sonnet?.usedPercent).toBe(60);
|
||||
expect(sonnet?.remainingPercent).toBe(40);
|
||||
});
|
||||
|
||||
it('emits a reauth error row on 401/needsReauth', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => reauthQuota(), clock);
|
||||
@@ -288,6 +361,25 @@ describe('Codex path', () => {
|
||||
nextReset: '2026-06-09T19:00:00.000Z',
|
||||
tier: 'pro',
|
||||
stale: false,
|
||||
staleAsOf: null,
|
||||
windows: [
|
||||
{
|
||||
key: 'five_hour',
|
||||
label: '5h',
|
||||
usedPercent: 19,
|
||||
remainingPercent: 81,
|
||||
resetAt: '2026-06-09T19:00:00.000Z',
|
||||
windowMinutes: 300,
|
||||
},
|
||||
{
|
||||
key: 'seven_day',
|
||||
label: 'week',
|
||||
usedPercent: 48,
|
||||
remainingPercent: 52,
|
||||
resetAt: '2026-06-14T00:00:00.000Z',
|
||||
windowMinutes: 10080,
|
||||
},
|
||||
],
|
||||
}),
|
||||
now: () => clock.now,
|
||||
};
|
||||
@@ -297,6 +389,9 @@ describe('Codex path', () => {
|
||||
expect(row?.quota_percentage).toBe(52);
|
||||
expect(row?.tier).toBe('pro');
|
||||
expect(row?.health).toBe('ok');
|
||||
expect(row?.quotaWindows).toHaveLength(2);
|
||||
expect(row?.quotaWindows?.[0].windowMinutes).toBe(300);
|
||||
expect(row?.staleAsOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags health warning when the Codex source is stale', async () => {
|
||||
@@ -308,11 +403,16 @@ describe('Codex path', () => {
|
||||
nextReset: null,
|
||||
tier: null,
|
||||
stale: true,
|
||||
staleAsOf: '2026-06-09T13:30:00.000Z',
|
||||
windows: [],
|
||||
}),
|
||||
now: () => clock.now,
|
||||
};
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.find((r) => r.provider === 'codex')?.health).toBe('warning');
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
expect(row?.health).toBe('warning');
|
||||
// staleAsOf flows through so the bar can render the freshness footnote.
|
||||
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
||||
});
|
||||
|
||||
it('omits the codex row when there is no rate_limits (exec-mode)', async () => {
|
||||
|
||||
Reference in new issue
Block a user