From 53e1d2663607e847b9f1b9393fdae7700257d148 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 9 Jul 2026 01:49:14 -0700 Subject: [PATCH] Add Usage Metrics Nucleic-Session: B4F51091-BE3C-42D3-BCDD-8E89E83543D0 Co-authored-by: Nucleic --- Sources/NucleicApp/QuotaIndicator.swift | 74 ++++++++++++++++++------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/Sources/NucleicApp/QuotaIndicator.swift b/Sources/NucleicApp/QuotaIndicator.swift index 0d99b3db..b1052307 100644 --- a/Sources/NucleicApp/QuotaIndicator.swift +++ b/Sources/NucleicApp/QuotaIndicator.swift @@ -330,39 +330,73 @@ struct QuotaCard: View { } } -// MARK: - Codex usage card (placeholder) +// MARK: - Codex usage card -/// A placeholder counterpart to `QuotaCard` for Codex (GPT) subscription limits. Codex has -/// no usage endpoint wired up yet, so this card only reserves the shape: the same card chrome -/// and 5-hour / weekly rows as the Claude card, dimmed, with a "Coming soon" note. When the -/// Codex usage backend lands it can render live windows here exactly like `QuotaCard`. +/// The Codex (ChatGPT-subscription) counterpart to `QuotaCard`: the same card chrome and +/// labeled window bars, reading `AppStore.codexUsage` — the rolling limits the `codex` +/// app-server pushes during a turn (`account/rateLimits/updated`). Codex reports two windows +/// (`primary` ≈ 5-hour, `secondary` ≈ weekly); each renders through the shared `UsageWindowRow` +/// so it reads identically to the Claude card. Until a Codex turn has reported usage there's +/// nothing to poll, so it falls back to a dimmed placeholder with a "no data yet" note. struct CodexUsageCard: View { + @Environment(AppStore.self) private var store + @Environment(\.appPalette) private var palette + /// When > 0, stretch to at least this height (set to the paired card's height so a row of + /// cards lines up — mirrors `QuotaCard.matchHeight`). + var matchHeight: CGFloat = 0 + var title: String = "Codex" + var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary) - Text("Codex").font(.headline) - Spacer() - Text("Coming soon") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(AppTheme.hairline, in: .capsule) + // Re-evaluate every 30s so the "resets in" countdown stays current, matching `QuotaCard`. + TimelineView(.periodic(from: .now, by: 30)) { context in + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary) + Text(title).font(.headline) + } + content(now: context.date) } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(16) + .frame(minHeight: matchHeight > 0 ? matchHeight : nil, alignment: .top) + .background(AppTheme.surface, in: .rect(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1)) + } + } + + @ViewBuilder + private func content(now: Date) -> some View { + if let usage = store.codexUsage, usage.hasData { + VStack(alignment: .leading, spacing: 14) { + windowRow(usage.primary, fallbackLabel: "5-hour limit", icon: QuotaFormat.fiveHourIcon, now: now) + windowRow(usage.secondary, fallbackLabel: "Weekly limit", icon: QuotaFormat.weeklyIcon, now: now) + } + } else { VStack(alignment: .leading, spacing: 14) { placeholderRow("5-hour limit", icon: QuotaFormat.fiveHourIcon) placeholderRow("Weekly limit", icon: QuotaFormat.weeklyIcon) } .opacity(0.5) - Text("Codex usage limits will appear here once support lands.") + Text("Codex usage appears once a Codex chat reports it.") .font(.caption).foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(16) - .background(AppTheme.surface, in: .rect(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1)) } - /// A dimmed, dataless mirror of `QuotaCard.windowRow`: the label and an empty bar, with the + /// One live Codex window; renders nothing for an absent window. Labels itself from the + /// window's reported length when codex sends one, else the positional fallback. + @ViewBuilder + private func windowRow( + _ window: CodexUsageWindow?, fallbackLabel: String, icon: String, now: Date + ) -> some View { + if let window { + UsageWindowRow( + label: window.label ?? fallbackLabel, icon: icon, + utilization: window.utilization(at: now), + resetCaption: QuotaFormat.resetCaption(window.resetsAt, now: now), palette: palette) + } + } + + /// A dimmed, dataless mirror of `UsageWindowRow`: the label and an empty bar, with the /// percentage shown as "—" so the row's shape reads without implying a real value. private func placeholderRow(_ label: String, icon: String) -> some View { VStack(alignment: .leading, spacing: 4) {