From fb1e8cf40df628126d55509fa520082842f23211 Mon Sep 17 00:00:00 2001 From: mauriciokataoka Date: Wed, 19 Aug 2026 10:13:55 -0300 Subject: [PATCH] feat: add prompt cache countdown timer with sound alerts --- .../Core/Cache/CacheCountdownManager.swift | 116 ++++++++++++++++ .../Core/ClaudeCode/ClaudeCodeManager.swift | 20 +++ AgentNotch/Core/Codex/CodexManager.swift | 6 + AgentNotch/Core/Settings/AppSettings.swift | 5 + .../Views/Notch/AgentNotchContentView.swift | 131 +++++++++++++++++- .../Views/Settings/AgentSettingsView.swift | 24 ++++ 6 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 AgentNotch/Core/Cache/CacheCountdownManager.swift diff --git a/AgentNotch/Core/Cache/CacheCountdownManager.swift b/AgentNotch/Core/Cache/CacheCountdownManager.swift new file mode 100644 index 0000000..73074a1 --- /dev/null +++ b/AgentNotch/Core/Cache/CacheCountdownManager.swift @@ -0,0 +1,116 @@ +import AppKit +import Foundation + +/// Tracks how much time remains before each tool's prompt cache expires. +/// +/// The cache has a sliding TTL: every time a message is written that reads or +/// creates cached tokens, the countdown resets to the full TTL. Sound alerts are +/// played at predefined milestones (subtle from 1:30 down to 0:30, prominent from +/// 0:20 until expiry). +@MainActor +final class CacheCountdownManager: ObservableObject { + static let shared = CacheCountdownManager() + + enum Source: String, CaseIterable, Identifiable { + case claudeCode = "Claude Code" + case codex = "Codex" + + var id: String { rawValue } + } + + /// Remaining seconds until expiry, refreshed every second. + @Published private(set) var claudeRemaining: TimeInterval = 0 + @Published private(set) var codexRemaining: TimeInterval = 0 + + /// True while the countdown for the source is running. + @Published private(set) var claudeActive = false + @Published private(set) var codexActive = false + + /// Subtle alerts: 1:30, 1:00, then every 10s down to 0:30. + private static let subtleMilestones: Set = [90, 60, 50, 40, 30] + /// Prominent alerts: 0:20, 0:10 and expiry. + private static let prominentMilestones: Set = [20, 10, 0] + + private struct Tracker { + var lastTouch: Date? + var announced: Set = [] + } + + private var claude = Tracker() + private var codex = Tracker() + private var ticker: Timer? + + private let subtleSound = NSSound(named: "Tink") + private let prominentSound = NSSound(named: "Sosumi") + + private init() {} + + /// Resets the sliding TTL for a source. Call whenever a message used cached tokens. + func markActivity(_ source: Source) { + switch source { + case .claudeCode: + claude.lastTouch = Date() + claude.announced = [] + case .codex: + codex.lastTouch = Date() + codex.announced = [] + } + ensureTicker() + } + + private func ensureTicker() { + guard ticker == nil else { return } + let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.tick() } + } + RunLoop.main.add(timer, forMode: .common) + ticker = timer + tick() + } + + private func tick() { + let ttl = TimeInterval(AppSettings.shared.cacheTimerTtlSeconds) + tick(source: .claudeCode, tracker: &claude, remaining: &claudeRemaining, active: &claudeActive, ttl: ttl) + tick(source: .codex, tracker: &codex, remaining: &codexRemaining, active: &codexActive, ttl: ttl) + + if !claudeActive && !codexActive { + ticker?.invalidate() + ticker = nil + } + } + + private func tick(source: Source, tracker: inout Tracker, remaining: inout TimeInterval, active: inout Bool, ttl: TimeInterval) { + guard let last = tracker.lastTouch else { + remaining = 0 + active = false + return + } + + let value = max(0, ttl - Date().timeIntervalSince(last)) + remaining = value + active = value > 0 + + guard AppSettings.shared.enableCacheTimer else { return } + + let rounded = Int(value.rounded(.down)) + guard !tracker.announced.contains(rounded) else { return } + + tracker.announced.insert(rounded) + + if Self.prominentMilestones.contains(rounded) { + playProminent() + } else if Self.subtleMilestones.contains(rounded) { + playSubtle() + } + } + + private func playSubtle() { + guard AppSettings.shared.cacheTimerSoundsEnabled else { return } + subtleSound?.play() + } + + private func playProminent() { + guard AppSettings.shared.cacheTimerSoundsEnabled else { return } + prominentSound?.play() + } +} \ No newline at end of file diff --git a/AgentNotch/Core/ClaudeCode/ClaudeCodeManager.swift b/AgentNotch/Core/ClaudeCode/ClaudeCodeManager.swift index 69b1392..10f36cb 100644 --- a/AgentNotch/Core/ClaudeCode/ClaudeCodeManager.swift +++ b/AgentNotch/Core/ClaudeCode/ClaudeCodeManager.swift @@ -852,6 +852,16 @@ final class ClaudeCodeManager: ObservableObject { sessionState.tokenUsage.cacheCreationInputTokens = usage["cache_creation_input_tokens"] as? Int ?? sessionState.tokenUsage.cacheCreationInputTokens } + // Sliding cache countdown: any cache hit resets the TTL (ignore replayed history) + if isLoadingHistoryBySession[sessionId] != true, + let usage = message["usage"] as? [String: Any] { + let read = usage["cache_read_input_tokens"] as? Int ?? 0 + let created = usage["cache_creation_input_tokens"] as? Int ?? 0 + if read > 0 || created > 0 { + CacheCountdownManager.shared.markActivity(.claudeCode) + } + } + if let role = message["role"] as? String, role == "user", let content = message["content"] as? [[String: Any]] { for item in content { @@ -1238,6 +1248,16 @@ final class ClaudeCodeManager: ObservableObject { state.tokenUsage.cacheCreationInputTokens = usage["cache_creation_input_tokens"] as? Int ?? state.tokenUsage.cacheCreationInputTokens } + // Sliding cache countdown: any cache hit resets the TTL (ignore replayed history) + if !isLoadingHistory, + let usage = message["usage"] as? [String: Any] { + let read = usage["cache_read_input_tokens"] as? Int ?? 0 + let created = usage["cache_creation_input_tokens"] as? Int ?? 0 + if read > 0 || created > 0 { + CacheCountdownManager.shared.markActivity(.claudeCode) + } + } + if let content = message["content"] as? [[String: Any]] { debugLog("[JSONL DEBUG] --- CONTENT (array with \(content.count) items) ---") for (index, item) in content.enumerated() { diff --git a/AgentNotch/Core/Codex/CodexManager.swift b/AgentNotch/Core/Codex/CodexManager.swift index d9d838a..95e8740 100644 --- a/AgentNotch/Core/Codex/CodexManager.swift +++ b/AgentNotch/Core/Codex/CodexManager.swift @@ -472,6 +472,12 @@ final class CodexManager: ObservableObject { sessionState.tokenUsage.reasoningOutputTokens = totalUsage["reasoning_output_tokens"] as? Int ?? 0 sessionState.tokenUsage.totalTokens = totalUsage["total_tokens"] as? Int ?? 0 + // Sliding cache countdown: a cache hit resets the TTL (ignore replayed history) + if isLoadingHistoryBySession[sessionId] != true, + (totalUsage["cached_input_tokens"] as? Int ?? 0) > 0 { + CacheCountdownManager.shared.markActivity(.codex) + } + if let contextWindow = info["model_context_window"] as? Int { sessionState.tokenUsage.modelContextWindow = contextWindow } diff --git a/AgentNotch/Core/Settings/AppSettings.swift b/AgentNotch/Core/Settings/AppSettings.swift index a3b5734..9e73244 100644 --- a/AgentNotch/Core/Settings/AppSettings.swift +++ b/AgentNotch/Core/Settings/AppSettings.swift @@ -52,6 +52,11 @@ public final class AppSettings: ObservableObject { @AppStorage("claudeUsageRefreshInterval") public var claudeUsageRefreshInterval: Int = 180 // seconds @AppStorage("showClaudeUsageInClosedNotch") public var showClaudeUsageInClosedNotch: Bool = true + // Cache countdown timer + @AppStorage("enableCacheTimer") public var enableCacheTimer: Bool = true + @AppStorage("cacheTimerTtlSeconds") public var cacheTimerTtlSeconds: Int = 300 + @AppStorage("cacheTimerSoundsEnabled") public var cacheTimerSoundsEnabled: Bool = true + public var mcpConfiguration: MCPConfiguration { MCPConfiguration( binaryPath: mcpBinaryPath, diff --git a/AgentNotch/Views/Notch/AgentNotchContentView.swift b/AgentNotch/Views/Notch/AgentNotchContentView.swift index 28c78ad..8f5672a 100644 --- a/AgentNotch/Views/Notch/AgentNotchContentView.swift +++ b/AgentNotch/Views/Notch/AgentNotchContentView.swift @@ -6,6 +6,7 @@ struct AgentNotchContentView: View { @StateObject private var settings = AppSettings.shared @StateObject private var claudeCodeManager = ClaudeCodeManager.shared @StateObject private var codexManager = CodexManager.shared + @ObservedObject private var cacheManager = CacheCountdownManager.shared @State private var isHovering = false @State private var hoverTask: Task? @State private var sessionStart = Date() @@ -362,6 +363,7 @@ struct AgentNotchContentView: View { && (claudeCodeManager.sessionStates.values.contains { $0.isActive || $0.needsPermission } || codexManager.sessionStates.values.contains { $0.isActive }) let hasPermissionNeeded = settings.showPermissionIndicator && !claudeCodeManager.sessionsNeedingPermission.isEmpty + let cacheActive = settings.enableCacheTimer && (cacheManager.claudeActive || cacheManager.codexActive) // Determine what tool to show (prefer active Codex, then active Claude, then recent) let currentClaudeTool: ClaudeToolExecution? = claudeActiveTool ?? recentClaudeTool @@ -388,7 +390,7 @@ struct AgentNotchContentView: View { : 0 let rightWingWidth: CGFloat = (hasCurrentTool || isThinking) ? 120 - : (hasPermissionNeeded ? 100 : 0) + : (hasPermissionNeeded ? 100 : (cacheActive ? 86 : 0)) // If nothing to show, just return the notch width let hasAnyContent = showLeftWing || rightWingWidth > 0 @@ -563,6 +565,13 @@ struct AgentNotchContentView: View { .font(.system(size: 9, weight: .medium)) .foregroundColor(.orange) } + } else if cacheActive { + CacheCountdownPill( + claudeRemaining: cacheManager.claudeRemaining, + codexRemaining: cacheManager.codexRemaining, + claudeActive: cacheManager.claudeActive, + codexActive: cacheManager.codexActive + ) } } .frame(width: rightWingWidth, alignment: .trailing) @@ -732,6 +741,28 @@ struct AgentNotchContentView: View { ) } + // Cache countdown timer + if settings.enableCacheTimer && (cacheManager.claudeActive || cacheManager.codexActive) { + NotchSection(title: "Cache TTL") { + VStack(spacing: 6) { + if cacheManager.claudeActive { + CacheCountdownRow( + source: .claudeCode, + remaining: cacheManager.claudeRemaining, + ttl: TimeInterval(settings.cacheTimerTtlSeconds) + ) + } + if cacheManager.codexActive { + CacheCountdownRow( + source: .codex, + remaining: cacheManager.codexRemaining, + ttl: TimeInterval(settings.cacheTimerTtlSeconds) + ) + } + } + } + } + // Use Claude Code tokens when JSONL is source, otherwise use telemetry let claudeTokens = claudeCodeManager.state.tokenUsage let hasClaudeTokens = claudeTokens.inputTokens > 0 || claudeTokens.outputTokens > 0 @@ -1003,6 +1034,104 @@ struct AgentNotchContentView: View { } } + // MARK: - Cache Countdown + + private struct CacheCountdownPill: View { + let claudeRemaining: TimeInterval + let codexRemaining: TimeInterval + let claudeActive: Bool + let codexActive: Bool + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "bolt.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(displayColor) + Text(formatTime(displayRemaining)) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.85)) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(displayColor.opacity(0.15), in: Capsule()) + .overlay( + Capsule() + .stroke(displayColor.opacity(0.4)) + ) + } + + private var displayRemaining: TimeInterval { + if claudeActive && codexActive { + return min(claudeRemaining, codexRemaining) + } + return claudeActive ? claudeRemaining : codexRemaining + } + + private var displayColor: Color { + if displayRemaining <= 20 { return .red } + if displayRemaining <= 60 { return .yellow } + return .green + } + + private func formatTime(_ interval: TimeInterval) -> String { + let seconds = max(0, Int(interval.rounded(.up))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + } + + private struct CacheCountdownRow: View { + let source: CacheCountdownManager.Source + let remaining: TimeInterval + let ttl: TimeInterval + + private var color: Color { + if remaining <= 20 { return .red } + if remaining <= 60 { return .yellow } + return .green + } + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + .shadow(color: color.opacity(0.6), radius: 2) + + Text(source.rawValue) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Spacer(minLength: 0) + + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.1)) + Capsule() + .fill(color.opacity(0.8)) + .frame(width: geo.size.width * progress) + } + } + .frame(width: 60, height: 4) + + Text(formatTime(remaining)) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(color) + .monospacedDigit() + } + } + + private var progress: Double { + guard ttl > 0 else { return 0 } + return min(1.0, max(0.0, remaining / ttl)) + } + + private func formatTime(_ interval: TimeInterval) -> String { + let seconds = max(0, Int(interval.rounded(.up))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + } + // MARK: - Context Progress Bar private struct ContextProgressBar: View { diff --git a/AgentNotch/Views/Settings/AgentSettingsView.swift b/AgentNotch/Views/Settings/AgentSettingsView.swift index 2da8857..4efc6e8 100644 --- a/AgentNotch/Views/Settings/AgentSettingsView.swift +++ b/AgentNotch/Views/Settings/AgentSettingsView.swift @@ -135,6 +135,30 @@ struct TelemetryGeneralSettingsTab: View { } header: { Text("Display") } + + Section { + Toggle("Enable cache countdown timer", isOn: $settings.enableCacheTimer) + .disabled(!(settings.enableClaudeCodeJSONL || settings.enableCodexJSONL)) + + Toggle("Play sound alerts", isOn: $settings.cacheTimerSoundsEnabled) + .disabled(!settings.enableCacheTimer) + + Stepper(value: $settings.cacheTimerTtlSeconds, in: 60...600, step: 30) { + HStack { + Text("Cache TTL") + Spacer() + Text("\(settings.cacheTimerTtlSeconds)s") + .foregroundColor(.secondary) + } + } + .disabled(!settings.enableCacheTimer) + + Text("Resets whenever a message uses cached tokens. Alerts at 1:30, 1:00, every 10s, then 0:20, 0:10 and 0:00.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } header: { + Text("Cache Timer") + } } .formStyle(.grouped) .padding()