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
116 changes: 116 additions & 0 deletions AgentNotch/Core/Cache/CacheCountdownManager.swift
Original file line number Diff line number Diff line change
@@ -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<Int> = [90, 60, 50, 40, 30]
/// Prominent alerts: 0:20, 0:10 and expiry.
private static let prominentMilestones: Set<Int> = [20, 10, 0]

private struct Tracker {
var lastTouch: Date?
var announced: Set<Int> = []
}

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()
}
}
20 changes: 20 additions & 0 deletions AgentNotch/Core/ClaudeCode/ClaudeCodeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions AgentNotch/Core/Codex/CodexManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions AgentNotch/Core/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
131 changes: 130 additions & 1 deletion AgentNotch/Views/Notch/AgentNotchContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
@State private var sessionStart = Date()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions AgentNotch/Views/Settings/AgentSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down