diff --git a/build.sh b/build.sh index a0d8e55..8be54ba 100755 --- a/build.sh +++ b/build.sh @@ -51,6 +51,7 @@ build_app "$APP" "stack-nudge" \ panel/Permissions.swift \ panel/Settings.swift \ panel/SessionStore.swift \ + panel/SessionUsage.swift \ panel/Sessions.swift \ panel/Phrases.swift \ panel/UpdateChecker.swift \ diff --git a/panel/Panel.swift b/panel/Panel.swift index cfe7d8e..6c60657 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -25,6 +25,7 @@ private enum KeyCode { static let one: UInt16 = 18 static let two: UInt16 = 19 static let three: UInt16 = 20 + static let four: UInt16 = 21 static let nKey: UInt16 = 45 } @@ -91,6 +92,7 @@ struct PanelContentView: View { switch nav.mode { case .events: eventsBody case .sessions: SessionsView(store: sessions) + case .usage: UsageView(nav: nav) case .settings: SettingsView(nav: nav) case .phrases: PhrasesView(model: phrases) { nav.mode = .settings } case .updateConfirm: @@ -115,6 +117,7 @@ struct PanelContentView: View { tab(.events, label: "Events", count: store.events.count) tab(.sessions, label: "Sessions", count: sessions.sessions.filter { $0.status == .active }.count) + tab(.usage, label: "Usage", count: 0) tab(.settings, label: "Settings", count: 0, dot: nav.updateAvailable != nil) Spacer() @@ -123,7 +126,7 @@ struct PanelContentView: View { // uncluttered while still surfacing the shortcut range. HStack(spacing: 2) { KeyCapView(symbol: "⌘") - KeyCapView(symbol: "1-3") + KeyCapView(symbol: "1-4") } .opacity(0.7) } @@ -327,6 +330,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private var permissionsWC: PermissionsWindowController? private var updateChecker: UpdateChecker? private var updater: Updater? + private let quotaProbe = QuotaProbe() + private var quotaTimer: Timer? + // Tracks whether the banner has already fired this period per tier so + // we don't refire on every poll. Reset when the tier's resets_at + // advances (a new period started, fresh budget). + private var quotaLastFired: [String: (resetsAt: Date?, fired: Bool)] = [:] func applicationDidFinishLaunching(_ notification: Notification) { let frame = NSRect(x: 0, y: 0, width: 420, height: 280) @@ -396,6 +405,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, updateChecker?.start() updater = Updater(nav: nav) + startQuotaPolling() + // If a previous panel instance was pkilled mid-update by install.sh, // it left a status file behind. Read it now and surface a brief toast // so the user knows the update completed (or failed). @@ -486,6 +497,98 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, hidePanel() } + // MARK: - Quota polling + + // Fires QuotaProbe on a recurring timer. Cadence varies: 60s while the + // panel is visible (keeps the Usage tab feeling alive), longer when + // hidden (default 5 min, configurable via STACKNUDGE_USAGE_POLL_MIN). + // Re-evaluating on every tick keeps the timer schedule responsive to + // the panel being shown/hidden. + private static let quotaPollVisibleInterval: TimeInterval = 60 + private var quotaPollHiddenInterval: TimeInterval { + let mins = Double(ConfigFile.read()["STACKNUDGE_USAGE_POLL_MIN"] ?? "") ?? 5 + return max(60, mins * 60) // floor at 60s to avoid hammering the endpoint + } + + private func startQuotaPolling() { + runQuotaProbe() + scheduleNextQuotaPoll() + } + + private func scheduleNextQuotaPoll() { + quotaTimer?.invalidate() + let interval = panel.isVisible ? Self.quotaPollVisibleInterval : quotaPollHiddenInterval + quotaTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + self?.runQuotaProbe() + self?.scheduleNextQuotaPoll() + } + } + + private func runQuotaProbe() { + quotaProbe.fetch { [weak self] snapshot in + guard let self, let snapshot else { return } + self.nav.quota = snapshot + self.nav.quotaLastUpdated = Date() + self.evaluateQuotaThresholds(snapshot) + } + } + + // Fire a banner when a tier crosses the user's configured threshold — + // once per period (reset when the tier's resets_at advances past the + // prior recorded reset, i.e. a new period started with a fresh budget). + // Master switch on PanelNav silences everything when toggled off. + private func evaluateQuotaThresholds(_ snapshot: QuotaSnapshot) { + guard nav.quotaAlertsEnabled else { return } + let threshold = Double(nav.quotaAlertThreshold) + + let tiers: [(name: String, label: String, tier: QuotaTier?)] = [ + ("five_hour", "Session", snapshot.fiveHour), + ("seven_day", "Weekly", snapshot.sevenDay), + ("seven_day_opus", "Weekly (Opus)", snapshot.sevenDayOpus), + ("seven_day_sonnet", "Weekly (Sonnet)", snapshot.sevenDaySonnet), + ] + + for (name, label, tier) in tiers { + guard let tier else { continue } + var state = quotaLastFired[name] ?? (resetsAt: tier.resetsAt, fired: false) + // Reset the fired flag if the period has rolled over. + if let prior = state.resetsAt, let now = tier.resetsAt, now > prior { + state = (resetsAt: now, fired: false) + } + if tier.utilization >= threshold, !state.fired { + postQuotaBanner(label: label, + percent: Int(tier.utilization.rounded()), + resetsAt: tier.resetsAt) + state.fired = true + } + quotaLastFired[name] = state + } + } + + // Cached — banner posts can fire frequently in test/edge cases; avoid + // allocating a fresh formatter every time. + private static let quotaBannerFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() + + private func postQuotaBanner(label: String, percent: Int, resetsAt: Date?) { + let body: String + if let resetsAt { + body = "\(percent)% used. Resets \(Self.quotaBannerFormatter.localizedString(for: resetsAt, relativeTo: Date()))." + } else { + body = "\(percent)% used." + } + let content = UNMutableNotificationContent() + content.title = "\(label) quota at \(percent)%" + content.body = body + content.categoryIdentifier = "STOP" + let req = UNNotificationRequest(identifier: UUID().uuidString, + content: content, trigger: nil) + UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) + } + // MARK: - UNUserNotificationCenter private func setupNotificationCenter() { @@ -729,6 +832,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, func applicationWillTerminate(_ notification: Notification) { listener?.stop() + quotaTimer?.invalidate() + quotaTimer = nil } // MARK: - PanelKeyDelegate @@ -787,7 +892,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } - // Cmd+1/2/3 jump directly between modes; the in-panel tab strip is + // Cmd+1/2/3/4 jump directly between modes; the in-panel tab strip is // the discoverable mouse equivalent. if cmdOnly { switch event.keyCode { @@ -796,6 +901,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, case KeyCode.two: nav.mode = .sessions; return true case KeyCode.three: + nav.mode = .usage; return true + case KeyCode.four: nav.mode = .settings; return true default: break @@ -942,6 +1049,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } + // Usage tab: only Esc — no selection / list semantics here, but we + // don't want arrow keys to leak through to the events store either. + if nav.mode == .usage { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return false } + if event.keyCode == KeyCode.escape { + hidePanel() + return true + } + return true // swallow other keys so they don't navigate events + } + // Events mode: filter out cmd/ctrl/opt-modified keys so app-level // shortcuts pass through to the responder chain. Shift is allowed // since we use Shift+S for the longer snooze. diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index be68e7a..1ae7a26 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -4,6 +4,7 @@ import SwiftUI enum PanelMode { case events case sessions + case usage case settings case phrases // Confirmation step after the user clicks the "Update available" row. @@ -75,6 +76,16 @@ final class PanelNav: ObservableObject { // PostUpdateView (mode = .postUpdate). Cleared on dismiss. @Published var postUpdateVersion: String? @Published var postUpdateNotes: String? + // Latest /api/oauth/usage snapshot. Driven by the QuotaProbe poller in + // PanelController. nil before the first probe completes, or when the + // probe failed (e.g. user denied keychain access, 401, 429). + @Published var quota: QuotaSnapshot? + @Published var quotaLastUpdated: Date? + // Threshold-crossing notifications. quotaAlertsEnabled is the master + // switch; quotaAlertThreshold is the single percent value used across + // all tiers — banner fires once per period when any tier reaches it. + @Published var quotaAlertsEnabled: Bool = true + @Published var quotaAlertThreshold: Int = 80 var actions: SettingsActions? // Wired by PanelController so nav can re-register the global hotkey @@ -82,6 +93,10 @@ final class PanelNav: ObservableObject { // new spec registered successfully. var setHotkey: ((String) -> Bool)? + // Selectable thresholds for the Usage "Alert threshold" cycle row. + // Sorted ascending so left/right arrows feel intuitive. + static let quotaThresholds: [Int] = [50, 70, 80, 90, 95] + static let macSounds = [ "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", @@ -112,7 +127,7 @@ final class PanelNav: ObservableObject { // when the offset is 1. var updateRowOffset: Int { updateAvailable != nil ? 1 : 0 } - var rowCount: Int { 13 + updateRowOffset } + var rowCount: Int { 15 + updateRowOffset } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means). When updateAvailable @@ -127,10 +142,12 @@ final class PanelNav: ObservableObject { // 6 Permission sound cycle // 7 Voice cycle // 8 Speed cycle - // 9 Edit phrases… action - // 10 Check permissions… action - // 11 Open config file… action - // 12 Quit panel action + // 9 Quota alerts toggle + // 10 Alert threshold cycle + // 11 Edit phrases… action + // 12 Check permissions… action + // 13 Open config file… action + // 14 Quit panel action // MARK: - Disk I/O @@ -148,6 +165,11 @@ final class PanelNav: ObservableObject { soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping" voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede" voiceSpeed = Double(config["STACKNUDGE_VOICE_SPEED"] ?? "") ?? 1.1 + quotaAlertsEnabled = ConfigFile.bool(config, "STACKNUDGE_QUOTA_ALERTS", default: true) + // Coerce out-of-list values to the nearest valid threshold so a + // hand-edited config can't desync the cycle row's selection. + let rawThreshold = Int(config["STACKNUDGE_QUOTA_THRESHOLD"] ?? "") ?? 80 + quotaAlertThreshold = Self.quotaThresholds.min(by: { abs($0 - rawThreshold) < abs($1 - rawThreshold) }) ?? 80 } func loadVoices() { @@ -208,10 +230,10 @@ final class PanelNav: ObservableObject { } switch selectedSettingIndex - updateRowOffset { case 0: startRecordingHotkey() - case 9: actions?.editPhrases() - case 10: actions?.checkPermissions() - case 11: actions?.openConfig() - case 12: actions?.quit() + case 11: actions?.editPhrases() + case 12: actions?.checkPermissions() + case 13: actions?.openConfig() + case 14: actions?.quit() default: applyCycle(forward: true) } } @@ -255,6 +277,19 @@ final class PanelNav: ObservableObject { let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) + case 9: + quotaAlertsEnabled.toggle() + ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS", + value: quotaAlertsEnabled ? "true" : "false") + case 10: + // Cycle through the static thresholds list. Index wraps in both + // directions so the user can dial in either way. + let list = Self.quotaThresholds + let idx = list.firstIndex(of: quotaAlertThreshold) ?? 2 + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + quotaAlertThreshold = list[next] + ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD", + value: String(quotaAlertThreshold)) default: break } diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift new file mode 100644 index 0000000..6c048a9 --- /dev/null +++ b/panel/SessionUsage.swift @@ -0,0 +1,280 @@ +import AppKit +import Foundation +import Security +import SwiftUI + +// Quota tier reported by Anthropic's /api/oauth/usage endpoint. Each tier +// is a percentage of a budget with a known reset time. resetsAt is optional +// because some tiers (extra_usage, future tiers) don't reset on a cycle. +struct QuotaTier: Equatable { + let utilization: Double // 0…100 + let resetsAt: Date? +} + +// Snapshot of the user's Claude Code quota at a point in time. Mirrors the +// shape of the JSON returned by api.anthropic.com/api/oauth/usage. +// +// fiveHour → "Current session" — the 5-hour rolling window the TUI shows. +// sevenDay → "Current week (all models)". +// sevenDayOpus → "Current week (Opus only)" — nil on plans without one. +// sevenDaySonnet → "Current week (Sonnet only)" — nil on plans without one. +struct QuotaSnapshot: Equatable { + let fiveHour: QuotaTier? + let sevenDay: QuotaTier? + let sevenDayOpus: QuotaTier? + let sevenDaySonnet: QuotaTier? +} + +// Reads the Claude Code OAuth token and calls the (unofficial) +// /api/oauth/usage endpoint to fetch the user's quota state. The endpoint +// is the exact data source Claude Code's TUI statusline uses, so output +// matches `/usage` 1:1. +// +// Failure paths (no token, denied keychain access, network error, 401, 429) +// return nil and log to stderr — quota tracking is informational, never a +// critical path. The caller (PanelController) will just retry on the next +// poll tick. +final class QuotaProbe { + + static let endpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")! + static let keychainService = "Claude Code-credentials" + + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 10 + cfg.timeoutIntervalForResource = 15 + self.session = URLSession(configuration: cfg) + } + + // One-shot probe. Calls completion on the main queue. + func fetch(completion: @escaping (QuotaSnapshot?) -> Void) { + guard let token = readAccessToken() else { + completion(nil) + return + } + var request = URLRequest(url: Self.endpoint) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("stack-nudge", forHTTPHeaderField: "User-Agent") + + session.dataTask(with: request) { data, response, _ in + let http = response as? HTTPURLResponse + guard let data, http?.statusCode == 200, + let snapshot = Self.parse(data) else { + if let code = http?.statusCode, code != 200 { + FileHandle.standardError.write(Data( + "stack-nudge: /api/oauth/usage returned \(code)\n".utf8)) + } + DispatchQueue.main.async { completion(nil) } + return + } + DispatchQueue.main.async { completion(snapshot) } + }.resume() + } + + // MARK: - Keychain + + // Read the Claude Code credentials JSON blob from the macOS Keychain and + // extract `claudeAiOauth.accessToken`. macOS prompts the user the first + // time stack-nudge tries to read this entry — once approved (and once + // we're Developer-ID-signed), subsequent reads are silent. + private func readAccessToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { + if status != errSecItemNotFound { + FileHandle.standardError.write(Data( + "stack-nudge: keychain read failed (OSStatus \(status))\n".utf8)) + } + return nil + } + guard let blob = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let oauth = blob["claudeAiOauth"] as? [String: Any], + let token = oauth["accessToken"] as? String, + !token.isEmpty else { + return nil + } + return token + } + + // MARK: - Parsing + + private static let iso: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + private static let isoNoFrac: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static func parse(_ data: Data) -> QuotaSnapshot? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + return QuotaSnapshot( + fiveHour: tier(json["five_hour"]), + sevenDay: tier(json["seven_day"]), + sevenDayOpus: tier(json["seven_day_opus"]), + sevenDaySonnet: tier(json["seven_day_sonnet"]) + ) + } + + private static func tier(_ raw: Any?) -> QuotaTier? { + guard let dict = raw as? [String: Any], + let utilization = dict["utilization"] as? Double else { return nil } + let resetsAt: Date? = (dict["resets_at"] as? String).flatMap { + iso.date(from: $0) ?? isoNoFrac.date(from: $0) + } + return QuotaTier(utilization: utilization, resetsAt: resetsAt) + } +} + +// MARK: - Usage tab UI + +// Renders the current QuotaSnapshot as labelled progress bars. One bar per +// non-nil tier; an "Extra usage" row when the user's plan has top-up enabled. +// Empty state covers two cases: +// 1. Probe hasn't returned yet (loading) — show a spinner. +// 2. Probe failed (no token / denied keychain / 401 / 429) — instructional +// copy pointing the user at `claude /usage` and the in-app settings. +struct UsageView: View { + + @ObservedObject var nav: PanelNav + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let snapshot = nav.quota, !isAllNil(snapshot) { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if let tier = snapshot.fiveHour { + section("Current session") { tierRow(tier) } + } + if let tier = snapshot.sevenDay { + section("Current week (all models)") { tierRow(tier) } + } + if let tier = snapshot.sevenDayOpus { + section("Current week (Opus only)") { tierRow(tier) } + } + if let tier = snapshot.sevenDaySonnet { + section("Current week (Sonnet only)") { tierRow(tier) } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(ThinScrollers()) + } + } else { + emptyState + } + + PageFooter { + FooterHint(label: footerStatusLabel, keys: []) + FooterHint(label: "Hide", keys: ["esc"]) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func section(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal, 6) + content() + } + } + + private func tierRow(_ tier: QuotaTier) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Spacer() + Text("\(Int(tier.utilization.rounded()))%") + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(barColor(tier.utilization)) + } + ProgressView(value: min(tier.utilization, 100), total: 100) + .tint(barColor(tier.utilization)) + if let resets = tier.resetsAt { + Text("Resets \(Self.relative(.full, resets))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 6) + } + + // Treat a snapshot with all tiers nil the same as no snapshot at all — + // user sees the empty state rather than a blank page. Shouldn't happen + // for a real subscription, but covers anomalous endpoint responses. + private func isAllNil(_ s: QuotaSnapshot) -> Bool { + s.fiveHour == nil && s.sevenDay == nil + && s.sevenDayOpus == nil && s.sevenDaySonnet == nil + } + + private var emptyState: some View { + VStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Loading quota…") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Requires Claude Code login. First read may prompt the system keychain.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 24) + } + + // Green < 50 < yellow < 80 < red. Matches ClaudeBar's color thresholds + // so users coming from there see familiar colors. + private func barColor(_ utilization: Double) -> Color { + if utilization >= 80 { return .red } + if utilization >= 50 { return .yellow } + return .green + } + + private var footerStatusLabel: String { + guard let updated = nav.quotaLastUpdated else { return "Loading…" } + return "Updated \(Self.relative(.abbreviated, updated))" + } + + // Cached so SwiftUI re-renders don't allocate a fresh formatter on + // every call. Two styles cover all current uses (full for "Resets in + // 3 days", abbreviated for footer "Updated 5s ago"). + private static let fullFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() + private static let abbreviatedFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + + private static func relative(_ style: RelativeDateTimeFormatter.UnitsStyle, + _ date: Date) -> String { + let formatter = (style == .abbreviated) ? abbreviatedFormatter : fullFormatter + return formatter.localizedString(for: date, relativeTo: Date()) + } + +} + diff --git a/panel/Settings.swift b/panel/Settings.swift index ca03b89..af4432e 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -51,11 +51,16 @@ struct SettingsView: View { row(8 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) } + section("Usage") { + row(9 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off") + row(10 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%") + } + section("Actions") { - row(9 + off, label: "Edit phrases…", kind: .action, value: "") - row(10 + off, label: "Check permissions…", kind: .action, value: "") - row(11 + off, label: "Open config file…", kind: .action, value: "") - row(12 + off, label: "Quit panel", kind: .action, value: "") + row(11 + off, label: "Edit phrases…", kind: .action, value: "") + row(12 + off, label: "Check permissions…", kind: .action, value: "") + row(13 + off, label: "Open config file…", kind: .action, value: "") + row(14 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter