- 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]>
298 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|