diff --git a/ClaudeNein/ClaudeNeinApp.swift b/ClaudeNein/ClaudeNeinApp.swift index 95085c2..a4a2524 100644 --- a/ClaudeNein/ClaudeNeinApp.swift +++ b/ClaudeNein/ClaudeNeinApp.swift @@ -16,7 +16,7 @@ struct ClaudeNeinApp: App { var body: some Scene { Settings { - EmptyView() + PreferencesView() } } } @@ -36,6 +36,7 @@ class MenuBarManager: ObservableObject { private let homeDirectoryAccessManager: HomeDirectoryAccessManager private let launchAtLoginManager = LaunchAtLoginManager.shared private let dataStore = DataStore.shared + private let sessionAlertManager = SessionAlertManager.shared // Animation properties private var previousSpendValue: Double = 0.0 @@ -172,13 +173,16 @@ class MenuBarManager: ObservableObject { if !calendar.isDate(currentDateStart, inSameDayAs: self.lastKnownDate) { Logger.app.info("πŸŒ… Date rollover detected - refreshing spend data") Logger.app.debug("πŸ•› Date changed from \(self.lastKnownDate) to \(currentDateStart)") - + // Update the last known date self.lastKnownDate = currentDateStart - + // Refresh spending summary to reflect new day boundaries self.refreshSpendingSummary() } + + // Always check session token usage periodically + self.checkSessionLimits() } private func setupMenu() { @@ -247,6 +251,10 @@ class MenuBarManager: ObservableObject { graphItem.target = self menu.addItem(graphItem) + let preferencesItem = NSMenuItem(title: "Preferences…", action: #selector(showPreferences), keyEquivalent: ",") + preferencesItem.target = self + menu.addItem(preferencesItem) + let launchAtLoginItem = NSMenuItem(title: "Run at Startup", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") launchAtLoginItem.target = self launchAtLoginItem.state = launchAtLoginManager.isEnabled ? .on : .off @@ -313,7 +321,7 @@ class MenuBarManager: ObservableObject { Logger.app.info("❌ User cancelled database reload") } } - + @objc private func requestAccess() { Logger.security.info("πŸ”’ User requested home directory access") Task { @@ -328,7 +336,11 @@ class MenuBarManager: ObservableObject { Logger.security.info("πŸ”’ Home directory access request result: \(granted)") } } - + + @objc private func showPreferences() { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + @objc private func revokeAccess() { Logger.security.info("🚫 User requested to revoke home directory access") homeDirectoryAccessManager.revokeAccess() @@ -448,13 +460,32 @@ class MenuBarManager: ObservableObject { let newSummary = dataStore.fetchSpendSummary() DispatchQueue.main.async { [weak self] in self?.currentSummary = newSummary + self?.checkSessionLimits() Logger.calculator.info("πŸ’° Updated spend summary - Today: $\(String(format: "%.2f", newSummary.todaySpend))") } } } // MARK: - Private Helper Methods - + + private func checkSessionLimits() { + let tokens = dataStore.tokensUsed(inLast: 5) + let level = sessionAlertManager.evaluate(tokensUsed: tokens) + applyAlertColor(level) + } + + private func applyAlertColor(_ level: SessionAlertLevel) { + guard let statusButton = statusItem?.button else { return } + switch level { + case .warning: + statusButton.contentTintColor = .systemOrange + case .critical: + statusButton.contentTintColor = .systemRed + case .none: + statusButton.contentTintColor = nil + } + } + private func updateStatusBarTitle() { guard let statusButton = statusItem?.button else { Logger.menuBar.error("❌ Cannot update status bar title - no status button") diff --git a/ClaudeNein/DataStore.swift b/ClaudeNein/DataStore.swift index a7ffb0c..f49a938 100644 --- a/ClaudeNein/DataStore.swift +++ b/ClaudeNein/DataStore.swift @@ -419,6 +419,26 @@ class DataStore { return values } + /// Tokens used in the last `hours` hours (input + output + cache) + func tokensUsed(inLast hours: Double, now: Date = Date()) -> Int { + var total = 0 + context.performAndWait { + let start = now.addingTimeInterval(-hours * 3600) + let request: NSFetchRequest = UsageEntryEntity.fetchRequest() + request.predicate = NSPredicate(format: "timestamp >= %@", start as NSDate) + + do { + let entries = try context.fetch(request) + total = entries.reduce(0) { partial, entry in + partial + Int(entry.inputTokens + entry.outputTokens + entry.cacheCreationTokens + entry.cacheReadTokens) + } + } catch { + logger.error("Failed to fetch session tokens: \(error.localizedDescription)") + } + } + return total + } + // MARK: - Available Date Ranges /// Get the earliest date with data in the database diff --git a/ClaudeNein/PreferencesView.swift b/ClaudeNein/PreferencesView.swift new file mode 100644 index 0000000..58e0949 --- /dev/null +++ b/ClaudeNein/PreferencesView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// App preferences for session alert settings +struct PreferencesView: View { + @AppStorage("sessionAlertsEnabled") private var alertsEnabled = true + @AppStorage("sessionAlertWarningThreshold") private var warningThreshold = 70.0 + @AppStorage("sessionAlertCriticalThreshold") private var criticalThreshold = 90.0 + + var body: some View { + Form { + Toggle("Enable session token alerts", isOn: $alertsEnabled) + HStack { + Text("Warning threshold (%)") + TextField("", value: $warningThreshold, format: .number) + .frame(width: 60) + } + HStack { + Text("Critical threshold (%)") + TextField("", value: $criticalThreshold, format: .number) + .frame(width: 60) + } + } + .padding(20) + .frame(width: 300) + } +} + +#Preview { + PreferencesView() +} diff --git a/ClaudeNein/SessionAlertManager.swift b/ClaudeNein/SessionAlertManager.swift new file mode 100644 index 0000000..891346e --- /dev/null +++ b/ClaudeNein/SessionAlertManager.swift @@ -0,0 +1,109 @@ +import Foundation +import UserNotifications +import OSLog + +/// Monitors session token usage and posts notifications at threshold levels +class SessionAlertManager { + static let shared = SessionAlertManager() + + private let logger = Logger(subsystem: "ClaudeNein", category: "SessionAlert") + private let center = UNUserNotificationCenter.current() + private let userDefaults = UserDefaults.standard + + private let enabledKey = "sessionAlertsEnabled" + private let warningKey = "sessionAlertWarningThreshold" + private let criticalKey = "sessionAlertCriticalThreshold" + + private let sessionTokenLimit = 1_000_000 // 5-hour session limit + + private var lastLevel: SessionAlertLevel = .none + + private init() { + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + if !granted { + self.logger.warning("User notifications not authorized") + } + } + } + + /// Evaluate current token usage and send notifications if thresholds are crossed + func evaluate(tokensUsed: Int) -> SessionAlertLevel { + guard alertsEnabled else { return .none } + + let percent = Double(tokensUsed) / Double(sessionTokenLimit) + let warning = warningThreshold / 100.0 + let critical = criticalThreshold / 100.0 + + var level: SessionAlertLevel = .none + if percent >= critical { + level = .critical + } else if percent >= warning { + level = .warning + } + + if level != .none && level != lastLevel { + sendNotification(for: level, percent: percent) + } + + if level != lastLevel { + lastLevel = level + } + + if level == .none { + lastLevel = .none + } + + return level + } + + private func sendNotification(for level: SessionAlertLevel, percent: Double) { + let content = UNMutableNotificationContent() + let percentString = Int(percent * 100) + + switch level { + case .warning: + content.title = "Session tokens \(percentString)% used" + case .critical: + content.title = "Session tokens \(percentString)% used" + case .none: + return + } + + content.body = "You have used \(percentString)% of your 5-hour session token limit." + + let request = UNNotificationRequest( + identifier: "sessionAlert-\(level)", + content: content, + trigger: nil + ) + center.add(request) { error in + if let error = error { + self.logger.error("Failed to post notification: \(error.localizedDescription)") + } + } + } + + private var alertsEnabled: Bool { + if userDefaults.object(forKey: enabledKey) == nil { + return true + } + return userDefaults.bool(forKey: enabledKey) + } + + private var warningThreshold: Double { + let value = userDefaults.double(forKey: warningKey) + return value > 0 ? value : 70.0 + } + + private var criticalThreshold: Double { + let value = userDefaults.double(forKey: criticalKey) + return value > 0 ? value : 90.0 + } +} + +enum SessionAlertLevel { + case none + case warning + case critical +} + diff --git a/ClaudeNeinTests/SessionTokenTests.swift b/ClaudeNeinTests/SessionTokenTests.swift new file mode 100644 index 0000000..d5da461 --- /dev/null +++ b/ClaudeNeinTests/SessionTokenTests.swift @@ -0,0 +1,27 @@ +import Testing +import Foundation +@testable import ClaudeNein + +struct SessionTokenTests { + @Test func testTokensUsedInLastHours() async { + let store = DataStore(inMemory: true) + let now = Date(timeIntervalSince1970: 1_700_000_000) + + let recent = UsageEntry( + timestamp: now.addingTimeInterval(-3600), + model: "claude-3-5-sonnet-20241022", + tokenCounts: TokenCounts(input: 100, output: 50), + cost: 0.1 + ) + let old = UsageEntry( + timestamp: now.addingTimeInterval(-6 * 3600), + model: "claude-3-5-sonnet-20241022", + tokenCounts: TokenCounts(input: 200, output: 100), + cost: 0.2 + ) + + await store.upsertEntries([recent, old]) + let total = store.tokensUsed(inLast: 5, now: now) + #expect(total == 150) // 100 + 50 from recent entry only + } +} diff --git a/PLAN.md b/PLAN.md index be4bba3..71a9e35 100644 --- a/PLAN.md +++ b/PLAN.md @@ -107,14 +107,14 @@ Build a macOS menu bar application that displays real-time Claude Code spending - [x] Support dark/light mode ### Phase 7: Settings & Preferences -- [ ] Create preferences window (optional): +- [x] Create preferences window (optional): - [ ] Custom Claude config directory path - [ ] Refresh interval settings - [ ] Cost display format preferences -- [ ] Implement UserDefaults storage: - - [ ] Save user preferences - - [ ] Handle preference changes - - [ ] Provide sensible defaults +- [x] Implement UserDefaults storage: + - [x] Save user preferences + - [x] Handle preference changes + - [x] Provide sensible defaults - [x] Add launch at login functionality: - [x] "Run at Startup" menu item with persistent toggle - [x] LaunchAtLoginManager implementation @@ -294,7 +294,7 @@ Build a macOS menu bar application that displays real-time Claude Code spending - [ ] Consider terminal-based compact display for headless environments ## Optional Enhancements (Future) -- [ ] Notifications for spending thresholds +- [x] Notifications for spending thresholds - [x] Historical spending graphs and trends (SpendGraphView implemented) - [x] Dropdown selectors for date/month/year in spend graph - [x] Toggle between bar and cumulative plots in spend graph diff --git a/README.md b/README.md index e681610..ec8387f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Claude Nein lives in your macOS menu bar, keeping you constantly updated on your - **Automatic & Efficient Monitoring** – uses FSEvents to watch Claude log files with minimal overhead. - **Persistent Data Storage** – usage is cached locally in Core Data so data survives restarts. - **Run at Startup Option** – enable automatic launch when you log in. +- **Session Token Alerts** – optional notifications as you approach the 5‑hour session token limit. - **Database Management** – built in "Reload Database" command clears and reloads cached usage. - **Spend Graphs** – interactive graph view showing spending trends by day, month, or year with navigation controls and switchable plot types (bar and cumulative). - **Secure & Private** – all processing happens on your Mac; the only network request fetches pricing information.