Files
nucleic/Sources/NucleicApp/QuotaIndicator.swift
T
abkslmandClaude Opus 4.8 da3e150de3 Light/dark theme tweaks: usage card, branch selector, sidebar buttons, text
- Add AppTheme.usageBar: softer, appearance-aware tint for the home usage
  card bars/percentages — lighter in dark mode, a muted teal (not the sharp
  dark accent) in light mode. Amber/red warning states preserved.
- Branch selector on the home chat bar mirrors the project selector (icon +
  name + chevron) so it's a visible button, not a near-invisible white field.
- Color the three window-toolbar buttons with palette.accent; the split
  view's .tint didn't reach toolbar-hoisted items, leaving them white in light mode.
- Lighten light-mode primaryText (0.13 -> 0.24) to soften the harsh
  black-on-white contrast.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 22:47:12 -07:00

298 lines
12 KiB
Swift

import SwiftUI
import NucleicCore
// MARK: - Context-window usage
/// Per-session context-window occupancy, derived from the latest turn's input tokens
/// against the model's window. Drives the document-icon metric in the composer pill.
struct ContextWindowUsage: Equatable {
let usedTokens: Int
let windowTokens: Int
var utilization: Double {
guard windowTokens > 0 else { return 0 }
return min(100, Double(usedTokens) / Double(windowTokens) * 100)
}
}
// MARK: - Shared formatting
/// Formatting + color helpers shared by the compact pill and the dashboard card, so
/// both read the same `AppStore` quota state identically.
private enum QuotaFormat {
/// SF Symbols for each metric: a clock for the 5-hour window, a calendar for the
/// weekly window, a document for the session context window.
static let fiveHourIcon = "clock"
static let weeklyIcon = "calendar"
static let contextIcon = "doc.text"
static func percent(_ value: Double) -> String { "\(Int(value.rounded()))%" }
/// "6d 23h" / "2h 14m" / "14m" — rolls every 24h into days (the weekly window can
/// be hundreds of hours out), and drops the finest unit once days are shown since
/// minutes are noise at that scale. Coarse anyway: the views refresh every 30s.
static func duration(_ interval: TimeInterval) -> String {
let total = Int(interval)
let days = total / 86_400
let hours = (total % 86_400) / 3600
let minutes = (total % 3600) / 60
if days > 0 { return "\(days)d \(hours)h" }
if hours > 0 { return "\(hours)h \(minutes)m" }
if minutes > 0 { return "\(minutes)m" }
return "<1m"
}
static func resetCaption(_ window: UsageWindow, now: Date) -> String? {
guard let resetsAt = window.resetsAt else { return nil }
let remaining = resetsAt.timeIntervalSince(now)
guard remaining > 0 else { return "resetting…" }
return "resets in \(duration(remaining))"
}
/// A filled-bar / glyph tint: calm accent until 75%, amber to 90%, red beyond.
static func color(forPercent value: Double, palette: AppPalette) -> Color {
switch value {
case ..<75: return palette.accent
case ..<90: return palette.attention
default: return palette.danger
}
}
static func statusLabel(_ status: String?) -> String {
switch status {
case "rejected": return "limit reached"
case "allowed_warning": return "running low"
case "allowed": return "OK"
default: return status ?? "unknown"
}
}
static func gauge(forStatus status: String?) -> String {
switch status {
case "rejected": return "gauge.with.dots.needle.100percent"
case "allowed_warning": return "gauge.with.dots.needle.67percent"
default: return "gauge.with.dots.needle.33percent"
}
}
static func color(forStatus status: String?, palette: AppPalette) -> Color {
switch status {
case "rejected": return palette.danger
case "allowed_warning": return palette.attention
default: return .secondary
}
}
}
// MARK: - Compact pill
/// A compact, account-wide subscription-quota pill (used above the chat composer).
///
/// Shows, left to right: the session **context-window** usage (document icon), the
/// **5-hour** window (clock icon), and the **weekly** window (calendar icon) — each as a
/// `%` colored calm → amber → red. The account windows come from
/// `AppStore.subscriptionUsage` (real Pro/Max plan utilization via the OAuth usage
/// endpoint); when unavailable it falls back to the coarse `rate_limit_event` status,
/// and before any data arrives it shows a muted "Usage" placeholder so it's never blank.
struct QuotaIndicator: View {
@Environment(AppStore.self) private var store
/// Passed in rather than read from `\.appPalette`: the palette environment is
/// injected inside `RootView`'s body, which doesn't reliably reach every host.
let palette: AppPalette
/// Session context-window usage, when shown in a chat (nil elsewhere).
var contextUsage: ContextWindowUsage? = nil
/// When true, render every metric in `.secondary` (to sit quietly beside the chat
/// composer's branch label) instead of the per-utilization calm/amber/red colors.
var monochrome: Bool = false
private struct Metric: Identifiable {
let id: String
let icon: String
let text: String
let color: Color
}
var body: some View {
// Re-evaluate every 30s so the "resets in" countdown stays current without a
// tight per-second timer.
TimelineView(.periodic(from: .now, by: 30)) { context in
content(now: context.date)
.font(.caption)
}
}
@ViewBuilder
private func content(now: Date) -> some View {
let metrics = metrics()
if !metrics.isEmpty {
HStack(spacing: 10) {
ForEach(metrics) { metric in
Label(metric.text, systemImage: metric.icon)
.foregroundStyle(monochrome ? AnyShapeStyle(.secondary) : AnyShapeStyle(metric.color))
}
}
.help(helpText(now: now))
} else {
Label("Usage", systemImage: "gauge.with.dots.needle.33percent")
.foregroundStyle(.secondary)
.help("Checking Claude subscription usage…")
}
}
private func metrics() -> [Metric] {
var result: [Metric] = []
if let ctx = contextUsage {
result.append(Metric(
id: "context", icon: QuotaFormat.contextIcon,
text: QuotaFormat.percent(ctx.utilization),
color: QuotaFormat.color(forPercent: ctx.utilization, palette: palette)))
}
if let usage = store.subscriptionUsage, usage.peakUtilization != nil {
if let f = usage.fiveHour {
result.append(Metric(
id: "fiveHour", icon: QuotaFormat.fiveHourIcon,
text: QuotaFormat.percent(f.utilization),
color: QuotaFormat.color(forPercent: f.utilization, palette: palette)))
}
if let w = usage.sevenDay {
result.append(Metric(
id: "weekly", icon: QuotaFormat.weeklyIcon,
text: QuotaFormat.percent(w.utilization),
color: QuotaFormat.color(forPercent: w.utilization, palette: palette)))
}
} else if let rl = store.latestRateLimit {
result.append(Metric(
id: "status", icon: QuotaFormat.gauge(forStatus: rl.status),
text: QuotaFormat.statusLabel(rl.status),
color: QuotaFormat.color(forStatus: rl.status, palette: palette)))
}
return result
}
private func helpText(now: Date) -> String {
var lines: [String] = []
if let ctx = contextUsage {
lines.append("Context window: \(QuotaFormat.percent(ctx.utilization)) "
+ "(\(ctx.usedTokens.formatted()) / \(ctx.windowTokens.formatted()) tokens)")
}
if let usage = store.subscriptionUsage, usage.peakUtilization != nil {
lines.append("Claude subscription usage")
func line(_ name: String, _ w: UsageWindow?) {
guard let w else { return }
var s = " \(name): \(QuotaFormat.percent(w.utilization)) used"
if let caption = QuotaFormat.resetCaption(w, now: now) { s += " · \(caption)" }
lines.append(s)
}
line("5-hour", usage.fiveHour)
line("Weekly", usage.sevenDay)
line("Weekly · Opus", usage.sevenDayOpus)
line("Weekly · Sonnet", usage.sevenDaySonnet)
} else if let rl = store.latestRateLimit {
var s = "Usage quota: \(QuotaFormat.statusLabel(rl.status))"
if let resetsAt = rl.resetsAt {
let remaining = resetsAt.timeIntervalSince(now)
if remaining > 0 { s += " · resets in \(QuotaFormat.duration(remaining))" }
}
lines.append(s)
}
return lines.joined(separator: "\n")
}
}
// MARK: - Dashboard card
/// The home-dashboard form of the quota indicator: a card (matching the stat cards)
/// with labeled progress bars for the 5-hour (clock) and weekly (calendar) windows and
/// reset countdowns. `matchHeight` lets the home view size it to the activity chart so
/// the two columns line up; it falls back to the `rate_limit_event` status, then a
/// "checking…" placeholder, so it always shows something next to the chart.
struct QuotaCard: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
/// When > 0, the card stretches to at least this height (set to the activity
/// chart's measured height so the two columns are the same height).
var matchHeight: CGFloat = 0
var body: some View {
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("Usage").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.subscriptionUsage, usage.peakUtilization != nil {
VStack(alignment: .leading, spacing: 14) {
windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: now)
windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: now)
}
} else if let rl = store.latestRateLimit {
HStack(spacing: 6) {
Image(systemName: QuotaFormat.gauge(forStatus: rl.status))
.foregroundStyle(QuotaFormat.color(forStatus: rl.status, palette: palette))
Text("Quota \(QuotaFormat.statusLabel(rl.status))")
.foregroundStyle(.secondary)
}
.font(.callout)
} else {
Text("Checking subscription usage…")
.font(.callout).foregroundStyle(.secondary)
}
}
/// One window's bar; renders nothing for an absent window.
@ViewBuilder
private func windowRow(_ label: String, icon: String, _ window: UsageWindow?, now: Date) -> some View {
if let window {
let color = barColor(window.utilization)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
Text(label).font(.subheadline)
Spacer()
Text(QuotaFormat.percent(window.utilization))
.font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(color)
}
bar(fraction: window.utilization / 100, color: color)
if let caption = QuotaFormat.resetCaption(window, now: now) {
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
/// Bar/label tint for the card. Uses the softer, appearance-aware `usageBar`
/// (lighter in dark, lower-contrast in light than the saturated accent) while a
/// window sits in normal range, then escalates to the shared amber/red once it
/// runs low / over so the warning still reads loudly.
private func barColor(_ utilization: Double) -> Color {
switch utilization {
case ..<75: return AppTheme.usageBar
case ..<90: return palette.attention
default: return palette.danger
}
}
private func bar(fraction: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule().fill(color)
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 6)
}
}