Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions ClaudeNein/ClaudeNeinApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct ClaudeNeinApp: App {

var body: some Scene {
Settings {
EmptyView()
PreferencesView()
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions ClaudeNein/DataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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> = 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
Expand Down
30 changes: 30 additions & 0 deletions ClaudeNein/PreferencesView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
109 changes: 109 additions & 0 deletions ClaudeNein/SessionAlertManager.swift
Original file line number Diff line number Diff line change
@@ -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
}

27 changes: 27 additions & 0 deletions ClaudeNeinTests/SessionTokenTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 6 additions & 6 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading