diff --git a/.gitignore b/.gitignore index 8ad298c0..3ea6e31b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ build/ *.xcarchive *.dSYM +# Claude Code local config +.claude/ diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index a2489cd7..00cbfdb8 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -142,6 +142,14 @@ B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0A0012E60000100000001 /* BrightnessManager.swift */; }; B1F747F92EC7E94000F841DB /* LottieView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F747F82EC7E94000F841DB /* LottieView.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; + BBB4112A2F0816B90007988A /* ClaudeCodeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB411292F0816B90007988A /* ClaudeCodeModels.swift */; }; + BBB4112C2F0816D40007988A /* ClaudeCodeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB4112B2F0816D40007988A /* ClaudeCodeManager.swift */; }; + BBB411352F0817830007988A /* ContextBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB411312F0817830007988A /* ContextBar.swift */; }; + BBB411362F0817830007988A /* ClaudeCodeStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB411302F0817830007988A /* ClaudeCodeStatsView.swift */; }; + BBB411372F0817830007988A /* SessionPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB411322F0817830007988A /* SessionPicker.swift */; }; + BBB411382F0817830007988A /* ToolActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB411332F0817830007988A /* ToolActivityIndicator.swift */; }; + BBB411392F0817830007988A /* ClaudeCodeCompactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB4112F2F0817830007988A /* ClaudeCodeCompactView.swift */; }; + BBB4113B2F0870E30007988A /* SessionDotsIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB4113A2F0870E30007988A /* SessionDotsIndicator.swift */; }; F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; }; /* End PBXBuildFile section */ @@ -308,6 +316,14 @@ B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; B1F747F82EC7E94000F841DB /* LottieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieView.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; + BBB411292F0816B90007988A /* ClaudeCodeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeModels.swift; sourceTree = ""; }; + BBB4112B2F0816D40007988A /* ClaudeCodeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeManager.swift; sourceTree = ""; }; + BBB4112F2F0817830007988A /* ClaudeCodeCompactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeCompactView.swift; sourceTree = ""; }; + BBB411302F0817830007988A /* ClaudeCodeStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeStatsView.swift; sourceTree = ""; }; + BBB411312F0817830007988A /* ContextBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBar.swift; sourceTree = ""; }; + BBB411322F0817830007988A /* SessionPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPicker.swift; sourceTree = ""; }; + BBB411332F0817830007988A /* ToolActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolActivityIndicator.swift; sourceTree = ""; }; + BBB4113A2F0870E30007988A /* SessionDotsIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDotsIndicator.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -486,6 +502,7 @@ 1471639B2C5D362F0068B555 /* components */ = { isa = PBXGroup; children = ( + BBB411342F0817830007988A /* ClaudeCode */, B141C23B2CA5F50900AC8CC8 /* Onboarding */, B1C448992C97375A001F0858 /* Tips */, 14C08BB72C8DE49E000F8AA0 /* Calendar */, @@ -510,6 +527,7 @@ 147163B52C5D804B0068B555 /* managers */ = { isa = PBXGroup; children = ( + BBB4112B2F0816D40007988A /* ClaudeCodeManager.swift */, 11D58EA12E760AE100FA8377 /* ImageService.swift */, F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, @@ -626,6 +644,7 @@ 14D570C72C5F38760011E668 /* models */ = { isa = PBXGroup; children = ( + BBB411292F0816B90007988A /* ClaudeCodeModels.swift */, 11F748812ECB07A400F841DB /* MusicControlButton.swift */, 118D1FD02E98FF5F00A2FF63 /* SharingStateManager.swift */, 1163988E2DF5CC870052E6AF /* CalendarModel.swift */, @@ -757,6 +776,19 @@ path = Tips; sourceTree = ""; }; + BBB411342F0817830007988A /* ClaudeCode */ = { + isa = PBXGroup; + children = ( + BBB4113A2F0870E30007988A /* SessionDotsIndicator.swift */, + BBB4112F2F0817830007988A /* ClaudeCodeCompactView.swift */, + BBB411302F0817830007988A /* ClaudeCodeStatsView.swift */, + BBB411312F0817830007988A /* ContextBar.swift */, + BBB411322F0817830007988A /* SessionPicker.swift */, + BBB411332F0817830007988A /* ToolActivityIndicator.swift */, + ); + path = ClaudeCode; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -925,6 +957,7 @@ 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */, 9A0887322C7A693000C160EA /* TabButton.swift in Sources */, 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */, + BBB4112A2F0816B90007988A /* ClaudeCodeModels.swift in Sources */, 1194E9402EACC652009C82D6 /* Color+AccentColor.swift in Sources */, B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */, 116398962DF5D6C00052E6AF /* CalendarServiceProviding.swift in Sources */, @@ -936,6 +969,7 @@ 1163988F2DF5CC870052E6AF /* CalendarModel.swift in Sources */, 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */, 118EBE292E946B3F00D54B5A /* ShareServiceFinder.swift in Sources */, + BBB4113B2F0870E30007988A /* SessionDotsIndicator.swift in Sources */, B19424092CD0FF01003E5DC2 /* LottieAnimationView.swift in Sources */, B1F747F92EC7E94000F841DB /* LottieView.swift in Sources */, B1C974342C642B6D0000E707 /* MarqueeTextView.swift in Sources */, @@ -969,6 +1003,11 @@ B1A78C822C8BA08100BD51B0 /* FullscreenMediaDetection.swift in Sources */, 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */, 14D570C92C5F38890011E668 /* BoringViewModel.swift in Sources */, + BBB411352F0817830007988A /* ContextBar.swift in Sources */, + BBB411362F0817830007988A /* ClaudeCodeStatsView.swift in Sources */, + BBB411372F0817830007988A /* SessionPicker.swift in Sources */, + BBB411382F0817830007988A /* ToolActivityIndicator.swift in Sources */, + BBB411392F0817830007988A /* ClaudeCodeCompactView.swift in Sources */, 14D570BC2C5E98EB0011E668 /* generic.swift in Sources */, 118EBE272E92DE8400D54B5A /* NSMenu+AssociatedObject.swift in Sources */, B1CE8CFE2C6F659400DD9871 /* KeyboardShortcutsHelper.swift in Sources */, @@ -979,6 +1018,7 @@ B10F84A32C6C9596009F3026 /* TestView.swift in Sources */, 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */, 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */, + BBB4112C2F0816D40007988A /* ClaudeCodeManager.swift in Sources */, 1153BD932D986E4300979FB0 /* AppleMusicController.swift in Sources */, 11C5E3132DFE85970065821E /* SettingsWindowController.swift in Sources */, 110029272E84FD4C00035A57 /* TemporaryFileStorageService.swift in Sources */, diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index d95af616..3e6fad39 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -23,6 +23,7 @@ struct ContentView: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var brightnessManager = BrightnessManager.shared @ObservedObject var volumeManager = VolumeManager.shared + @ObservedObject var claudeCodeManager = ClaudeCodeManager.shared @State private var hoverTask: Task? @State private var isHovering: Bool = false @State private var anyDropDebounceTask: Task? @@ -75,6 +76,12 @@ struct ContentView: View { && !vm.hideOnClosed { chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) + } else if !coordinator.expandingView.show && vm.notchState == .closed + && Defaults[.enableClaudeCode] && Defaults[.enableClaudeCodeCollapsedView] + && !claudeCodeManager.availableSessions.isEmpty && !vm.hideOnClosed + { + // Claude Code compact view - dots are below the notch, no side extension needed + // chinWidth stays at vm.closedNotchSize.width (default) } return chinWidth @@ -288,6 +295,11 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { MusicLiveActivity() .frame(alignment: .center) + } else if !coordinator.expandingView.show && vm.notchState == .closed && Defaults[.enableClaudeCode] && Defaults[.enableClaudeCodeCollapsedView] && !claudeCodeManager.availableSessions.isEmpty && !vm.hideOnClosed { + // Claude Code compact view - show when any Claude Code sessions exist (like music shows even when paused) + ClaudeCodeCompactView() + .frame(height: vm.effectiveClosedNotchHeight) + .transition(.opacity.animation(.easeInOut(duration: 0.3))) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { BoringFaceAnimation() } else if vm.notchState == .open { @@ -347,6 +359,8 @@ struct ContentView: View { NotchHomeView(albumArtNamespace: albumArtNamespace) case .shelf: ShelfView() + case .claudeCode: + ClaudeCodeStatsView() } } .transition( diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index fa343990..53036774 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -700,6 +700,12 @@ } } } + }, + "%@ / 200k" : { + + }, + "%@:" : { + }, "%d%%" : { "localizations" : { @@ -901,6 +907,16 @@ } } }, + "%lld session%@ available" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld session%2$@ available" + } + } + } + }, "%lld%%" : { "localizations" : { "ar" : { @@ -1000,6 +1016,9 @@ } } } + }, + "%lldms" : { + }, "• Bug fixes" : { "localizations" : { @@ -1700,6 +1719,9 @@ } } } + }, + "Active sessions" : { + }, "Add" : { "localizations" : { @@ -5201,6 +5223,9 @@ } } } + }, + "Claude Code" : { + }, "Clear slot" : { "localizations" : { @@ -5901,6 +5926,9 @@ } } } + }, + "Context" : { + }, "Continue" : { "localizations" : { @@ -7404,6 +7432,9 @@ } } } + }, + "Display session status dots below the notch when closed." : { + }, "Download" : { "localizations" : { @@ -8305,6 +8336,9 @@ } } } + }, + "Enable Claude Code integration" : { + }, "Enable colored spectrograms" : { "extractionState" : "stale", @@ -12008,6 +12042,9 @@ } } } + }, + "left" : { + }, "Lottie JSON URL" : { "localizations" : { @@ -14508,6 +14545,9 @@ } } } + }, + "No active sessions" : { + }, "No custom animation available" : { "localizations" : { @@ -15009,6 +15049,12 @@ } } } + }, + "No session selected" : { + + }, + "No sessions" : { + }, "Not Now" : { "localizations" : { @@ -16909,6 +16955,9 @@ } } } + }, + "Refresh Sessions" : { + }, "Release name" : { "localizations" : { @@ -18111,6 +18160,9 @@ } } } + }, + "Select Session" : { + }, "Select the music source you want to use. You can change this later in the app settings." : { "localizations" : { @@ -18411,6 +18463,12 @@ } } } + }, + "Session dots show the status of active Claude Code sessions. Tap a dot to focus the corresponding IDE." : { + + }, + "Sessions" : { + }, "Settings" : { "localizations" : { @@ -19011,6 +19069,9 @@ } } } + }, + "Show Claude Code tab in the expanded notch view." : { + }, "Show cool face animation while inactive" : { "localizations" : { @@ -20011,6 +20072,9 @@ } } } + }, + "Show session dots in closed notch" : { + }, "Show settings icon in notch" : { "localizations" : { @@ -20811,6 +20875,9 @@ } } } + }, + "Start Claude Code to begin" : { + }, "Stopped" : { "extractionState" : "stale", @@ -22414,6 +22481,9 @@ } } } + }, + "used" : { + }, "Using System Accent" : { "localizations" : { diff --git a/boringNotch/boringNotch.entitlements b/boringNotch/boringNotch.entitlements index 7f3881d6..d0e4a191 100644 --- a/boringNotch/boringNotch.entitlements +++ b/boringNotch/boringNotch.entitlements @@ -16,6 +16,10 @@ com.apple.security.files.user-selected.read-write + com.apple.security.temporary-exception.files.home-relative-path.read-only + + /.claude/ + com.apple.security.network.client com.apple.security.network.server diff --git a/boringNotch/components/ClaudeCode/ClaudeCodeCompactView.swift b/boringNotch/components/ClaudeCode/ClaudeCodeCompactView.swift new file mode 100644 index 00000000..e2691ce8 --- /dev/null +++ b/boringNotch/components/ClaudeCode/ClaudeCodeCompactView.swift @@ -0,0 +1,146 @@ +// +// ClaudeCodeCompactView.swift +// boringNotch +// +// Compact view shown in the closed notch state +// Shows: tool activity indicator, last message preview, context bar +// + +import SwiftUI + +struct ClaudeCodeCompactView: View { + @ObservedObject var manager = ClaudeCodeManager.shared + @EnvironmentObject var vm: BoringViewModel + + var body: some View { + // Black rectangle covering the notch with dots overlaid at the bottom + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width, height: vm.effectiveClosedNotchHeight) + .overlay(alignment: .bottom) { + // Session dots at the bottom of the notch area + if !manager.availableSessions.isEmpty { + SessionDotsIndicator() + .padding(.bottom, 2) + } + } + } +} + +/// Pulsing indicator shown when Claude is waiting for user permission +struct PermissionNeededIndicator: View { + let toolName: String? + @State private var isPulsing = false + + var body: some View { + ZStack { + // Outer pulsing ring + Circle() + .stroke(Color.orange.opacity(0.5), lineWidth: 2) + .frame(width: 16, height: 16) + .scaleEffect(isPulsing ? 1.3 : 1.0) + .opacity(isPulsing ? 0 : 0.8) + + // Inner solid circle + Circle() + .fill(Color.orange) + .frame(width: 10, height: 10) + + // Exclamation mark + Image(systemName: "exclamationmark") + .font(.system(size: 6, weight: .bold)) + .foregroundColor(.white) + } + .frame(width: 20, height: 20) + .onAppear { + withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: false)) { + isPulsing = true + } + } + } +} + +struct ClaudeCodeCompactViewMinimal: View { + @ObservedObject var manager = ClaudeCodeManager.shared + + var body: some View { + HStack(spacing: 6) { + // Session dots (compact version) + if !manager.availableSessions.isEmpty { + SessionDotsIndicatorCompact() + } else { + ToolActivityIndicatorCompact(isActive: manager.state.hasActiveTools) + } + + // Model badge + if !manager.state.model.isEmpty { + Text(modelShortName) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.primary.opacity(0.7)) + } + + // Context percentage + Text("\(Int(manager.state.contextPercentage))%") + .font(.system(size: 10).monospacedDigit()) + .foregroundColor(contextColor) + } + } + + private var modelShortName: String { + if manager.state.model.contains("opus") { + return "opus" + } else if manager.state.model.contains("sonnet") { + return "sonnet" + } else if manager.state.model.contains("haiku") { + return "haiku" + } + return "claude" + } + + private var contextColor: Color { + let pct = manager.state.contextPercentage + if pct > 90 { return .red } + if pct > 75 { return .orange } + return .green + } +} + +/// Compact pulsing dot for minimal view +struct PermissionNeededIndicatorCompact: View { + @State private var isPulsing = false + + var body: some View { + Circle() + .fill(Color.orange) + .frame(width: 8, height: 8) + .scaleEffect(isPulsing ? 1.2 : 0.9) + .opacity(isPulsing ? 1.0 : 0.6) + .onAppear { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + } +} + +#Preview { + VStack(spacing: 20) { + ClaudeCodeCompactView() + .frame(width: 300) + .padding() + .background(Color.black.opacity(0.8)) + .cornerRadius(8) + + ClaudeCodeCompactViewMinimal() + .padding() + .background(Color.black.opacity(0.8)) + .cornerRadius(8) + + // Preview session dots + SessionDotsIndicator() + .padding() + .background(Color.black.opacity(0.8)) + .cornerRadius(8) + } + .padding() +} diff --git a/boringNotch/components/ClaudeCode/ClaudeCodeStatsView.swift b/boringNotch/components/ClaudeCode/ClaudeCodeStatsView.swift new file mode 100644 index 00000000..b4b6c6a3 --- /dev/null +++ b/boringNotch/components/ClaudeCode/ClaudeCodeStatsView.swift @@ -0,0 +1,259 @@ +// +// ClaudeCodeStatsView.swift +// boringNotch +// +// Compact view showing Claude Code stats - designed to fit in 190px notch height +// + +import SwiftUI + +struct ClaudeCodeStatsView: View { + @ObservedObject var manager = ClaudeCodeManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Row 1: Session picker + connection status + model/branch + HStack(spacing: 6) { + SessionPicker(manager: manager) + + if manager.state.isConnected { + Circle() + .fill(Color.green) + .frame(width: 5, height: 5) + + if !manager.state.model.isEmpty { + Text(modelDisplayName) + .font(.caption2) + .foregroundColor(.secondary) + } + + if !manager.state.gitBranch.isEmpty { + HStack(spacing: 2) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 8)) + Text(manager.state.gitBranch) + .lineLimit(1) + } + .font(.caption2) + .foregroundColor(.secondary) + } + } + + Spacer() + } + + if manager.state.isConnected { + // Row 2: Context bar with token breakdown + ContextBarWithBreakdown( + percentage: manager.state.contextPercentage, + usage: manager.state.tokenUsage + ) + + // Row 3: Todo list (show up to 3) + if !manager.state.todos.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(manager.state.todos.prefix(3)) { todo in + HStack(spacing: 4) { + Image(systemName: todoIcon(for: todo.status)) + .font(.system(size: 8)) + .foregroundColor(todoColor(for: todo.status)) + Text(todo.content) + .font(.caption2) + .foregroundColor(todo.status == .completed ? .secondary.opacity(0.6) : .secondary) + .lineLimit(1) + .strikethrough(todo.status == .completed) + Spacer() + } + } + } + } + + // Row 4: Last message output + if !manager.state.lastMessage.isEmpty { + Text(manager.state.lastMessage) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Row 5: Active/recent tools + if !manager.state.activeTools.isEmpty || !manager.state.recentTools.isEmpty { + HStack(spacing: 4) { + if let activeTool = manager.state.activeTools.first { + ToolActivityIndicator(isActive: true, toolName: activeTool.toolName) + .scaleEffect(0.5) + Text(activeTool.toolName) + .font(.caption2) + .foregroundColor(.orange) + } else if let recentTool = manager.state.recentTools.first { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 8)) + .foregroundColor(.green) + Text(recentTool.toolName) + .font(.caption2) + .foregroundColor(.secondary) + if let duration = recentTool.durationMs { + Text("\(duration)ms") + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary.opacity(0.7)) + } + } + Spacer() + } + } + + } else { + // Not connected state - centered + Spacer() + HStack(spacing: 8) { + Image(systemName: "terminal") + .font(.title3) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text("No session selected") + .font(.caption) + .foregroundColor(.secondary) + + if manager.availableSessions.isEmpty { + Text("Start Claude Code to begin") + .font(.caption2) + .foregroundColor(.secondary.opacity(0.7)) + } else { + Text("\(manager.availableSessions.count) session\(manager.availableSessions.count == 1 ? "" : "s") available") + .font(.caption2) + .foregroundColor(.secondary.opacity(0.7)) + } + } + } + Spacer() + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var modelDisplayName: String { + if manager.state.model.contains("opus") { + return "Opus" + } else if manager.state.model.contains("sonnet") { + return "Sonnet" + } else if manager.state.model.contains("haiku") { + return "Haiku" + } + return "Claude" + } + + private func todoIcon(for status: ClaudeTodoItem.TodoStatus) -> String { + switch status { + case .pending: + return "circle" + case .inProgress: + return "circle.lefthalf.filled" + case .completed: + return "checkmark.circle.fill" + } + } + + private func todoColor(for status: ClaudeTodoItem.TodoStatus) -> Color { + switch status { + case .pending: + return .secondary + case .inProgress: + return .orange + case .completed: + return .green + } + } +} + +// Context bar with token breakdown +struct ContextBarWithBreakdown: View { + let percentage: Double + let usage: TokenUsage + + private var barColor: Color { + if percentage > 80 { return .red } + if percentage > 60 { return .orange } + return .green + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // Progress bar row + HStack(spacing: 6) { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.gray.opacity(0.3)) + RoundedRectangle(cornerRadius: 2) + .fill(barColor) + .frame(width: max(0, geo.size.width * min(1, percentage / 100))) + } + } + .frame(height: 6) + + Text("\(Int(percentage))%") + .font(.caption.monospacedDigit().bold()) + .foregroundColor(barColor) + .frame(width: 36, alignment: .trailing) + } + + // Token breakdown row + HStack(spacing: 12) { + TokenLabel(label: "In", value: usage.inputTokens, color: .blue) + TokenLabel(label: "Out", value: usage.outputTokens, color: .purple) + TokenLabel(label: "Cache", value: usage.cacheReadInputTokens, color: .cyan) + + Spacer() + + Text("\(formatTokens(usage.totalTokens)) / 200k") + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary) + } + } + } + + private func formatTokens(_ tokens: Int) -> String { + if tokens >= 1000 { + return "\(tokens / 1000)k" + } + return "\(tokens)" + } +} + +struct TokenLabel: View { + let label: String + let value: Int + let color: Color + + var body: some View { + HStack(spacing: 2) { + Circle() + .fill(color.opacity(0.8)) + .frame(width: 4, height: 4) + Text("\(label):") + .font(.caption2) + .foregroundColor(.secondary) + Text(formatValue(value)) + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary) + } + } + + private func formatValue(_ v: Int) -> String { + if v >= 1000 { + return "\(v / 1000)k" + } + return "\(v)" + } +} + +#Preview { + ClaudeCodeStatsView() + .background(Color.black.opacity(0.9)) + .cornerRadius(12) + .padding() +} diff --git a/boringNotch/components/ClaudeCode/ContextBar.swift b/boringNotch/components/ClaudeCode/ContextBar.swift new file mode 100644 index 00000000..681ef247 --- /dev/null +++ b/boringNotch/components/ClaudeCode/ContextBar.swift @@ -0,0 +1,108 @@ +// +// ContextBar.swift +// boringNotch +// +// Claude Code context usage progress bar +// + +import SwiftUI + +struct ContextBar: View { + let percentage: Double + var width: CGFloat = 60 + var height: CGFloat = 8 + + private var fillColor: Color { + if percentage > 90 { + return .red + } else if percentage > 75 { + return .orange + } else if percentage > 50 { + return .yellow + } else { + return .green + } + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: height / 2) + .fill(Color.gray.opacity(0.3)) + + // Fill + RoundedRectangle(cornerRadius: height / 2) + .fill(fillColor) + .frame(width: max(0, geo.size.width * min(1, percentage / 100))) + } + } + .frame(width: width, height: height) + } +} + +struct ContextBarWithLabel: View { + let percentage: Double + let tokensUsed: Int + let tokensTotal: Int + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Context") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("\(Int(percentage))%") + .font(.caption.monospacedDigit()) + .foregroundColor(.primary) + } + + ContextBar(percentage: percentage, width: .infinity, height: 6) + .frame(maxWidth: .infinity) + + HStack { + Text(formatTokens(tokensUsed)) + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary) + Text("used") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text(formatTokens(tokensTotal - tokensUsed)) + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary) + Text("left") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + private func formatTokens(_ count: Int) -> String { + if count >= 1000 { + return "\(count / 1000)k" + } + return "\(count)" + } +} + +#Preview { + VStack(spacing: 20) { + ContextBar(percentage: 25) + ContextBar(percentage: 55) + ContextBar(percentage: 80) + ContextBar(percentage: 95) + + ContextBarWithLabel( + percentage: 42, + tokensUsed: 84000, + tokensTotal: 200000 + ) + .frame(width: 200) + .padding() + .background(Color.black.opacity(0.8)) + .cornerRadius(8) + } + .padding() +} diff --git a/boringNotch/components/ClaudeCode/SessionDotsIndicator.swift b/boringNotch/components/ClaudeCode/SessionDotsIndicator.swift new file mode 100644 index 00000000..0ee05f19 --- /dev/null +++ b/boringNotch/components/ClaudeCode/SessionDotsIndicator.swift @@ -0,0 +1,179 @@ +// +// SessionDotsIndicator.swift +// boringNotch +// +// Row of dots showing all active Claude Code sessions +// Green blinking = active (thinking or running tools), Orange blinking = needs permission, Gray = idle +// + +import SwiftUI + +struct SessionDotsIndicator: View { + @ObservedObject var manager = ClaudeCodeManager.shared + + var body: some View { + HStack(spacing: 6) { + ForEach(manager.availableSessions) { session in + SessionDot( + session: session, + state: manager.sessionStates[session.id] + ) + .onTapGesture { + manager.focusIDE(for: session) + } + } + } + } +} + +struct SessionDot: View { + let session: ClaudeSession + let state: ClaudeCodeState? + + @State private var isBlinking = false + + private var dotColor: Color { + if state?.needsPermission == true { + return .orange + } else if state?.isActive == true { + return .green + } + return .gray + } + + private var shouldBlink: Bool { + state?.needsPermission == true || state?.isActive == true + } + + var body: some View { + RoundedRectangle(cornerRadius: 1) + .fill(dotColor) + .frame(width: 14, height: 3) + .opacity(shouldBlink ? (isBlinking ? 1.0 : 0.3) : 0.5) + .animation(.easeInOut(duration: 0.6), value: isBlinking) + .onAppear { + startBlinkingIfNeeded() + } + .onChange(of: shouldBlink) { _, newValue in + if newValue { + startBlinkingIfNeeded() + } else { + isBlinking = false + } + } + .onChange(of: state?.needsPermission) { _, _ in + // Reset animation when permission state changes + if shouldBlink { + isBlinking = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startBlinkingIfNeeded() + } + } + } + .help(tooltipText) + } + + private var tooltipText: String { + var text = session.displayName + if session.isTerminalSession { + text += " (Terminal)" + } else { + text += " (\(session.ideName))" + } + if state?.needsPermission == true { + text += " - Needs permission" + } else if state?.isActive == true { + text += " - Working" + } else { + text += " - Idle" + } + return text + } + + private func startBlinkingIfNeeded() { + guard shouldBlink else { return } + withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { + isBlinking = true + } + } +} + +/// Compact version showing just the dots without additional styling +struct SessionDotsIndicatorCompact: View { + @ObservedObject var manager = ClaudeCodeManager.shared + + var body: some View { + HStack(spacing: 4) { + ForEach(manager.availableSessions) { session in + SessionDotCompact( + session: session, + state: manager.sessionStates[session.id] + ) + .onTapGesture { + manager.focusIDE(for: session) + } + } + } + } +} + +struct SessionDotCompact: View { + let session: ClaudeSession + let state: ClaudeCodeState? + + @State private var isBlinking = false + + private var dotColor: Color { + if state?.needsPermission == true { + return .orange + } else if state?.isActive == true { + return .green + } + return .gray + } + + private var shouldBlink: Bool { + state?.needsPermission == true || state?.isActive == true + } + + var body: some View { + RoundedRectangle(cornerRadius: 1) + .fill(dotColor) + .frame(width: 14, height: 3) + .opacity(shouldBlink ? (isBlinking ? 1.0 : 0.3) : 0.5) + .animation(.easeInOut(duration: 0.6), value: isBlinking) + .onAppear { + if shouldBlink { + withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { + isBlinking = true + } + } + } + .onChange(of: shouldBlink) { _, newValue in + if newValue { + withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { + isBlinking = true + } + } else { + isBlinking = false + } + } + } +} + +#Preview { + VStack(spacing: 20) { + // Normal size + SessionDotsIndicator() + .padding() + .background(Color.black.opacity(0.8)) + .cornerRadius(8) + + // Compact size + SessionDotsIndicatorCompact() + .padding() + .background(Color.black.opacity(0.8)) + .cornerRadius(8) + } + .padding() +} diff --git a/boringNotch/components/ClaudeCode/SessionPicker.swift b/boringNotch/components/ClaudeCode/SessionPicker.swift new file mode 100644 index 00000000..a0f09182 --- /dev/null +++ b/boringNotch/components/ClaudeCode/SessionPicker.swift @@ -0,0 +1,123 @@ +// +// SessionPicker.swift +// boringNotch +// +// Dropdown menu for selecting which Claude Code session to monitor +// + +import SwiftUI + +struct SessionPicker: View { + @ObservedObject var manager: ClaudeCodeManager + + var body: some View { + Menu { + if manager.availableSessions.isEmpty { + Text("No active sessions") + .foregroundColor(.secondary) + } else { + ForEach(manager.availableSessions) { session in + Button(action: { manager.selectSession(session) }) { + HStack { + Image(systemName: ideIcon(for: session.ideName)) + VStack(alignment: .leading) { + Text(session.displayName) + Text(session.ideName) + .font(.caption) + .foregroundColor(.secondary) + } + if session.pid == manager.selectedSession?.pid { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } + + Divider() + + Button(action: { manager.scanForSessions() }) { + Label("Refresh Sessions", systemImage: "arrow.clockwise") + } + } label: { + HStack(spacing: 6) { + Image(systemName: "terminal.fill") + .font(.system(size: 12)) + + if let session = manager.selectedSession { + Text(session.displayName) + .lineLimit(1) + } else { + Text("Select Session") + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.down") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(6) + } + .menuStyle(.borderlessButton) + } + + private func ideIcon(for ideName: String) -> String { + switch ideName.lowercased() { + case "cursor": + return "cursorarrow.rays" + case "vscode", "visual studio code": + return "chevron.left.forwardslash.chevron.right" + case "xcode": + return "hammer.fill" + case "terminal": + return "terminal" + default: + return "laptopcomputer" + } + } +} + +struct SessionPickerCompact: View { + @ObservedObject var manager: ClaudeCodeManager + + var body: some View { + Menu { + ForEach(manager.availableSessions) { session in + Button(session.displayName) { + manager.selectSession(session) + } + } + + if manager.availableSessions.isEmpty { + Text("No sessions") + } + } label: { + HStack(spacing: 4) { + Circle() + .fill(manager.selectedSession != nil ? Color.green : Color.gray) + .frame(width: 6, height: 6) + + if let session = manager.selectedSession { + Text(session.displayName) + .font(.caption) + .lineLimit(1) + } + } + } + .menuStyle(.borderlessButton) + } +} + +#Preview { + VStack(spacing: 20) { + SessionPicker(manager: ClaudeCodeManager.shared) + SessionPickerCompact(manager: ClaudeCodeManager.shared) + } + .padding() + .frame(width: 300) + .background(Color.black.opacity(0.8)) +} diff --git a/boringNotch/components/ClaudeCode/ToolActivityIndicator.swift b/boringNotch/components/ClaudeCode/ToolActivityIndicator.swift new file mode 100644 index 00000000..0c57650e --- /dev/null +++ b/boringNotch/components/ClaudeCode/ToolActivityIndicator.swift @@ -0,0 +1,127 @@ +// +// ToolActivityIndicator.swift +// boringNotch +// +// Animated indicator showing when Claude Code tools are running +// + +import SwiftUI + +struct ToolActivityIndicator: View { + let isActive: Bool + let toolName: String? + + @State private var isAnimating = false + + var body: some View { + ZStack { + // Pulse ring when active + if isActive { + Circle() + .stroke(Color.green.opacity(0.5), lineWidth: 2) + .frame(width: 24, height: 24) + .scaleEffect(isAnimating ? 1.5 : 1.0) + .opacity(isAnimating ? 0 : 0.8) + } + + // Icon + Image(systemName: iconName) + .font(.system(size: 14)) + .foregroundColor(isActive ? .green : .gray) + .rotationEffect(.degrees(isActive && shouldRotate ? (isAnimating ? 360 : 0) : 0)) + } + .frame(width: 24, height: 24) + .onAppear { + if isActive { + startAnimation() + } + } + .onChange(of: isActive) { _, newValue in + if newValue { + startAnimation() + } else { + isAnimating = false + } + } + } + + private var iconName: String { + guard let name = toolName?.lowercased() else { + return "terminal.fill" + } + + switch name { + case "bash": + return "terminal.fill" + case "read": + return "doc.text.fill" + case "write": + return "pencil" + case "edit": + return "pencil.line" + case "glob": + return "magnifyingglass" + case "grep": + return "text.magnifyingglass" + case "task": + return "person.fill" + case "webfetch": + return "globe" + case "websearch": + return "magnifyingglass.circle" + default: + return "gearshape.fill" + } + } + + private var shouldRotate: Bool { + guard let name = toolName?.lowercased() else { return false } + return ["bash", "task", "webfetch", "websearch"].contains(name) + } + + private func startAnimation() { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + isAnimating = true + } + } +} + +struct ToolActivityIndicatorCompact: View { + let isActive: Bool + + @State private var dotIndex = 0 + private let timer = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect() + + var body: some View { + HStack(spacing: 2) { + ForEach(0..<3) { index in + Circle() + .fill(isActive ? (index == dotIndex ? Color.green : Color.green.opacity(0.3)) : Color.gray.opacity(0.3)) + .frame(width: 4, height: 4) + } + } + .onReceive(timer) { _ in + if isActive { + dotIndex = (dotIndex + 1) % 3 + } + } + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + ToolActivityIndicator(isActive: true, toolName: "Bash") + ToolActivityIndicator(isActive: true, toolName: "Read") + ToolActivityIndicator(isActive: true, toolName: "Glob") + ToolActivityIndicator(isActive: false, toolName: nil) + } + + HStack(spacing: 20) { + ToolActivityIndicatorCompact(isActive: true) + ToolActivityIndicatorCompact(isActive: false) + } + } + .padding() + .background(Color.black.opacity(0.8)) +} diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index cff23f33..cb2174fd 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -60,6 +60,9 @@ struct SettingsView: View { NavigationLink(value: "Advanced") { Label("Advanced", systemImage: "gearshape.2") } + NavigationLink(value: "ClaudeCode") { + Label("Claude Code", systemImage: "terminal") + } NavigationLink(value: "About") { Label("About", systemImage: "info.circle") } @@ -85,6 +88,8 @@ struct SettingsView: View { Charge() case "Shelf": Shelf() + case "ClaudeCode": + ClaudeCodeSettings() case "Shortcuts": Shortcuts() case "Extensions": @@ -1018,6 +1023,96 @@ struct Shelf: View { } } +struct ClaudeCodeSettings: View { + @ObservedObject var claudeCodeManager = ClaudeCodeManager.shared + @Default(.enableClaudeCode) var enableClaudeCode + + var body: some View { + Form { + Section { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Enable Claude Code integration") + .font(.headline) + Text("Show Claude Code tab in the expanded notch view.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 40) + Defaults.Toggle("", key: .enableClaudeCode) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.large) + } + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Show session dots in closed notch") + .font(.headline) + Text("Display session status dots below the notch when closed.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 40) + Defaults.Toggle("", key: .enableClaudeCodeCollapsedView) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.large) + .disabled(!enableClaudeCode) + } + } header: { + Text("General") + } footer: { + Text("Session dots show the status of active Claude Code sessions. Tap a dot to focus the corresponding IDE.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + HStack { + Text("Active sessions") + Spacer() + Text("\(claudeCodeManager.availableSessions.count)") + .foregroundStyle(.secondary) + } + + if !claudeCodeManager.availableSessions.isEmpty { + ForEach(claudeCodeManager.availableSessions) { session in + HStack { + Circle() + .fill(sessionColor(for: session)) + .frame(width: 8, height: 8) + Text(session.displayName) + Spacer() + Text(session.ideName) + .foregroundStyle(.secondary) + .font(.caption) + } + } + } + } header: { + Text("Sessions") + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Claude Code") + } + + private func sessionColor(for session: ClaudeSession) -> Color { + guard let state = claudeCodeManager.sessionStates[session.id] else { + return .gray + } + if state.needsPermission { + return .orange + } else if state.isActive { + return .green + } + return .gray + } +} + //struct Extensions: View { // @State private var effectTrigger: Bool = false // var body: some View { diff --git a/boringNotch/components/Tabs/TabSelectionView.swift b/boringNotch/components/Tabs/TabSelectionView.swift index b99d4af1..ad5c31a2 100644 --- a/boringNotch/components/Tabs/TabSelectionView.swift +++ b/boringNotch/components/Tabs/TabSelectionView.swift @@ -16,7 +16,8 @@ struct TabModel: Identifiable { let tabs = [ TabModel(label: "Home", icon: "house.fill", view: .home), - TabModel(label: "Shelf", icon: "tray.fill", view: .shelf) + TabModel(label: "Shelf", icon: "tray.fill", view: .shelf), + TabModel(label: "Claude", icon: "terminal.fill", view: .claudeCode) ] struct TabSelectionView: View { diff --git a/boringNotch/enums/generic.swift b/boringNotch/enums/generic.swift index e70b4595..b7ea2d74 100644 --- a/boringNotch/enums/generic.swift +++ b/boringNotch/enums/generic.swift @@ -27,6 +27,7 @@ public enum NotchState { public enum NotchViews { case home case shelf + case claudeCode } enum SettingsEnum { diff --git a/boringNotch/managers/ClaudeCodeManager.swift b/boringNotch/managers/ClaudeCodeManager.swift new file mode 100644 index 00000000..47d66e4e --- /dev/null +++ b/boringNotch/managers/ClaudeCodeManager.swift @@ -0,0 +1,1288 @@ +// +// ClaudeCodeManager.swift +// boringNotch +// +// Created for Claude Code Notch integration +// + +import Foundation +import Combine +import UserNotifications +import AppKit + +@MainActor +final class ClaudeCodeManager: ObservableObject { + static let shared = ClaudeCodeManager() + + // MARK: - Cached Formatters (expensive to create repeatedly) + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + // MARK: - Published Properties + + @Published private(set) var availableSessions: [ClaudeSession] = [] + @Published var selectedSession: ClaudeSession? + @Published private(set) var state: ClaudeCodeState = ClaudeCodeState() + @Published private(set) var dailyStats: DailyStats = DailyStats() + + // MARK: - Multi-Session Permission Tracking + + /// Per-session state tracking for permission detection + @Published private(set) var sessionStates: [String: ClaudeCodeState] = [:] + + /// Sessions currently waiting for user permission approval + @Published private(set) var sessionsNeedingPermission: [ClaudeSession] = [] + + /// Track when we last had activity (for grace period before notch collapses) + private var lastActivityTime: Date = Date() + /// Grace period to keep notch visible after activity stops (seconds) + private let activityGracePeriod: TimeInterval = 2.0 + + /// True if any session has activity (thinking, active tools, or needs permission) + /// Includes a grace period to prevent flickering when switching between tools + var hasAnySessionActivity: Bool { + // Check if any session is active (thinking or has active tools) or needs permission + for sessionState in sessionStates.values { + if sessionState.isActive || sessionState.needsPermission { + lastActivityTime = Date() + return true + } + } + // Also check selected session's state + if state.isActive || state.needsPermission { + lastActivityTime = Date() + return true + } + if !sessionsNeedingPermission.isEmpty { + lastActivityTime = Date() + return true + } + + // Grace period: keep showing activity for a short time after it stops + // This prevents the notch from flickering during tool transitions + let timeSinceLastActivity = Date().timeIntervalSince(lastActivityTime) + if timeSinceLastActivity < activityGracePeriod { + return true + } + + return false + } + + // MARK: - Private Properties + + // Use the real home directory, not the sandboxed container + private let claudeDir: URL = { + // Get the real home directory by reading from passwd, bypassing sandbox + if let pw = getpwuid(getuid()), let home = pw.pointee.pw_dir { + let homePath = String(cString: home) + return URL(fileURLWithPath: homePath).appendingPathComponent(".claude") + } + // Fallback to standard (will be sandboxed) + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude") + }() + private var ideDir: URL { claudeDir.appendingPathComponent("ide") } + private var projectsDir: URL { claudeDir.appendingPathComponent("projects") } + + private var sessionFileWatcher: DispatchSourceFileSystemObject? + private var ideDirWatcher: DispatchSourceFileSystemObject? + private var sessionFileHandle: FileHandle? + private var lastReadPosition: UInt64 = 0 + + private var sessionScanTimer: Timer? + + /// Timer to detect when a tool is waiting for permission (no result after delay) + private var permissionCheckTimer: Timer? + /// Tracks tool IDs that we're waiting on for permission check (for selected session - legacy) + private var pendingToolChecks: [String: Date] = [:] + /// Delay before assuming a tool needs permission (seconds) + private let permissionCheckDelay: TimeInterval = 2.5 + /// Flag to disable permission tracking during history loading + private var isLoadingHistory: Bool = false + + // MARK: - Multi-Session Watching (for permission detection across all sessions) + + /// File watchers for all active sessions (keyed by session.id) + private var sessionWatchers: [String: DispatchSourceFileSystemObject] = [:] + /// File handles for all active sessions + private var sessionFileHandles: [String: FileHandle] = [:] + /// Read positions for all active sessions + private var sessionReadPositions: [String: UInt64] = [:] + /// Pending tool checks per session: [sessionId: [toolId: startTime]] + private var pendingToolChecksBySession: [String: [String: Date]] = [:] + /// History loading flag per session + private var isLoadingHistoryBySession: [String: Bool] = [:] + /// Timer to detect idle state (no activity for a while = Claude is done) + private var idleCheckTimer: Timer? + /// Delay before assuming Claude is idle (seconds) + /// Set higher to prevent flickering between tool calls + private let idleCheckDelay: TimeInterval = 8.0 + + // MARK: - Failed Session Tracking (to prevent repeated log spam) + + /// Sessions that failed to start watching (no log file yet) + private var failedSessionIds: Set = [] + /// Timestamps of when sessions failed - retry after interval + private var failedSessionTimestamps: [String: Date] = [:] + /// Retry interval for failed sessions (seconds) + private let failedSessionRetryInterval: TimeInterval = 60 + + // MARK: - Initialization + + private init() { + setupNotifications() + startSessionScanning() + loadDailyStats() + } + + // Note: cleanup is handled by stopWatching() called manually or when app terminates + + // MARK: - Public Methods + + /// Scan for active Claude Code sessions (both IDE and terminal) + func scanForSessions() { + let fm = FileManager.default + + var sessions: [ClaudeSession] = [] + + // MARK: 1. Scan IDE sessions from lock files + if fm.fileExists(atPath: ideDir.path) { + do { + let lockFiles = try fm.contentsOfDirectory(at: ideDir, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lock" } + + for lockFile in lockFiles { + guard let data = fm.contents(atPath: lockFile.path) else { + continue + } + + do { + let lockFileData = try JSONDecoder().decode(ClaudeSessionLockFile.self, from: data) + let session = lockFileData.toSession() + + // Verify process is still running + if isProcessRunning(pid: session.pid) { + sessions.append(session) + } + } catch { + // Skip invalid lock files silently + } + } + } catch { + print("[ClaudeCode] Error scanning IDE sessions: \(error)") + } + } + + // MARK: 2. Scan terminal sessions from recent JSONL activity + let terminalSessions = scanForTerminalSessions(excludingIDEWorkspaces: sessions.compactMap { $0.workspaceFolders.first }) + sessions.append(contentsOf: terminalSessions) + + // Only log when session count changes + if sessions.count != availableSessions.count { + print("[ClaudeCode] Active sessions: \(sessions.count) (IDE: \(sessions.filter { !$0.isTerminalSession }.count), Terminal: \(sessions.filter { $0.isTerminalSession }.count))") + } + availableSessions = sessions + + // Auto-select if only one session and none selected + if selectedSession == nil && sessions.count == 1 { + selectSession(sessions[0]) + } + + // Clear selection if selected session no longer exists + if let selected = selectedSession, + !sessions.contains(where: { $0.id == selected.id }) { + selectedSession = nil + state = ClaudeCodeState() + stopWatchingSessionFile() + } + + // MARK: Multi-Session Watching - Watch ALL sessions for permission detection + let currentSessionIds = Set(sessions.map { $0.id }) + + // Start watching new sessions + for session in sessions { + if sessionWatchers[session.id] == nil { + // Skip if recently failed (retry after interval to avoid log spam) + if let failedTime = failedSessionTimestamps[session.id], + Date().timeIntervalSince(failedTime) < failedSessionRetryInterval { + continue + } + startWatchingSession(session) + } + } + + // Stop watching sessions that no longer exist + let watchedIds = Array(sessionWatchers.keys) + for watchedId in watchedIds where !currentSessionIds.contains(watchedId) { + stopWatchingSession(id: watchedId) + } + + // Clean up failed session tracking for sessions that no longer exist + failedSessionIds = failedSessionIds.intersection(currentSessionIds) + failedSessionTimestamps = failedSessionTimestamps.filter { currentSessionIds.contains($0.key) } + } + + /// Scan for terminal sessions by looking for recently modified JSONL files + /// that don't correspond to any IDE session + private func scanForTerminalSessions(excludingIDEWorkspaces ideWorkspaces: [String]) -> [ClaudeSession] { + let fm = FileManager.default + var terminalSessions: [ClaudeSession] = [] + + // Convert IDE workspaces to project keys for comparison + let ideProjectKeys = Set(ideWorkspaces.map { workspace -> String in + workspace + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ".", with: "-") + }) + + guard fm.fileExists(atPath: projectsDir.path) else { + return [] + } + + do { + let projectDirs = try fm.contentsOfDirectory(at: projectsDir, includingPropertiesForKeys: [.contentModificationDateKey]) + + // Time threshold: only consider projects with activity in last 5 minutes + let recentThreshold = Date().addingTimeInterval(-5 * 60) + + for projectDir in projectDirs { + // Skip if this is an IDE session + let projectKey = projectDir.lastPathComponent + if ideProjectKeys.contains(projectKey) { + continue + } + + // Skip hidden files/directories + if projectKey.hasPrefix(".") { + continue + } + + // Check for recent JSONL file activity in this project + guard let mostRecentFile = findMostRecentJSONLFile(in: projectDir) else { + continue + } + + // Get modification date + guard let attrs = try? fm.attributesOfItem(atPath: mostRecentFile.path), + let modDate = attrs[.modificationDate] as? Date, + modDate > recentThreshold else { + continue + } + + // Convert project key back to workspace path + // e.g., "-Users-foo-bar" -> "/Users/foo/bar" + let workspacePath = projectKey + .replacingOccurrences(of: "-", with: "/") + .replacingOccurrences(of: "//", with: "/") // Handle double-dashes if any + + // Create terminal session + let session = ClaudeSession.terminalSession(projectKey: projectKey, workspacePath: workspacePath) + terminalSessions.append(session) + } + } catch { + print("[ClaudeCode] Error scanning terminal sessions: \(error)") + } + + return terminalSessions + } + + /// Find the most recently modified JSONL file in a project directory + private func findMostRecentJSONLFile(in projectDir: URL) -> URL? { + let fm = FileManager.default + + guard let files = try? fm.contentsOfDirectory(at: projectDir, includingPropertiesForKeys: [.contentModificationDateKey]) else { + return nil + } + + let jsonlFiles = files.filter { $0.pathExtension == "jsonl" } + + // Find the most recently modified file + var mostRecent: (url: URL, date: Date)? + for file in jsonlFiles { + if let attrs = try? fm.attributesOfItem(atPath: file.path), + let modDate = attrs[.modificationDate] as? Date { + if mostRecent == nil || modDate > mostRecent!.date { + mostRecent = (file, modDate) + } + } + } + + return mostRecent?.url + } + + /// Select a session to monitor + func selectSession(_ session: ClaudeSession) { + guard session != selectedSession else { return } + + print("[ClaudeCode] Selecting session: \(session.displayName)") + selectedSession = session + state = ClaudeCodeState() + state.cwd = session.workspaceFolders.first ?? "" + + startWatchingSessionFile() + } + + /// Manually refresh state + func refresh() { + scanForSessions() + if selectedSession != nil { + readNewSessionData() + } + } + + // MARK: - Session Scanning + + private func startSessionScanning() { + // Initial scan + scanForSessions() + + // Periodic scan every 10 seconds (reduced from 5 to minimize memory pressure) + sessionScanTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.scanForSessions() + self?.loadDailyStats() + } + } + } + + private func isProcessRunning(pid: Int) -> Bool { + // Use NSRunningApplication or check /proc to avoid sandbox restrictions with kill() + // The kill() approach doesn't work in sandboxed apps + let runningApps = NSWorkspace.shared.runningApplications + if runningApps.contains(where: { $0.processIdentifier == Int32(pid) }) { + return true + } + + // Fallback: check if the process directory exists (works for any process) + let procPath = "/proc/\(pid)" + if FileManager.default.fileExists(atPath: procPath) { + return true + } + + // Another fallback: try to get process info via sysctl + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(pid)] + var info = kinfo_proc() + var size = MemoryLayout.size + let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) + + // If sysctl succeeds and returns data, process exists + return result == 0 && size > 0 + } + + // MARK: - File Watching + + private func startWatchingSessionFile() { + stopWatchingSessionFile() + + guard let session = selectedSession, + let projectKey = session.projectKey else { + print("[ClaudeCode] No session or projectKey available") + return + } + + print("[ClaudeCode] Looking for project dir with key: \(projectKey)") + let projectDir = projectsDir.appendingPathComponent(projectKey) + print("[ClaudeCode] Project dir path: \(projectDir.path)") + print("[ClaudeCode] Project dir exists: \(FileManager.default.fileExists(atPath: projectDir.path))") + + // Find the most recent JSONL file (not agent files) + guard let jsonlFile = findCurrentSessionFile(in: projectDir) else { + print("[ClaudeCode] No session file found for project: \(projectKey)") + return + } + + print("[ClaudeCode] Watching session file: \(jsonlFile.path)") + + // Open file for reading + do { + sessionFileHandle = try FileHandle(forReadingFrom: jsonlFile) + + // Seek to end to only read new content + sessionFileHandle?.seekToEndOfFile() + lastReadPosition = sessionFileHandle?.offsetInFile ?? 0 + + // But first, read recent history for initial state + loadRecentHistory(from: jsonlFile) + + } catch { + print("Error opening session file: \(error)") + return + } + + // Set up file system watcher + let fd = open(jsonlFile.path, O_EVTONLY) + guard fd >= 0 else { + print("Failed to open file descriptor for watching") + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend], + queue: .main + ) + + source.setEventHandler { [weak self] in + self?.readNewSessionData() + } + + source.setCancelHandler { + close(fd) + } + + source.resume() + sessionFileWatcher = source + state.isConnected = true + } + + private func stopWatchingSessionFile() { + sessionFileWatcher?.cancel() + sessionFileWatcher = nil + sessionFileHandle?.closeFile() + sessionFileHandle = nil + lastReadPosition = 0 + state.isConnected = false + } + + private func stopWatching() { + sessionScanTimer?.invalidate() + sessionScanTimer = nil + idleCheckTimer?.invalidate() + idleCheckTimer = nil + stopWatchingSessionFile() + ideDirWatcher?.cancel() + ideDirWatcher = nil + + // Stop all multi-session watchers + for sessionId in sessionWatchers.keys { + stopWatchingSession(id: sessionId) + } + } + + // MARK: - Multi-Session Watching (Permission Detection for All Sessions) + + /// Start watching a specific session for permission detection + private func startWatchingSession(_ session: ClaudeSession) { + guard sessionWatchers[session.id] == nil, + let projectKey = session.projectKey else { + return + } + + let projectDir = projectsDir.appendingPathComponent(projectKey) + guard let jsonlFile = findCurrentSessionFile(in: projectDir) else { + // Only log once per session, then track as failed to avoid spam + if !failedSessionIds.contains(session.id) { + print("[ClaudeCode-Multi] No session file found for: \(session.displayName)") + failedSessionIds.insert(session.id) + failedSessionTimestamps[session.id] = Date() + } + return + } + + // Session file exists - clear from failed tracking if it was there + failedSessionIds.remove(session.id) + failedSessionTimestamps.removeValue(forKey: session.id) + + print("[ClaudeCode-Multi] Starting to watch session: \(session.displayName)") + + // Initialize state for this session + var sessionState = ClaudeCodeState() + sessionState.cwd = session.workspaceFolders.first ?? "" + sessionState.isConnected = true + sessionStates[session.id] = sessionState + + // Open file handle + do { + let handle = try FileHandle(forReadingFrom: jsonlFile) + handle.seekToEndOfFile() + sessionFileHandles[session.id] = handle + sessionReadPositions[session.id] = handle.offsetInFile + + // Load recent history for initial state + loadRecentHistoryForSession(from: jsonlFile, sessionId: session.id) + + } catch { + print("[ClaudeCode-Multi] Error opening session file: \(error)") + return + } + + // Set up file system watcher + let fd = open(jsonlFile.path, O_EVTONLY) + guard fd >= 0 else { + print("[ClaudeCode-Multi] Failed to open file descriptor for watching") + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend], + queue: .main + ) + + source.setEventHandler { [weak self] in + self?.readNewSessionDataForSession(sessionId: session.id) + } + + source.setCancelHandler { + close(fd) + } + + source.resume() + sessionWatchers[session.id] = source + } + + /// Stop watching a specific session + private func stopWatchingSession(id sessionId: String) { + sessionWatchers[sessionId]?.cancel() + sessionWatchers.removeValue(forKey: sessionId) + sessionFileHandles[sessionId]?.closeFile() + sessionFileHandles.removeValue(forKey: sessionId) + sessionReadPositions.removeValue(forKey: sessionId) + sessionStates.removeValue(forKey: sessionId) + pendingToolChecksBySession.removeValue(forKey: sessionId) + isLoadingHistoryBySession.removeValue(forKey: sessionId) + + // Update sessionsNeedingPermission + sessionsNeedingPermission.removeAll { $0.id == sessionId } + + print("[ClaudeCode-Multi] Stopped watching session: \(sessionId)") + } + + /// Load recent history for a specific session + private func loadRecentHistoryForSession(from file: URL, sessionId: String) { + // Read only the last ~50KB of the file to get recent lines (avoids loading huge files into memory) + let maxBytesToRead: UInt64 = 50 * 1024 // 50KB should be plenty for last 50 lines + + guard let handle = try? FileHandle(forReadingFrom: file) else { + return + } + defer { try? handle.close() } + + // Seek to near the end of the file + let fileSize = handle.seekToEndOfFile() + let startPosition = fileSize > maxBytesToRead ? fileSize - maxBytesToRead : 0 + handle.seek(toFileOffset: startPosition) + + guard let data = try? handle.readToEnd(), + let content = String(data: data, encoding: .utf8) else { + return + } + + let lines = content.components(separatedBy: .newlines) + // Skip first line if we started mid-file (it might be truncated) + let linesToProcess = startPosition > 0 ? Array(lines.dropFirst().suffix(50)) : Array(lines.suffix(50)) + + // Disable permission tracking during history loading + isLoadingHistoryBySession[sessionId] = true + for line in linesToProcess where !line.isEmpty { + parseJSONLLineForSession(line, sessionId: sessionId) + } + isLoadingHistoryBySession[sessionId] = false + + // Clear any active state from history - they're already done + sessionStates[sessionId]?.activeTools.removeAll() + sessionStates[sessionId]?.isThinking = false + pendingToolChecksBySession[sessionId]?.removeAll() + } + + /// Read new data for a specific session + private func readNewSessionDataForSession(sessionId: String) { + guard let handle = sessionFileHandles[sessionId], + let lastPosition = sessionReadPositions[sessionId] else { return } + + handle.seek(toFileOffset: lastPosition) + let newData = handle.readDataToEndOfFile() + sessionReadPositions[sessionId] = handle.offsetInFile + + guard !newData.isEmpty, + let content = String(data: newData, encoding: .utf8) else { return } + + // Any file activity means Claude is working (including compacting/summarizing) + // Set isThinking immediately when we detect new data being written + sessionStates[sessionId]?.isThinking = true + + let lines = content.components(separatedBy: .newlines) + for line in lines where !line.isEmpty { + parseJSONLLineForSession(line, sessionId: sessionId) + } + + sessionStates[sessionId]?.lastUpdateTime = Date() + + // Reset idle timer - we just got activity + resetIdleTimer() + } + + /// Reset the idle detection timer + private func resetIdleTimer() { + idleCheckTimer?.invalidate() + idleCheckTimer = Timer.scheduledTimer(withTimeInterval: idleCheckDelay, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.markAllSessionsIdle() + } + } + } + + /// Mark all sessions as idle (no activity for a while) + private func markAllSessionsIdle() { + for sessionId in sessionStates.keys { + // Only mark idle if not waiting for permission + if sessionStates[sessionId]?.needsPermission != true { + sessionStates[sessionId]?.isThinking = false + } + } + // Also mark selected session as idle + if !state.needsPermission { + state.isThinking = false + } + } + + /// Parse a JSONL line for a specific session (focused on permission detection) + private func parseJSONLLineForSession(_ line: String, sessionId: String) { + guard let data = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + + // Parse message content for tool detection + if let message = json["message"] as? [String: Any] { + parseMessageForSession(message, sessionId: sessionId) + } + } + + /// Parse message content for a specific session + private func parseMessageForSession(_ message: [String: Any], sessionId: String) { + // Extract model + if let model = message["model"] as? String { + sessionStates[sessionId]?.model = model + } + + // Track thinking state based on message role + // Key insight: Claude logs messages AFTER they're complete + // - User message logged = Claude is about to think/respond + // - Assistant message logged = Claude finished responding (idle timer will mark idle) + // - Tool_result = Claude will continue thinking after tool executes + if let role = message["role"] as? String { + if role == "user" { + // User message logged - Claude is about to respond + // Check if this is a tool_result (continues thinking) or new prompt (starts thinking) + var hasToolResult = false + if let content = message["content"] as? [[String: Any]] { + hasToolResult = content.contains { ($0["type"] as? String) == "tool_result" } + } + // Either way, Claude is now thinking (responding to user or continuing after tool) + sessionStates[sessionId]?.isThinking = true + } else if role == "assistant" { + // Assistant message logged = Claude finished this response + // Keep isThinking true - the idle timer will set it to false after delay + // This prevents the dot from flickering between responses + sessionStates[sessionId]?.isThinking = true + } + } + + // Extract message content for tool_use detection + if let content = message["content"] as? [[String: Any]] { + for item in content { + if let type = item["type"] as? String { + switch type { + case "tool_use": + if let toolId = item["id"] as? String, + let toolName = item["name"] as? String { + let tool = ToolExecution( + id: toolId, + toolName: toolName, + argument: extractToolArgument(from: item["input"]), + startTime: Date() + ) + if sessionStates[sessionId]?.activeTools.contains(where: { $0.id == toolId }) != true { + sessionStates[sessionId]?.activeTools.append(tool) + startPermissionCheckForSession(sessionId: sessionId, toolId: toolId, toolName: toolName) + } + } + + default: + break + } + } + } + } + + // Check for tool_result in user messages to mark tools as complete + if let role = message["role"] as? String, role == "user", + let content = message["content"] as? [[String: Any]] { + for item in content { + if let type = item["type"] as? String, type == "tool_result", + let toolUseId = item["tool_use_id"] as? String { + clearPermissionCheckForSession(sessionId: sessionId, toolId: toolUseId) + + // Mark tool as complete + if let index = sessionStates[sessionId]?.activeTools.firstIndex(where: { $0.id == toolUseId }) { + sessionStates[sessionId]?.activeTools.remove(at: index) + } + + // IMPORTANT: Set isThinking=true immediately after tool completion + // Claude will always respond after receiving a tool result, so we stay active + sessionStates[sessionId]?.isThinking = true + } + } + } + } + + /// Start tracking a tool for permission check in a specific session + private func startPermissionCheckForSession(sessionId: String, toolId: String, toolName: String) { + guard isLoadingHistoryBySession[sessionId] != true else { return } + + pendingToolChecksBySession[sessionId, default: [:]][toolId] = Date() + + // Start or restart the permission check timer + permissionCheckTimer?.invalidate() + permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: permissionCheckDelay, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.checkPendingPermissionsForAllSessions() + } + } + } + + /// Clear permission tracking for a tool in a specific session + private func clearPermissionCheckForSession(sessionId: String, toolId: String) { + pendingToolChecksBySession[sessionId]?.removeValue(forKey: toolId) + + // If this session was showing permission needed, clear it + if sessionStates[sessionId]?.needsPermission == true { + if pendingToolChecksBySession[sessionId]?.isEmpty ?? true { + sessionStates[sessionId]?.needsPermission = false + sessionStates[sessionId]?.pendingPermissionTool = nil + } + } + + // Update sessionsNeedingPermission + updateSessionsNeedingPermission() + } + + /// Check all sessions for pending permissions + private func checkPendingPermissionsForAllSessions() { + let now = Date() + + for (sessionId, toolChecks) in pendingToolChecksBySession { + for (toolId, startTime) in toolChecks { + let elapsed = now.timeIntervalSince(startTime) + if elapsed >= permissionCheckDelay { + // This tool has been pending too long - likely needs permission + if let tool = sessionStates[sessionId]?.activeTools.first(where: { $0.id == toolId }) { + sessionStates[sessionId]?.needsPermission = true + sessionStates[sessionId]?.pendingPermissionTool = tool.toolName + break + } + } + } + } + + updateSessionsNeedingPermission() + + // Stop timer if no more pending tools across all sessions + let hasPendingTools = pendingToolChecksBySession.values.contains { !$0.isEmpty } + if !hasPendingTools { + permissionCheckTimer?.invalidate() + permissionCheckTimer = nil + } + } + + /// Update the sessionsNeedingPermission array based on current state + private func updateSessionsNeedingPermission() { + var needingPermission: [ClaudeSession] = [] + + for session in availableSessions { + if sessionStates[session.id]?.needsPermission == true { + needingPermission.append(session) + } + } + + // Also check the selected session's state + if state.needsPermission, let selected = selectedSession { + if !needingPermission.contains(where: { $0.id == selected.id }) { + needingPermission.append(selected) + } + } + + sessionsNeedingPermission = needingPermission + } + + private func findCurrentSessionFile(in projectDir: URL) -> URL? { + let fm = FileManager.default + + guard fm.fileExists(atPath: projectDir.path) else { return nil } + + do { + let files = try fm.contentsOfDirectory(at: projectDir, includingPropertiesForKeys: [.contentModificationDateKey]) + .filter { $0.pathExtension == "jsonl" && !$0.lastPathComponent.hasPrefix("agent-") } + .sorted { url1, url2 in + let date1 = (try? url1.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + let date2 = (try? url2.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + return date1 > date2 + } + + return files.first + } catch { + print("Error finding session file: \(error)") + return nil + } + } + + // MARK: - Data Reading + + private func loadRecentHistory(from file: URL) { + // Read only the last ~50KB of the file to get recent lines (avoids loading huge files into memory) + let maxBytesToRead: UInt64 = 50 * 1024 // 50KB should be plenty for last 50 lines + + guard let handle = try? FileHandle(forReadingFrom: file) else { + return + } + defer { try? handle.close() } + + // Seek to near the end of the file + let fileSize = handle.seekToEndOfFile() + let startPosition = fileSize > maxBytesToRead ? fileSize - maxBytesToRead : 0 + handle.seek(toFileOffset: startPosition) + + guard let data = try? handle.readToEnd(), + let content = String(data: data, encoding: .utf8) else { + return + } + + let lines = content.components(separatedBy: .newlines) + // Skip first line if we started mid-file (it might be truncated) + let linesToProcess = startPosition > 0 ? Array(lines.dropFirst().suffix(50)) : Array(lines.suffix(50)) + + // Disable permission tracking during history loading - these are already completed tools + isLoadingHistory = true + for line in linesToProcess where !line.isEmpty { + parseJSONLLine(line) + } + isLoadingHistory = false + + // Clear any active state from history - they're already done + state.activeTools.removeAll() + state.isThinking = false + pendingToolChecks.removeAll() + + state.lastUpdateTime = Date() + } + + private func readNewSessionData() { + guard let handle = sessionFileHandle else { return } + + // Read new data from last position + handle.seek(toFileOffset: lastReadPosition) + let newData = handle.readDataToEndOfFile() + lastReadPosition = handle.offsetInFile + + guard !newData.isEmpty, + let content = String(data: newData, encoding: .utf8) else { return } + + // Any file activity means Claude is working (including compacting/summarizing) + // Set isThinking immediately when we detect new data being written + state.isThinking = true + + let lines = content.components(separatedBy: .newlines) + for line in lines where !line.isEmpty { + parseJSONLLine(line) + } + + state.lastUpdateTime = Date() + + // Reset idle timer - we just got activity + resetIdleTimer() + } + + // MARK: - JSONL Parsing + + private func parseJSONLLine(_ line: String) { + guard let data = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + + // Extract session info + if let sessionId = json["sessionId"] as? String { + state.sessionId = sessionId + } + if let cwd = json["cwd"] as? String { + state.cwd = cwd + } + if let gitBranch = json["gitBranch"] as? String { + state.gitBranch = gitBranch + } + + // Parse message content + if let message = json["message"] as? [String: Any] { + parseMessage(message) + } + + // Parse tool use results + if json["toolUseResult"] != nil { + // Tool completed - could track timing here + } + } + + private func parseMessage(_ message: [String: Any]) { + // Extract model + if let model = message["model"] as? String { + state.model = model + } + + // Track thinking state based on message role + // Key insight: Claude logs messages AFTER they're complete + // - User message logged = Claude is about to think/respond + // - Assistant message logged = Claude finished responding (idle timer will mark idle) + if let role = message["role"] as? String { + if role == "user" { + // User message logged - Claude is about to respond + state.isThinking = true + } else if role == "assistant" { + // Assistant message logged = Claude finished this response + // Keep isThinking true - the idle timer will set it to false after delay + state.isThinking = true + } + } + + // Extract token usage + if let usage = message["usage"] as? [String: Any] { + state.tokenUsage.inputTokens = usage["input_tokens"] as? Int ?? state.tokenUsage.inputTokens + state.tokenUsage.outputTokens = usage["output_tokens"] as? Int ?? state.tokenUsage.outputTokens + state.tokenUsage.cacheReadInputTokens = usage["cache_read_input_tokens"] as? Int ?? state.tokenUsage.cacheReadInputTokens + state.tokenUsage.cacheCreationInputTokens = usage["cache_creation_input_tokens"] as? Int ?? state.tokenUsage.cacheCreationInputTokens + } + + // Extract message content + if let content = message["content"] as? [[String: Any]] { + for item in content { + if let type = item["type"] as? String { + switch type { + case "text": + if let text = item["text"] as? String { + // Get first line or first 100 chars as preview + let preview = text.components(separatedBy: .newlines).first ?? text + state.lastMessage = String(preview.prefix(100)) + state.lastMessageTime = Date() + } + + case "tool_use": + if let toolId = item["id"] as? String, + let toolName = item["name"] as? String { + + // Parse TodoWrite tool to extract todos + if toolName == "TodoWrite", + let input = item["input"] as? [String: Any], + let todos = input["todos"] as? [[String: Any]] { + parseTodos(todos) + } + + let tool = ToolExecution( + id: toolId, + toolName: toolName, + argument: extractToolArgument(from: item["input"]), + startTime: Date() + ) + // Add to active tools + if !state.activeTools.contains(where: { $0.id == toolId }) { + state.activeTools.append(tool) + // Start tracking this tool for permission check + startPermissionCheck(toolId: toolId, toolName: toolName) + } + } + + default: + break + } + } + } + } + + // Check for tool_result in user messages to mark tools as complete + if let role = message["role"] as? String, role == "user", + let content = message["content"] as? [[String: Any]] { + for item in content { + if let type = item["type"] as? String, type == "tool_result", + let toolUseId = item["tool_use_id"] as? String { + // Clear permission tracking for this tool + clearPermissionCheck(toolId: toolUseId) + + // Mark tool as complete + if let index = state.activeTools.firstIndex(where: { $0.id == toolUseId }) { + var tool = state.activeTools.remove(at: index) + tool.endTime = Date() + state.recentTools.insert(tool, at: 0) + // Keep only last 10 recent tools + if state.recentTools.count > 10 { + state.recentTools.removeLast() + } + } + + // IMPORTANT: Set isThinking=true immediately after tool completion + // Claude will always respond after receiving a tool result, so we stay active + state.isThinking = true + } + } + } + } + + private func extractToolArgument(from input: Any?) -> String? { + guard let input = input as? [String: Any] else { return nil } + + // Common argument names + if let pattern = input["pattern"] as? String { return pattern } + if let command = input["command"] as? String { return String(command.prefix(50)) } + if let filePath = input["file_path"] as? String { return URL(fileURLWithPath: filePath).lastPathComponent } + if let query = input["query"] as? String { return String(query.prefix(50)) } + if let prompt = input["prompt"] as? String { return String(prompt.prefix(50)) } + + return nil + } + + private func parseTodos(_ todosArray: [[String: Any]]) { + var newTodos: [ClaudeTodoItem] = [] + + for todoDict in todosArray { + guard let content = todoDict["content"] as? String, + let statusStr = todoDict["status"] as? String else { + continue + } + + let status: ClaudeTodoItem.TodoStatus + switch statusStr { + case "pending": + status = .pending + case "in_progress": + status = .inProgress + case "completed": + status = .completed + default: + status = .pending + } + + newTodos.append(ClaudeTodoItem(content: content, status: status)) + } + + // Replace the entire todo list (TodoWrite always sends the complete list) + state.todos = newTodos + } + + // MARK: - Permission Detection + + /// Start tracking a tool to check if it needs permission + private func startPermissionCheck(toolId: String, toolName: String) { + // Don't track permission during history loading - those tools are already completed + guard !isLoadingHistory else { + return + } + + pendingToolChecks[toolId] = Date() + + // Start or restart the permission check timer + permissionCheckTimer?.invalidate() + permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: permissionCheckDelay, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.checkPendingPermissions() + } + } + } + + /// Clear permission tracking for a tool (when it completes) + private func clearPermissionCheck(toolId: String) { + pendingToolChecks.removeValue(forKey: toolId) + + // If we were showing permission needed for this tool, clear it + if state.needsPermission { + // Check if any other tools still need permission + if pendingToolChecks.isEmpty { + state.needsPermission = false + state.pendingPermissionTool = nil + permissionCheckTimer?.invalidate() + permissionCheckTimer = nil + } else { + // Re-check if any remaining tools need permission + checkPendingPermissions() + } + } + + // Stop timer if no more pending tools + if pendingToolChecks.isEmpty { + permissionCheckTimer?.invalidate() + permissionCheckTimer = nil + } + } + + /// Check if any pending tools have exceeded the delay (likely waiting for permission) + private func checkPendingPermissions() { + let now = Date() + + for (toolId, startTime) in pendingToolChecks { + let elapsed = now.timeIntervalSince(startTime) + if elapsed >= permissionCheckDelay { + // This tool has been pending too long - likely needs permission + if let tool = state.activeTools.first(where: { $0.id == toolId }) { + if !state.needsPermission { + print("[ClaudeCode] ⚠️ Tool '\(tool.toolName)' waiting for permission") + } + state.needsPermission = true + state.pendingPermissionTool = tool.toolName + return + } else { + // Tool not in activeTools - still show permission indicator + if !state.needsPermission { + state.needsPermission = true + state.pendingPermissionTool = "Tool" + } + return + } + } + } + } + + // MARK: - IDE Focus + + /// Bring the IDE or terminal running Claude Code to the front + /// - Parameter session: The session to focus. If nil, focuses the selected session. + func focusIDE(for session: ClaudeSession? = nil) { + guard let targetSession = session ?? selectedSession else { + print("[ClaudeCode] No session to focus") + return + } + + let ideName = targetSession.ideName.lowercased() + print("[ClaudeCode] Attempting to focus: \(targetSession.ideName) (terminal: \(targetSession.isTerminalSession))") + + // Map common IDE/terminal names to bundle identifiers + let bundleIdentifiers: [String] = { + if targetSession.isTerminalSession { + // Try common terminal apps - Warp, iTerm2, Terminal + return [ + "dev.warp.Warp-Stable", // Warp + "com.googlecode.iterm2", // iTerm2 + "com.apple.Terminal" // Apple Terminal + ] + } else if ideName.contains("cursor") { + return ["com.todesktop.230313mzl4w4u92"] + } else if ideName.contains("code") || ideName.contains("vscode") { + return ["com.microsoft.VSCode", "com.visualstudio.code.oss"] + } else if ideName.contains("windsurf") { + return ["com.codeium.windsurf"] + } else if ideName.contains("zed") { + return ["dev.zed.Zed"] + } else { + // Try to find by process ID as fallback + return [] + } + }() + + // Try to activate by bundle identifier first + for bundleId in bundleIdentifiers { + if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId).first { + print("[ClaudeCode] Found app by bundle ID: \(bundleId)") + app.activate(options: [.activateIgnoringOtherApps]) + return + } + } + + // Fallback: find by PID (only works for IDE sessions) + if !targetSession.isTerminalSession { + let runningApps = NSWorkspace.shared.runningApplications + if let app = runningApps.first(where: { $0.processIdentifier == Int32(targetSession.pid) }) { + print("[ClaudeCode] Found app by PID: \(targetSession.pid)") + app.activate(options: [.activateIgnoringOtherApps]) + return + } + + // Last resort: try to find any app with matching name + if let app = runningApps.first(where: { + $0.localizedName?.lowercased().contains(ideName) == true + }) { + print("[ClaudeCode] Found app by name match: \(app.localizedName ?? "unknown")") + app.activate(options: [.activateIgnoringOtherApps]) + return + } + } + + print("[ClaudeCode] Could not find app to focus") + } + + // MARK: - Notifications + + private func setupNotifications() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + func notifyAgentCompletion(agent: AgentInfo) { + let content = UNMutableNotificationContent() + content.title = "Agent Completed" + content.body = "\(agent.name): \(agent.description)" + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + + // MARK: - Daily Stats + + /// Load daily stats from ~/.claude/stats-cache.json + func loadDailyStats() { + let statsFile = claudeDir.appendingPathComponent("stats-cache.json") + + guard FileManager.default.fileExists(atPath: statsFile.path), + let data = FileManager.default.contents(atPath: statsFile.path) else { + return + } + + do { + let cache = try JSONDecoder().decode(StatsCache.self, from: data) + + // Get today's date in the format used by the cache (YYYY-MM-DD) + let today = Self.dateFormatter.string(from: Date()) + + var stats = DailyStats() + + // Try to find today's activity first, otherwise get the most recent + let sortedActivity = cache.dailyActivity?.sorted { $0.date > $1.date } + if let todayActivity = sortedActivity?.first(where: { $0.date == today }) { + stats.date = today + stats.messageCount = todayActivity.messageCount ?? 0 + stats.toolCallCount = todayActivity.toolCallCount ?? 0 + stats.sessionCount = todayActivity.sessionCount ?? 0 + } else if let latestActivity = sortedActivity?.first { + // Use most recent day's stats + stats.date = latestActivity.date + stats.messageCount = latestActivity.messageCount ?? 0 + stats.toolCallCount = latestActivity.toolCallCount ?? 0 + stats.sessionCount = latestActivity.sessionCount ?? 0 + } + + // Try to find today's token usage first, otherwise get the most recent + let sortedTokens = cache.dailyModelTokens?.sorted { $0.date > $1.date } + let targetDate = stats.date.isEmpty ? today : stats.date + if let dayTokens = sortedTokens?.first(where: { $0.date == targetDate }), + let tokensByModel = dayTokens.tokensByModel { + stats.tokensUsed = tokensByModel.values.reduce(0, +) + } else if let latestTokens = sortedTokens?.first, + let tokensByModel = latestTokens.tokensByModel { + stats.tokensUsed = tokensByModel.values.reduce(0, +) + if stats.date.isEmpty { + stats.date = latestTokens.date + } + } + + // Only update and log if stats changed + if stats != dailyStats { + dailyStats = stats + } + + } catch { + print("[ClaudeCode] Error parsing stats-cache.json: \(error)") + } + } +} diff --git a/boringNotch/models/BoringViewModel.swift b/boringNotch/models/BoringViewModel.swift index 25390e76..c3d36206 100644 --- a/boringNotch/models/BoringViewModel.swift +++ b/boringNotch/models/BoringViewModel.swift @@ -192,7 +192,7 @@ class BoringViewModel: NSObject, ObservableObject { func open() { self.notchSize = openNotchSize self.notchState = .open - + // Force music information update when notch is opened MusicManager.shared.forceUpdate() } @@ -210,11 +210,11 @@ class BoringViewModel: NSObject, ObservableObject { self.edgeAutoOpenActive = false // Set the current view to shelf if it contains files and the user enables openShelfByDefault - // Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home + // Otherwise, if the user has not enabled openLastShelfByDefault, set the view to Claude Code if !ShelfStateViewModel.shared.isEmpty && Defaults[.openShelfByDefault] { coordinator.currentView = .shelf } else if !coordinator.openLastTabByDefault { - coordinator.currentView = .home + coordinator.currentView = .claudeCode } } diff --git a/boringNotch/models/ClaudeCodeModels.swift b/boringNotch/models/ClaudeCodeModels.swift new file mode 100644 index 00000000..7a703395 --- /dev/null +++ b/boringNotch/models/ClaudeCodeModels.swift @@ -0,0 +1,316 @@ +// +// ClaudeCodeModels.swift +// boringNotch +// +// Created for Claude Code Notch integration +// + +import Foundation + +// MARK: - Session Discovery + +/// Represents an active Claude Code session (IDE or terminal) +/// IDE sessions come from ~/.claude/ide/*.lock files +/// Terminal sessions are detected from recent JSONL activity in ~/.claude/projects/ +struct ClaudeSession: Identifiable, Equatable { + // Use workspace path as unique ID since multiple sessions can share the same PID (Cursor) + var id: String { workspaceFolders.first ?? "\(pid)" } + + let pid: Int + let workspaceFolders: [String] + let ideName: String + let transport: String? + let runningInWindows: Bool? + + /// True if this is a terminal session (detected from JSONL activity, no lock file) + let isTerminalSession: Bool + + /// For terminal sessions: the project directory key (e.g., "-Users-foo-bar") + let terminalProjectKey: String? + + /// Derived from workspace path for project JSONL lookup + var projectKey: String? { + // Terminal sessions already have the project key + if let terminalKey = terminalProjectKey { + return terminalKey + } + guard let workspace = workspaceFolders.first else { return nil } + // Convert /Users/foo/bar.baz to -Users-foo-bar-baz + // Claude Code keeps the leading dash, so we only trim trailing dashes + return workspace + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ".", with: "-") + } + + /// Display name for UI (last folder component) + var displayName: String { + guard let workspace = workspaceFolders.first else { return "Unknown" } + return URL(fileURLWithPath: workspace).lastPathComponent + } + + /// Create an IDE session from lock file data + init(pid: Int, workspaceFolders: [String], ideName: String, transport: String?, runningInWindows: Bool?) { + self.pid = pid + self.workspaceFolders = workspaceFolders + self.ideName = ideName + self.transport = transport + self.runningInWindows = runningInWindows + self.isTerminalSession = false + self.terminalProjectKey = nil + } + + /// Create a terminal session from project directory activity + static func terminalSession(projectKey: String, workspacePath: String) -> ClaudeSession { + ClaudeSession( + pid: 0, // No PID for terminal sessions + workspaceFolders: [workspacePath], + ideName: "Terminal", + transport: nil, + runningInWindows: nil, + isTerminalSession: true, + terminalProjectKey: projectKey + ) + } + + /// Internal initializer for terminal sessions + private init(pid: Int, workspaceFolders: [String], ideName: String, transport: String?, runningInWindows: Bool?, isTerminalSession: Bool, terminalProjectKey: String?) { + self.pid = pid + self.workspaceFolders = workspaceFolders + self.ideName = ideName + self.transport = transport + self.runningInWindows = runningInWindows + self.isTerminalSession = isTerminalSession + self.terminalProjectKey = terminalProjectKey + } +} + +/// Codable wrapper for decoding IDE lock files +struct ClaudeSessionLockFile: Codable { + let pid: Int + let workspaceFolders: [String] + let ideName: String + let transport: String? + let runningInWindows: Bool? + + func toSession() -> ClaudeSession { + ClaudeSession(pid: pid, workspaceFolders: workspaceFolders, ideName: ideName, transport: transport, runningInWindows: runningInWindows) + } +} + +// MARK: - Token Usage + +/// Token usage data from JSONL message.usage field +struct TokenUsage: Equatable { + var inputTokens: Int = 0 + var outputTokens: Int = 0 + var cacheReadInputTokens: Int = 0 + var cacheCreationInputTokens: Int = 0 + + var totalTokens: Int { + inputTokens + outputTokens + cacheReadInputTokens + cacheCreationInputTokens + } + + /// Context window is 200k for opus-4-5 + static let contextWindow = 200_000 + + var contextPercentage: Double { + guard Self.contextWindow > 0 else { return 0 } + return min(100, Double(totalTokens) / Double(Self.contextWindow) * 100) + } + + // MARK: - Cost Estimation (per 1M tokens, USD) + // Opus 4.5 pricing: $15/1M input, $75/1M output, $1.50/1M cache read, $18.75/1M cache write + // Sonnet 4: $3/1M input, $15/1M output, $0.30/1M cache read, $3.75/1M cache write + + struct ModelPricing { + let inputPerMillion: Double + let outputPerMillion: Double + let cacheReadPerMillion: Double + let cacheWritePerMillion: Double + } + + static let opusPricing = ModelPricing( + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheReadPerMillion: 1.50, + cacheWritePerMillion: 18.75 + ) + + static let sonnetPricing = ModelPricing( + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheReadPerMillion: 0.30, + cacheWritePerMillion: 3.75 + ) + + /// Calculate estimated cost for this session + func estimatedCost(model: String) -> Double { + let pricing = model.contains("opus") ? Self.opusPricing : Self.sonnetPricing + + let inputCost = Double(inputTokens) / 1_000_000 * pricing.inputPerMillion + let outputCost = Double(outputTokens) / 1_000_000 * pricing.outputPerMillion + let cacheReadCost = Double(cacheReadInputTokens) / 1_000_000 * pricing.cacheReadPerMillion + let cacheWriteCost = Double(cacheCreationInputTokens) / 1_000_000 * pricing.cacheWritePerMillion + + return inputCost + outputCost + cacheReadCost + cacheWriteCost + } +} + +// MARK: - Tool Execution + +/// Represents a tool call in progress or completed +struct ToolExecution: Identifiable, Equatable { + let id: String + let toolName: String + let argument: String? + let startTime: Date + var endTime: Date? + var isRunning: Bool { endTime == nil } + + var durationMs: Int? { + guard let end = endTime else { return nil } + return Int(end.timeIntervalSince(startTime) * 1000) + } +} + +// MARK: - Agent Info + +/// Represents a background agent task +struct AgentInfo: Identifiable, Equatable { + let id: String + let name: String + let description: String + let startTime: Date + var isActive: Bool = true + + var durationSeconds: Int { + Int(Date().timeIntervalSince(startTime)) + } +} + +// MARK: - Todo Item + +/// Claude Code todo item +struct ClaudeTodoItem: Identifiable, Equatable { + let id = UUID() + let content: String + let status: TodoStatus + + enum TodoStatus: String { + case pending + case inProgress = "in_progress" + case completed + } +} + +// MARK: - Complete State + +/// Complete Claude Code state for display +struct ClaudeCodeState: Equatable { + var sessionId: String = "" + var model: String = "" + var cwd: String = "" + var gitBranch: String = "" + + var tokenUsage: TokenUsage = TokenUsage() + + var lastMessage: String = "" + var lastMessageTime: Date? + + var activeTools: [ToolExecution] = [] + var recentTools: [ToolExecution] = [] + + var agents: [AgentInfo] = [] + var todos: [ClaudeTodoItem] = [] + + var isConnected: Bool = false + var lastUpdateTime: Date? + + /// True when Claude is waiting for user permission to execute a tool + var needsPermission: Bool = false + /// The tool waiting for permission (if any) + var pendingPermissionTool: String? + + /// True when Claude is actively generating a response (thinking) + var isThinking: Bool = false + + // Convenience accessors + var contextPercentage: Double { tokenUsage.contextPercentage } + var hasActiveTools: Bool { !activeTools.isEmpty } + var currentToolName: String? { activeTools.first?.toolName } + + /// True when the session is actively processing (thinking or running tools) + var isActive: Bool { isThinking || hasActiveTools } +} + +// MARK: - Daily Stats (from stats-cache.json) + +/// Daily activity stats from ~/.claude/stats-cache.json +struct DailyStats: Equatable { + var messageCount: Int = 0 + var toolCallCount: Int = 0 + var sessionCount: Int = 0 + var tokensUsed: Int = 0 + var date: String = "" + + var isEmpty: Bool { + // Only empty if date is not set (means we haven't loaded stats yet) + date.isEmpty + } +} + +/// Stats cache structure matching ~/.claude/stats-cache.json +struct StatsCache: Codable { + let dailyActivity: [DailyActivity]? + let dailyModelTokens: [DailyModelTokens]? + let modelUsage: [String: ModelUsageStats]? + let totalSessions: Int? + let totalMessages: Int? + + struct DailyActivity: Codable { + let date: String + let messageCount: Int? + let sessionCount: Int? + let toolCallCount: Int? + } + + struct DailyModelTokens: Codable { + let date: String + let tokensByModel: [String: Int]? + } + + struct ModelUsageStats: Codable { + let inputTokens: Int? + let outputTokens: Int? + let cacheReadInputTokens: Int? + let cacheCreationInputTokens: Int? + } +} + +// MARK: - JSONL Parsing Helpers + +/// Represents a parsed JSONL line from session log +struct SessionLogEntry { + let type: String + let sessionId: String? + let model: String? + let cwd: String? + let gitBranch: String? + let usage: TokenUsage? + let messageContent: String? + let toolUse: ToolUseInfo? + let toolResult: ToolResultInfo? + let timestamp: Date? +} + +struct ToolUseInfo { + let id: String + let name: String + let input: [String: Any]? +} + +struct ToolResultInfo { + let toolUseId: String + let content: String? + let isError: Bool +} diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 8477434c..cb09f8b3 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -183,6 +183,10 @@ extension Defaults.Keys { // MARK: Media Controller static let mediaController = Key("mediaController", default: defaultMediaController) + // MARK: Claude Code + static let enableClaudeCode = Key("enableClaudeCode", default: true) + static let enableClaudeCodeCollapsedView = Key("enableClaudeCodeCollapsedView", default: true) + // MARK: Advanced Settings static let useCustomAccentColor = Key("useCustomAccentColor", default: false) static let customAccentColorData = Key("customAccentColorData", default: nil)