diff --git a/NotchIA.xcodeproj/project.pbxproj b/NotchIA.xcodeproj/project.pbxproj index 3a5eece..f5c23f4 100644 --- a/NotchIA.xcodeproj/project.pbxproj +++ b/NotchIA.xcodeproj/project.pbxproj @@ -122,7 +122,6 @@ AA3300012F2100010000BBBB /* ActiveCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3300022F2100010000BBBB /* ActiveCallManager.swift */; }; AA3300032F2100010000BBBB /* FocusModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3300042F2100010000BBBB /* FocusModeManager.swift */; }; AA3300072F2100020000BBBB /* ActiveCallPulseDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3300062F2100020000BBBB /* ActiveCallPulseDot.swift */; }; - AA4400012F2200010000CCCC /* CopilotManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4400022F2200010000CCCC /* CopilotManager.swift */; }; AIAURA00000000000000ABCD /* AppleIntelligenceAura.swift in Sources */ = {isa = PBXBuildFile; fileRef = AIAURA00000000000000ABCE /* AppleIntelligenceAura.swift */; }; AIRPDS00000000000000ABCD /* AirPodsBatteryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AIRPDS00000000000000ABCE /* AirPodsBatteryManager.swift */; }; ALBCAC00000000000000ABCD /* AlbumArtCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = ALBCAC00000000000000ABCE /* AlbumArtCache.swift */; }; @@ -339,7 +338,6 @@ AA3300022F2100010000BBBB /* ActiveCallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallManager.swift; sourceTree = ""; }; AA3300042F2100010000BBBB /* FocusModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusModeManager.swift; sourceTree = ""; }; AA3300062F2100020000BBBB /* ActiveCallPulseDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallPulseDot.swift; sourceTree = ""; }; - AA4400022F2200010000CCCC /* CopilotManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotManager.swift; sourceTree = ""; }; AIAURA00000000000000ABCE /* AppleIntelligenceAura.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleIntelligenceAura.swift; sourceTree = ""; }; AIRPDS00000000000000ABCE /* AirPodsBatteryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirPodsBatteryManager.swift; sourceTree = ""; }; ALBCAC00000000000000ABCE /* AlbumArtCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumArtCache.swift; sourceTree = ""; }; @@ -622,7 +620,6 @@ children = ( AA3300022F2100010000BBBB /* ActiveCallManager.swift */, AA3300042F2100010000BBBB /* FocusModeManager.swift */, - AA4400022F2200010000CCCC /* CopilotManager.swift */, CAA100122F2000010000AAAA /* CodexManager.swift */, BBB4112B2F0816D40007988A /* ClaudeCodeManager.swift */, JSONLR0000000000000000A1 /* JSONLSessionReader.swift */, @@ -1217,7 +1214,6 @@ B17266E32C65F7FB0031BA0D /* WhatsNewView.swift in Sources */, 14C08BB62C8DE42D000F8AA0 /* CalendarManager.swift in Sources */, B10F84A32C6C9596009F3026 /* TestView.swift in Sources */, - AA4400012F2200010000CCCC /* CopilotManager.swift in Sources */, 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */, 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */, AA3300012F2100010000BBBB /* ActiveCallManager.swift in Sources */, @@ -1293,7 +1289,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20903; + CURRENT_PROJECT_VERSION = 20904; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1301,7 +1297,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.3; + MARKETING_VERSION = 2.9.4; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1319,7 +1315,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20903; + CURRENT_PROJECT_VERSION = 20904; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1327,7 +1323,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.3; + MARKETING_VERSION = 2.9.4; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1478,7 +1474,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20903; + CURRENT_PROJECT_VERSION = 20904; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -1506,7 +1502,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.3; + MARKETING_VERSION = 2.9.4; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1534,7 +1530,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 20903; + CURRENT_PROJECT_VERSION = 20904; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; @@ -1562,7 +1558,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.3; + MARKETING_VERSION = 2.9.4; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/NotchIA/ContentView.swift b/NotchIA/ContentView.swift index 0ff96ba..bee17a6 100644 --- a/NotchIA/ContentView.swift +++ b/NotchIA/ContentView.swift @@ -24,7 +24,6 @@ struct ContentView: View { @ObservedObject var volumeManager = VolumeManager.shared @ObservedObject var claudeCodeManager = ClaudeCodeManager.shared @ObservedObject var codexManager = CodexManager.shared - @ObservedObject var copilotManager = CopilotManager.shared @ObservedObject var pomodoroManager = PomodoroManager.shared @ObservedObject var screenLockManager = ScreenLockManager.shared @ObservedObject var activeCallManager = ActiveCallManager.shared @@ -210,20 +209,15 @@ struct ContentView: View { private var activeCodeAssistantProvider: CodeAssistantProvider? { let claudeActive = claudeCodeManager.hasAnySessionActivity || claudeCodeManager.state.needsPermission let codexActive = codexManager.hasAnySessionActivity - let copilotActive = copilotManager.isActive - switch (claudeActive, codexActive, copilotActive) { - case (true, false, false): + switch (claudeActive, codexActive) { + case (true, false): return .claudeCode - case (false, true, false): + case (false, true): return .codex - case (false, false, true): - return .copilot - case (true, true, false): + case (true, true): return selectedCodeAssistantProvider - case (true, false, true), (false, true, true), (true, true, true): - return selectedCodeAssistantProvider - case (false, false, false): + case (false, false): return nil } } @@ -246,15 +240,12 @@ struct ContentView: View { // running-but-idle CLI process should not keep the notch occupied. let claudeActive = claudeCodeManager.hasAnySessionActivity || claudeCodeManager.state.needsPermission let codexActive = codexManager.hasAnySessionActivity - let copilotActive = copilotManager.isActive switch selectedCodeAssistantProvider { case .claudeCode: return claudeActive || codexActive case .codex: return codexActive || claudeActive - case .copilot: - return copilotActive } } @@ -1265,8 +1256,6 @@ struct ContentView: View { ClaudeCodeCompactView() case .codex: CodexCompactView() - case .copilot: - CopilotCompactIndicatorView() } } @@ -1363,33 +1352,6 @@ struct ContentView: View { contextWindow: compactUsage.contextWindow, quotaSubtitle: quotaSubtitle ) - - case .copilot: - let quotaSubtitle = compactCopilotStatusSubtitle() - // Display the IDE actually hosting Copilot (Cursor / VS Code / - // Insiders) rather than a flat "Copilot" string — without this - // the user can't tell whether Copilot detection is succeeding - // for non-VS-Code editors. - let copilotProviderName: String = { - if !copilotManager.currentIDE.isEmpty { return copilotManager.currentIDE } - return "Copilot" - }() - return buildCodeAssistantCompactSummary( - providerName: copilotProviderName, - startedAt: copilotManager.isActive ? copilotManager.lastActivityTime : nil, - completedDuration: 0, - now: now, - isCompleted: false, - needsPermission: false, - activeTool: copilotManager.activeTool.map { - ToolExecution(id: "copilot-active-tool", toolName: $0, argument: nil, startTime: copilotManager.lastActivityTime, endTime: nil) - }, - isThinking: copilotManager.isThinking, - hasLiveOutput: !copilotManager.latestEventText.isEmpty, - totalTokens: copilotManager.tokenUsage.totalTokens, - contextWindow: copilotManager.tokenUsage.contextWindow, - quotaSubtitle: quotaSubtitle - ) } } @@ -1527,30 +1489,6 @@ struct ContentView: View { // the token-based subtitle derived from currentRequestTokens. private func compactClaudeQuotaSubtitle() -> String? { nil } - private func compactCopilotStatusSubtitle() -> String? { - if copilotManager.isRateLimited { - return String(localized: "Limite atteinte") - } - - if copilotManager.lastObservedUsage != nil { - return String(localized: "Dernier usage") - } - - guard !copilotManager.currentSKU.isEmpty else { return nil } - return compactCopilotPlanLabel(copilotManager.currentSKU) - } - - private func compactCopilotPlanLabel(_ sku: String) -> String { - switch sku { - case "monthly_subscriber_quota": - return String(localized: "Mensuel") - case "free_limited_copilot": - return String(localized: "Gratuit limité") - default: - return sku.replacingOccurrences(of: "_", with: " ") - } - } - private func compactLiveText(_ text: String?) -> String? { guard let text else { return nil } diff --git a/NotchIA/Localizable.xcstrings b/NotchIA/Localizable.xcstrings index 3a51c50..8b821dc 100644 --- a/NotchIA/Localizable.xcstrings +++ b/NotchIA/Localizable.xcstrings @@ -4750,6 +4750,7 @@ } }, "Agent d'édition" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12704,6 +12705,7 @@ } }, "Copilot a atteint sa limite de chat" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12732,6 +12734,7 @@ } }, "Copilot n’expose pas localement les tokens restants du compte. Seuls le plan, la limitation éventuelle et le dernier usage observé sont fiables ici." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12760,6 +12763,7 @@ } }, "Copilot non disponible" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12788,6 +12792,7 @@ } }, "Copilot prêt" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12816,6 +12821,7 @@ } }, "Copilot prêt dans VS Code" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12844,6 +12850,7 @@ } }, "Copilot rédige une réponse" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12872,6 +12879,7 @@ } }, "Copilot travaille" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12900,6 +12908,7 @@ } }, "Copilot utilise ses outils" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -14851,6 +14860,7 @@ } }, "Dernier usage" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -14879,6 +14889,7 @@ } }, "Dernier usage observé" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -16533,6 +16544,7 @@ } }, "Édition" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -16785,6 +16797,7 @@ } }, "En attente" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -21071,6 +21084,7 @@ } }, "Gratuit limité" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -24536,6 +24550,7 @@ } }, "Limite atteinte" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -24564,6 +24579,7 @@ } }, "Limite d'utilisation Copilot atteinte" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -26656,6 +26672,7 @@ } }, "Mensuel" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -30912,6 +30929,7 @@ } }, "Ouvre GitHub Copilot Chat dans VS Code" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -33132,6 +33150,7 @@ } }, "Quota Copilot atteint" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -34876,6 +34895,7 @@ } }, "Réponse" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/NotchIA/components/ClaudeCode/CodeAssistantViews.swift b/NotchIA/components/ClaudeCode/CodeAssistantViews.swift index 2d69c08..47381db 100644 --- a/NotchIA/components/ClaudeCode/CodeAssistantViews.swift +++ b/NotchIA/components/ClaudeCode/CodeAssistantViews.swift @@ -2,7 +2,7 @@ // CodeAssistantViews.swift // NotchIA // -// Shared provider switcher for Claude Code, ChatGPT Codex and GitHub Copilot +// Shared provider switcher for Claude Code and ChatGPT Codex // import Defaults @@ -27,10 +27,6 @@ struct CodeAssistantStatsView: View { CodexStatsView() .id(CodeAssistantProvider.codex) .frame(height: geo.size.height - OpenNotchLayoutMetrics.contentInsets.top - 30) - case .copilot: - CopilotStatsView() - .id(CodeAssistantProvider.copilot) - .frame(height: geo.size.height - OpenNotchLayoutMetrics.contentInsets.top - 30) } } .padding(.trailing, OpenNotchLayoutMetrics.contentInsets.trailing) @@ -43,7 +39,6 @@ struct CodeAssistantStatsView: View { .onAppear { ClaudeCodeManager.shared.refresh() CodexManager.shared.refresh() - CopilotManager.shared.refresh() } } } @@ -59,9 +54,6 @@ struct CodeAssistantCompactView: View { case .codex: CodexCompactView() .id(CodeAssistantProvider.codex) - case .copilot: - CopilotCompactIndicatorView() - .id(CodeAssistantProvider.copilot) } } } @@ -70,7 +62,6 @@ struct CodeAssistantProviderPicker: View { @Binding var selection: CodeAssistantProvider @ObservedObject private var claudeCodeManager = ClaudeCodeManager.shared @ObservedObject private var codexManager = CodexManager.shared - @ObservedObject private var copilotManager = CopilotManager.shared var body: some View { HStack(spacing: 4) { @@ -87,19 +78,6 @@ struct CodeAssistantProviderPicker: View { title: CodeAssistantProvider.codex.title, isAvailable: !codexManager.availableSessions.isEmpty ) - - providerButton( - provider: .copilot, - icon: "bolt.badge.checkmark", - // Show the detected IDE name in the picker label too — - // "Cursor" / "VS Code" / "VS Code Insiders" — instead - // of the generic "Copilot". Falls back to the enum's - // canonical title when no IDE has been detected yet. - title: copilotManager.currentIDE.isEmpty - ? CodeAssistantProvider.copilot.title - : copilotManager.currentIDE, - isAvailable: copilotManager.isConnected || copilotManager.isActive - ) } .padding(3) .background(Color.white.opacity(0.08)) @@ -147,261 +125,8 @@ struct CodeAssistantProviderPicker: View { claudeCodeManager.refresh() case .codex: codexManager.refresh() - case .copilot: - copilotManager.refresh() - } - } - } -} - -struct CopilotStatsView: View { - @ObservedObject private var manager = CopilotManager.shared - - var body: some View { - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 6) { - - // Row 1: Badges — icon + status dot + IDE + model + phase - HStack(spacing: 6) { - Image(systemName: "bolt.badge.checkmark.fill") - .font(.system(size: 12)) - .foregroundColor(manager.isActive ? .green : manager.isConnected ? .blue : .secondary) - - if manager.isConnected { - Circle() - .fill(manager.isActive ? Color.green : Color.gray) - .frame(width: 5, height: 5) - - copilotBadge( - icon: "chevron.left.forwardslash.chevron.right", - text: manager.currentIDE, - tint: .blue - ) - - if !manager.activeModel.isEmpty { - copilotBadge(icon: "cpu", text: manager.activeModel, tint: .mint) - } - - if manager.isActive { - copilotBadge(icon: "bolt.fill", text: manager.phaseDescription, tint: .green) - } - - if !manager.currentSKU.isEmpty { - copilotBadge(icon: "person.crop.circle.badge.checkmark", text: copilotPlanLabel, tint: .orange) - } - } - - Spacer() - } - - if manager.isConnected { - if manager.isRateLimited { - statusCard( - title: String(localized: "Limite atteinte"), - icon: "exclamationmark.triangle.fill", - text: manager.rateLimitMessage, - tint: .orange - ) - } - - // Activity card — current phase / event text - statusCard( - title: manager.isActive ? manager.phaseDescription : String(localized: "Connecté"), - icon: manager.activeTool != nil - ? "wrench.and.screwdriver" - : manager.isActive ? "ellipsis.bubble" : "checkmark.circle", - text: manager.latestEventText, - tint: manager.isActive ? .blue : .secondary - ) - - // Workspace card - if !manager.currentWorkspaceName.isEmpty { - statusCard( - title: "Projet", - icon: "folder.fill", - text: manager.currentWorkspaceName, - tint: .secondary - ) - } - - if let observedUsage = manager.lastObservedUsage { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 4) { - Image(systemName: "number") - .font(.system(size: 8, weight: .semibold)) - Text("Dernier usage observé") - .font(.caption2.weight(.semibold)) - } - .foregroundColor(.mint) - - HStack(spacing: 6) { - usageChip(title: "Prompt", value: observedUsage.promptTokens) - usageChip(title: "Cache", value: observedUsage.cachedTokens) - usageChip(title: "Completion", value: observedUsage.completionTokens) - usageChip(title: "Total", value: observedUsage.totalTokens) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color.white.opacity(0.05)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - - // Account row - if !manager.accountName.isEmpty { - HStack(spacing: 3) { - Image(systemName: "person.fill") - .font(.system(size: 7)) - Text(manager.accountName) - .font(.caption2) - } - .foregroundColor(.secondary.opacity(0.75)) - } - - Text("Copilot n’expose pas localement les tokens restants du compte. Seuls le plan, la limitation éventuelle et le dernier usage observé sont fiables ici.") - .font(.caption2) - .foregroundColor(.secondary.opacity(0.7)) - .fixedSize(horizontal: false, vertical: true) - - } else { - // Not connected state — mirrors Claude/Codex empty state - HStack(spacing: 8) { - Image(systemName: "bolt.slash") - .font(.title3) - .foregroundColor(.secondary) - - VStack(alignment: .leading, spacing: 2) { - Text("Copilot non disponible") - .font(.caption) - .foregroundColor(.secondary) - - Text("Ouvre GitHub Copilot Chat dans VS Code") - .font(.caption2) - .foregroundColor(.secondary.opacity(0.7)) - } - } - } - } - .padding(.top, OpenNotchLayoutMetrics.contentInsets.top) - .padding(.leading, OpenNotchLayoutMetrics.contentInsets.leading) - .padding(.trailing, OpenNotchLayoutMetrics.contentInsets.trailing) - .padding(.bottom, OpenNotchLayoutMetrics.contentInsets.bottom) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .onAppear { - manager.refresh() - } - } - - @ViewBuilder - private func copilotBadge(icon: String, text: String, tint: Color) -> some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: 8, weight: .semibold)) - Text(text) - .lineLimit(1) - } - .font(.caption2) - .foregroundColor(tint) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background(tint.opacity(0.12)) - .clipShape(Capsule()) - } - - private var copilotPlanLabel: String { - switch manager.currentSKU { - case "monthly_subscriber_quota": - return "Mensuel" - case "free_limited_copilot": - return String(localized: "Gratuit limité") - default: - return manager.currentSKU.replacingOccurrences(of: "_", with: " ") - } - } - - @ViewBuilder - private func usageChip(title: String, value: Int) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.secondary) - - Text(formatTokenCount(value)) - .font(.caption2.weight(.semibold)) - .foregroundColor(.primary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 7) - .padding(.vertical, 5) - .background(Color.white.opacity(0.04)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - private func formatTokenCount(_ value: Int) -> String { formatAITokenCount(value) } - - @ViewBuilder - private func statusCard(title: String, icon: String, text: String, tint: Color) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: 8, weight: .semibold)) - Text(title) - .font(.caption2.weight(.semibold)) } - .foregroundColor(tint) - - Text(text) - .font(.caption2) - .foregroundColor(.secondary) - .lineLimit(3) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color.white.opacity(0.05)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } -} - -struct CopilotCompactIndicatorView: View { - @ObservedObject private var manager = CopilotManager.shared - @EnvironmentObject var vm: NotchIAViewModel - - var body: some View { - Rectangle() - .fill(.black) - .frame(width: vm.closedNotchSize.width, height: vm.effectiveClosedNotchHeight) - .overlay(alignment: .bottom) { - if manager.isConnected || manager.isActive { - HStack(spacing: 6) { - Image(systemName: "bolt.badge.checkmark") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(manager.isActive ? .green.opacity(0.85) : .secondary) - - Text(compactLabel) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(.primary.opacity(0.75)) - .lineLimit(1) - - ToolActivityIndicatorCompact(isActive: manager.isActive) - } - .padding(.bottom, 2) - } - } - } - - private var compactLabel: String { - if !manager.activeModel.isEmpty { return manager.activeModel } - if !manager.currentWorkspaceName.isEmpty { return manager.currentWorkspaceName } - // Fall back to the detected IDE name (Cursor / VS Code / VS Code - // Insiders) so the user can tell at a glance WHICH editor is - // hosting Copilot — defaulting to "Copilot" alone would hide - // the fact that detection works across multiple IDEs. - if !manager.currentIDE.isEmpty { return manager.currentIDE } - return "Copilot" } } diff --git a/NotchIA/managers/ClaudeCodeManager.swift b/NotchIA/managers/ClaudeCodeManager.swift index 5c12e3d..7c56576 100644 --- a/NotchIA/managers/ClaudeCodeManager.swift +++ b/NotchIA/managers/ClaudeCodeManager.swift @@ -782,21 +782,27 @@ final class ClaudeCodeManager: ObservableObject { let now = Date() for sessionId in sessionStates.keys { - // Only mark idle if not waiting for permission - if sessionStates[sessionId]?.needsPermission != true { + guard sessionStates[sessionId]?.needsPermission != true else { continue } + // Ne fige le chrono QUE si la session n'est plus active : ni + // « thinking », ni outil en cours. Sinon (Claude réfléchit + // longtemps, ou un outil type Bash/build tourne plusieurs minutes + // sans écrire au JSONL) l'idle-timer tuerait le chrono pendant le + // travail. Avant, `captureCompletionIfNeeded` était appelé + // inconditionnellement et nullait `activityStartedAt` malgré la + // garde `activeTools.isEmpty` placée trop tard. + let stillActive = (sessionStates[sessionId]?.isThinking == true) + || (sessionStates[sessionId]?.activeTools.isEmpty == false) + if !stillActive { captureCompletionIfNeeded(for: sessionId, at: now) sessionStates[sessionId]?.isThinking = false - if sessionStates[sessionId]?.activeTools.isEmpty == true { - sessionStates[sessionId]?.activityStartedAt = nil - } } } // Also mark selected session as idle if !state.needsPermission { - captureCompletionIfNeeded(for: &state, at: now) - state.isThinking = false - if state.activeTools.isEmpty { - state.activityStartedAt = nil + let stillActive = state.isThinking || !state.activeTools.isEmpty + if !stillActive { + captureCompletionIfNeeded(for: &state, at: now) + state.isThinking = false } } } @@ -886,13 +892,20 @@ final class ClaudeCodeManager: ObservableObject { sessionStates[sessionId]?.lastMessageTime = Date() } if role == "user" && !isLoadingHistory { - sessionStates[sessionId]?.latestThinking = "" - sessionStates[sessionId]?.isThinking = true - sessionStates[sessionId]?.activityStartedAt = Date() - sessionStates[sessionId]?.lastCompletedAt = nil - sessionStates[sessionId]?.lastCompletedDuration = 0 - sessionStates[sessionId]?.promptTokensTotal = 0 - lastActivityTime = Date() + if Self.messageIsRealUserPrompt(message) { + sessionStates[sessionId]?.latestThinking = "" + sessionStates[sessionId]?.isThinking = true + sessionStates[sessionId]?.activityStartedAt = Date() + sessionStates[sessionId]?.lastCompletedAt = nil + sessionStates[sessionId]?.lastCompletedDuration = 0 + sessionStates[sessionId]?.promptTokensTotal = 0 + lastActivityTime = Date() + } else { + // Continuation (tool_result) — voir parseMessage : on + // garde le chrono et les tokens du tour en cours. + sessionStates[sessionId]?.isThinking = true + lastActivityTime = Date() + } } else if role == "assistant" { sessionStates[sessionId]?.isThinking = true } @@ -1362,6 +1375,25 @@ final class ClaudeCodeManager: ObservableObject { } } + /// Distingue un VRAI message utilisateur (nouveau prompt) d'un message + /// `role=user` qui ne porte que des `tool_result` (réponse d'outil, + /// continuation du tour en cours). Claude Code écrit ces deux types avec + /// `role=user` ; sans distinction, chaque outil exécuté relancerait le + /// chrono à 0 et viderait le compteur de tokens. + /// - content String → prompt texte direct → vrai prompt. + /// - content array contenant uniquement des `tool_result` → continuation. + /// - content array avec au moins un bloc `text` → vrai prompt. + nonisolated static func messageIsRealUserPrompt(_ message: [String: Any]) -> Bool { + if message["content"] is String { return true } + if let items = message["content"] as? [[String: Any]] { + let hasText = items.contains { ($0["type"] as? String) == "text" } + let hasToolResult = items.contains { ($0["type"] as? String) == "tool_result" } + if hasToolResult && !hasText { return false } + return true + } + return true + } + private func parseMessage(_ message: [String: Any]) { // Extract model if let model = message["model"] as? String { @@ -1377,13 +1409,24 @@ final class ClaudeCodeManager: ObservableObject { // is observed (handled below). if let role = message["role"] as? String { if role == "user" && !isLoadingHistory { - state.latestThinking = "" - state.isThinking = true - state.activityStartedAt = Date() - state.lastCompletedAt = nil - state.lastCompletedDuration = 0 - state.promptTokensTotal = 0 - lastActivityTime = Date() + if Self.messageIsRealUserPrompt(message) { + // VRAI nouveau prompt utilisateur → nouveau cycle : + // chrono + tokens repartent à 0. + state.latestThinking = "" + state.isThinking = true + state.activityStartedAt = Date() + state.lastCompletedAt = nil + state.lastCompletedDuration = 0 + state.promptTokensTotal = 0 + lastActivityTime = Date() + } else { + // Message role=user portant uniquement des tool_result + // (réponse d'outil) → CONTINUATION du même tour. Ne PAS + // reset le chrono ni les tokens, sinon le temps repart à 0 + // et les tokens se vident à chaque outil exécuté. + state.isThinking = true + lastActivityTime = Date() + } } else if role == "assistant" { state.isThinking = true markSelectedStateActive() diff --git a/NotchIA/managers/CopilotManager.swift b/NotchIA/managers/CopilotManager.swift deleted file mode 100644 index 2c73662..0000000 --- a/NotchIA/managers/CopilotManager.swift +++ /dev/null @@ -1,754 +0,0 @@ -// -// CopilotManager.swift -// NotchIA -// -// GitHub Copilot Chat detection driven by VS Code extension logs -// - -import AppKit -import Foundation - -// Get real user home directory (handles sandboxed apps) -private func getRealHomeDirectory() -> String { - if let pwUser = getpwuid(getuid()) { - if let homeDir = pwUser.pointee.pw_dir { - return String(cString: homeDir) - } - } - return NSHomeDirectory() -} - -@MainActor -final class CopilotManager: ObservableObject { - static let shared = CopilotManager() - - @Published private(set) var isActive: Bool = false - @Published private(set) var isConnected: Bool = false - @Published private(set) var currentIDE: String = "VS Code" - @Published private(set) var currentWorkspaceName: String = "" - @Published private(set) var lastActivityTime: Date = .distantPast - @Published private(set) var tokenUsage: CopilotTokenUsage = CopilotTokenUsage() - @Published private(set) var isThinking: Bool = false - @Published private(set) var activeTool: String? = nil - @Published private(set) var accountName: String = "" - @Published private(set) var activeModel: String = "" - @Published private(set) var phaseDescription: String = String(localized: "En attente") - @Published private(set) var latestEventText: String = String(localized: "Copilot prêt") - @Published private(set) var currentSKU: String = "" - @Published private(set) var isRateLimited: Bool = false - @Published private(set) var rateLimitMessage: String = "" - @Published private(set) var lastObservedUsage: CopilotObservedUsage? = nil - - private var refreshTimer: Timer? - private var launchObserver: NSObjectProtocol? - private var activateObserver: NSObjectProtocol? - private var terminateObserver: NSObjectProtocol? - - private let activityTimeout: TimeInterval = 60 // 60 seconds for active session - - /// Pure I/O + parsing helper. Isolated off main to avoid blocking the UI - /// every 2.5s while the refresh timer ticks (reads up to ~1 MB across log files). - /// Tries Cursor / VS Code / VS Code Insiders log roots in order; the reader - /// picks the first one that exists and contains Copilot logs at scan time. - private let logReader: CopilotLogReader = { - let home = URL(fileURLWithPath: getRealHomeDirectory()) - let appSupport = home.appendingPathComponent("Library/Application Support") - let candidateRoots: [URL] = [ - appSupport.appendingPathComponent("Cursor/logs"), - appSupport.appendingPathComponent("Code/logs"), - appSupport.appendingPathComponent("Code - Insiders/logs"), - ] - return CopilotLogReader( - ideLocksRoot: home.appendingPathComponent(".copilot/ide"), - extensionLogsRootCandidates: candidateRoots - ) - }() - - /// Monotonic ticket to discard stale background reads if a newer tick has already applied. - private var statusTicket: UInt64 = 0 - - private init() { - startMonitoring() - } - - deinit { - refreshTimer?.invalidate() - if let launchObserver { - NSWorkspace.shared.notificationCenter.removeObserver(launchObserver) - } - if let activateObserver { - NSWorkspace.shared.notificationCenter.removeObserver(activateObserver) - } - if let terminateObserver { - NSWorkspace.shared.notificationCenter.removeObserver(terminateObserver) - } - } - - func refresh() { - checkCopilotStatus() - } - - private func startMonitoring() { - refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.checkCopilotStatus() - } - } - RunLoop.main.add(refreshTimer!, forMode: .common) - - launchObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didLaunchApplicationNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.checkCopilotStatus() - } - } - - activateObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didActivateApplicationNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.checkCopilotStatus() - } - } - - terminateObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didTerminateApplicationNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.checkCopilotStatus() - } - } - - checkCopilotStatus() - } - - private func checkCopilotStatus() { - // Pro lockdown : pas de scan des logs Copilot pour les users free. - // Provider Copilot affilié à l'onglet IA Code requiresPro. - guard LicenseManager.shared.state.isPro else { - if isActive { isActive = false } - return - } - statusTicket &+= 1 - let ticket = statusTicket - let reader = logReader - Task.detached(priority: .utility) { [weak self] in - let lockEntries = reader.loadActiveIDELocks() - let logSnapshot = reader.latestLogSnapshot() - let rateLimitSnapshot = reader.latestRateLimitSnapshot() - await MainActor.run { [weak self] in - guard let self else { return } - // Drop stale payloads if a newer tick has already started. - guard ticket == self.statusTicket else { return } - self.applyStatus( - lockEntries: lockEntries, - logSnapshot: logSnapshot, - rateLimitSnapshot: rateLimitSnapshot - ) - } - } - } - - private func applyStatus( - lockEntries: [CopilotLogReader.IDELockEntry], - logSnapshot: CopilotLogReader.CopilotLogSnapshot, - rateLimitSnapshot: CopilotLogReader.CopilotRateLimitSnapshot - ) { - let preferredLock = lockEntries.sorted { (a, b) -> Bool in - let timeA = a.timestamp ?? 0 - let timeB = b.timestamp ?? 0 - return timeA > timeB - }.first - - // Use log activity as primary connection indicator (more reliable than lock file) - // Consider "connected" if there's activity within last 1 hour - let connectionTimeout: TimeInterval = 3600 - let hasRecentLogActivity = logSnapshot.eventTime != nil && - Date().timeIntervalSince(logSnapshot.eventTime!) <= connectionTimeout - - isConnected = preferredLock != nil || hasRecentLogActivity - - if let preferredLock { - currentIDE = (preferredLock.ideName?.isEmpty ?? true) ? "VS Code" : preferredLock.ideName ?? String(localized: "VS Code") - currentWorkspaceName = preferredLock.workspaceName - } else { - currentIDE = "VS Code" - currentWorkspaceName = "" - } - - if let accountName = logSnapshot.accountName, !accountName.isEmpty { - self.accountName = accountName - } else if !isConnected { - accountName = "" - } - - if let model = logSnapshot.modelName, !model.isEmpty { - activeModel = model - } else if !isConnected { - activeModel = "" - } - - if let sku = logSnapshot.sku { - currentSKU = sku - } else if !isConnected { - currentSKU = "" - } - - if let observedUsage = logSnapshot.observedUsage { - lastObservedUsage = observedUsage - tokenUsage = CopilotTokenUsage( - inputTokens: observedUsage.promptTokens + observedUsage.cachedTokens, - outputTokens: observedUsage.completionTokens, - contextWindow: 200_000 - ) - } else if !isConnected { - lastObservedUsage = nil - tokenUsage = CopilotTokenUsage() - } - - if let eventTime = logSnapshot.eventTime { - lastActivityTime = eventTime - } else if !isConnected { - lastActivityTime = .distantPast - } - - let rateLimitWindow: TimeInterval = 15 * 60 - if let rateLimitTime = rateLimitSnapshot.time, - Date().timeIntervalSince(rateLimitTime) <= rateLimitWindow { - isRateLimited = true - rateLimitMessage = rateLimitSnapshot.message ?? String(localized: "Quota Copilot atteint") - } else { - isRateLimited = false - rateLimitMessage = "" - } - - let now = Date() - let hasRecentActivity: Bool - if let eventTime = logSnapshot.eventTime { - hasRecentActivity = now.timeIntervalSince(eventTime) <= activityTimeout - } else { - hasRecentActivity = false - } - - isActive = isConnected && hasRecentActivity - isThinking = isActive && logSnapshot.phase != String(localized: "Terminé") - activeTool = isActive ? logSnapshot.activeTool : nil - - // Debug output - - - if isActive { - phaseDescription = logSnapshot.phase ?? "Actif" - latestEventText = isRateLimited - ? (rateLimitMessage.isEmpty ? "Quota Copilot atteint" : rateLimitMessage) - : (logSnapshot.eventText ?? String(localized: "Copilot travaille")) - } else if isConnected { - phaseDescription = String(localized: "Connecté") - latestEventText = isRateLimited - ? (rateLimitMessage.isEmpty ? "Quota Copilot atteint" : rateLimitMessage) - : (logSnapshot.eventText ?? String(localized: "Copilot prêt dans VS Code")) - } else { - phaseDescription = "Indisponible" - latestEventText = String(localized: "Ouvre GitHub Copilot Chat dans VS Code") - activeTool = nil - } - } - - /// External hook if a future integration can push token updates. - func updateTokenUsage(_ usage: CopilotTokenUsage) { - tokenUsage = usage - } -} - -/// Nonisolated file-reading / parsing helper used by CopilotManager. -/// Extracted as a plain struct so its methods can run off the main actor -/// without cascading `nonisolated` annotations on every helper. -/// Visibility is `internal` (rather than `fileprivate`) solely so the -/// XCTest target can `@testable import NotchIA` and unit-test the parsers. -struct CopilotLogReader { - let ideLocksRoot: URL - let extensionLogsRootCandidates: [URL] - - /// Resolves the first candidate root that exists AND currently contains - /// at least one Copilot log file. Falls back to the first existing root - /// (or the first candidate) so behavior matches legacy single-root setups. - var extensionLogsRoot: URL { - let fm = FileManager.default - var firstExisting: URL? - for root in extensionLogsRootCandidates { - var isDir: ObjCBool = false - guard fm.fileExists(atPath: root.path, isDirectory: &isDir), isDir.boolValue else { - continue - } - if firstExisting == nil { firstExisting = root } - if rootContainsCopilotLogs(root) { - return root - } - } - return firstExisting ?? extensionLogsRootCandidates.first ?? ideLocksRoot - } - - private func rootContainsCopilotLogs(_ root: URL) -> Bool { - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { - return false - } - for case let url as URL in enumerator { - if url.lastPathComponent == "GitHub Copilot Chat.log" { - return true - } - } - return false - } - - struct IDELockEntry: Decodable { - let pid: Int? - let ideName: String? - let timestamp: Double? - let workspaceFolders: [String]? - - var workspaceName: String { - guard let path = workspaceFolders?.first, !path.isEmpty else { return "" } - return URL(fileURLWithPath: path).lastPathComponent - } - - var resolvedTimestamp: Date { - guard let timestamp else { return .distantPast } - return Date(timeIntervalSince1970: timestamp / 1000) - } - } - - struct CopilotLogSnapshot { - var accountName: String? - var modelName: String? - var phase: String? - var eventText: String? - var eventTime: Date? - var activeTool: String? - var sku: String? - var observedUsage: CopilotObservedUsage? - } - - struct CopilotRateLimitSnapshot { - var time: Date? - var message: String? - } - - static let timestampFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - return formatter - }() - - func loadActiveIDELocks() -> [IDELockEntry] { - guard let lockURLs = try? FileManager.default.contentsOfDirectory( - at: ideLocksRoot, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { - return [] - } - - return lockURLs.compactMap { url in - guard url.pathExtension == "lock", - let data = try? Data(contentsOf: url), - let entry = try? JSONDecoder().decode(IDELockEntry.self, from: data) else { - return nil - } - - guard let pid = entry.pid, - NSRunningApplication(processIdentifier: pid_t(pid)) != nil else { - return nil - } - - return IDELockEntry( - pid: entry.pid, - ideName: entry.ideName, - timestamp: entry.timestamp, - workspaceFolders: entry.workspaceFolders - ) - } - } - - func latestLogSnapshot() -> CopilotLogSnapshot { - let logFiles = copilotChatLogFiles() - - for url in logFiles.prefix(5) { - let snapshot = parseLogSnapshot(from: url) - if snapshot.eventTime != nil || snapshot.accountName != nil || snapshot.modelName != nil { - return snapshot - } - } - - return CopilotLogSnapshot() - } - - func copilotChatLogFiles() -> [URL] { - guard let enumerator = FileManager.default.enumerator( - at: extensionLogsRoot, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - return [] - } - - let files = enumerator.compactMap { element -> URL? in - guard let url = element as? URL, - url.lastPathComponent == "GitHub Copilot Chat.log" else { - return nil - } - return url - } - - return files.sorted { lhs, rhs in - let leftDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let rightDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return leftDate > rightDate - } - } - - func parseLogSnapshot(from url: URL) -> CopilotLogSnapshot { - var snapshot = CopilotLogSnapshot() - let lines = tailLines(of: url, maximumBytes: 128 * 1024) - - for line in lines.reversed() { - if snapshot.accountName == nil, - let accountName = extractSuffix(from: line, marker: "Logged in as ") { - snapshot.accountName = accountName - } - - if snapshot.modelName == nil, - let modelName = extractSuffix(from: line, marker: "Using default model: ") { - snapshot.modelName = modelName - } - - if snapshot.sku == nil, - let sku = extractSuffix(from: line, marker: "copilot token sku: ") { - snapshot.sku = sku - } - - if snapshot.observedUsage == nil, - let usage = parseObservedUsage(from: line) { - snapshot.observedUsage = usage - } - - if snapshot.eventTime == nil, - let parsedEvent = parseEvent(from: line) { - snapshot.eventTime = parsedEvent.time - snapshot.phase = parsedEvent.phase - snapshot.activeTool = parsedEvent.activeTool - snapshot.eventText = parsedEvent.text - - if snapshot.modelName == nil, let modelName = parsedEvent.modelName { - snapshot.modelName = modelName - } - } - - if snapshot.accountName != nil, - snapshot.modelName != nil, - snapshot.eventTime != nil, - snapshot.sku != nil, - snapshot.observedUsage != nil { - break - } - } - - return snapshot - } - - func latestRateLimitSnapshot() -> CopilotRateLimitSnapshot { - let logFiles = copilotRateLimitLogFiles() - - for url in logFiles.prefix(6) { - let snapshot = parseRateLimitSnapshot(from: url) - if snapshot.time != nil { - return snapshot - } - } - - return CopilotRateLimitSnapshot() - } - - func copilotRateLimitLogFiles() -> [URL] { - guard let enumerator = FileManager.default.enumerator( - at: extensionLogsRoot, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - return [] - } - - let allowedNames: Set = [ - "GitHub Copilot Chat.log", - "renderer.log", - "exthost.log", - ] - - let files = enumerator.compactMap { element -> URL? in - guard let url = element as? URL, - allowedNames.contains(url.lastPathComponent) else { - return nil - } - return url - } - - return files.sorted { lhs, rhs in - let leftDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let rightDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return leftDate > rightDate - } - } - - func parseRateLimitSnapshot(from url: URL) -> CopilotRateLimitSnapshot { - let lines = tailLines(of: url, maximumBytes: 128 * 1024) - - for line in lines.reversed() { - guard line.contains("ChatRateLimited") || line.contains("user_global_rate_limited") else { - continue - } - - return CopilotRateLimitSnapshot( - time: parseTimestamp(from: line), - message: extractRateLimitMessage(from: line) - ) - } - - return CopilotRateLimitSnapshot() - } - - func parseEvent(from line: String) -> (time: Date, phase: String, activeTool: String?, text: String, modelName: String?)? { - guard let eventTime = parseTimestamp(from: line) else { return nil } - - if line.contains("finish reason: [tool_calls]") { - return ( - time: eventTime, - phase: "Outils", - activeTool: "Agent", - text: String(localized: "Copilot utilise ses outils"), - modelName: nil - ) - } - - if line.contains("finish reason: [stop]") { - return ( - time: eventTime, - phase: String(localized: "Réponse"), - activeTool: nil, - text: String(localized: "Copilot rédige une réponse"), - modelName: nil - ) - } - - guard line.contains("| success |") else { return nil } - - let components = line.components(separatedBy: " | ") - guard components.count >= 5 else { return nil } - - let rawModel = components[2].trimmingCharacters(in: .whitespaces) - - // Handle two formats: - // 1. "gpt-4o-mini -> gpt-4o-mini-2024-07-18" → extract "gpt-4o-mini" - // 2. "accounts/msft/routers/mp3yn0h7" → this is a router, not a model - var modelName: String? = nil - if rawModel.contains(" -> ") { - modelName = rawModel.components(separatedBy: " -> ").first? - .trimmingCharacters(in: .whitespaces) - } else if !rawModel.contains("accounts/") && !rawModel.contains("routers/") { - modelName = rawModel - } - - let rawRoute = components[4] - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) - - let phase = phaseLabel(for: rawRoute) - let activeTool = activeToolLabel(for: rawRoute) - let routeLabel = routeLabel(for: rawRoute) - - // Show model if available, otherwise just the route - let displayModel = modelName ?? "" - let text = displayModel.isEmpty ? routeLabel : "\(routeLabel) • \(displayModel)" - - return ( - time: eventTime, - phase: phase, - activeTool: activeTool, - text: text, - modelName: displayModel - ) - } - - func parseTimestamp(from line: String) -> Date? { - guard line.count >= 23 else { return nil } - let timestampPrefix = String(line.prefix(23)) - return Self.timestampFormatter.date(from: timestampPrefix) - } - - func extractSuffix(from line: String, marker: String) -> String? { - guard let range = line.range(of: marker) else { return nil } - let suffix = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) - return suffix.isEmpty ? nil : suffix - } - - func parseObservedUsage(from line: String) -> CopilotObservedUsage? { - guard let usageText = extractSuffix(from: line, marker: "Summarization usage: ") else { - return nil - } - - var promptTokens = 0 - var cachedTokens = 0 - var completionTokens = 0 - - for component in usageText.components(separatedBy: ",") { - let trimmed = component.trimmingCharacters(in: .whitespacesAndNewlines) - - if let value = parseIntValue(from: trimmed, key: "prompt") { - promptTokens = value - } else if let value = parseIntValue(from: trimmed, key: "cached") { - cachedTokens = value - } else if let value = parseIntValue(from: trimmed, key: "completion") { - completionTokens = value - } - } - - guard promptTokens > 0 || cachedTokens > 0 || completionTokens > 0 else { return nil } - - return CopilotObservedUsage( - promptTokens: promptTokens, - cachedTokens: cachedTokens, - completionTokens: completionTokens - ) - } - - func parseIntValue(from text: String, key: String) -> Int? { - guard text.hasPrefix("\(key)=") else { return nil } - return Int(text.dropFirst(key.count + 1)) - } - - func extractRateLimitMessage(from line: String) -> String { - if line.contains("user_global_rate_limited") { - return String(localized: "Limite d'utilisation Copilot atteinte") - } - - if line.contains("ChatRateLimited") { - return String(localized: "Copilot a atteint sa limite de chat") - } - - return String(localized: "Quota Copilot atteint") - } - - func routeLabel(for route: String) -> String { - switch route { - case "panel/editAgent": - return String(localized: "Agent d'édition") - case "progressMessages": - return String(localized: "Réflexion") - case "title": - return "Titre" - case "copilotLanguageModelWrapper": - return String(localized: "Réponse") - default: - return route.replacingOccurrences(of: "/", with: " ") - } - } - - func phaseLabel(for route: String) -> String { - switch route { - case "panel/editAgent": - return String(localized: "Édition") - case "progressMessages": - return String(localized: "Réflexion") - case "copilotLanguageModelWrapper": - return String(localized: "Réponse") - default: - return "Actif" - } - } - - func activeToolLabel(for route: String) -> String? { - switch route { - case "panel/editAgent": - return "Agent" - case "progressMessages": - return String(localized: "Réflexion") - default: - return nil - } - } - - func tailLines(of url: URL, maximumBytes: Int) -> [String] { - guard let handle = try? FileHandle(forReadingFrom: url) else { return [] } - - defer { - try? handle.close() - } - - let fileSize = (try? handle.seekToEnd()) ?? 0 - let byteCount = UInt64(maximumBytes) - let startOffset = fileSize > byteCount ? fileSize - byteCount : 0 - - try? handle.seek(toOffset: startOffset) - let data = (try? handle.readToEnd()) ?? Data() - - guard let text = String(data: data, encoding: .utf8) else { return [] } - return text.components(separatedBy: .newlines).filter { !$0.isEmpty } - } -} - -struct CopilotTokenUsage: Equatable { - var inputTokens: Int = 0 - var outputTokens: Int = 0 - var contextWindow: Int = 200_000 - - var totalTokens: Int { - inputTokens + outputTokens - } - - var contextPercentage: Double { - guard contextWindow > 0 else { return 0 } - return min(100, Double(totalTokens) / Double(contextWindow) * 100) - } - - var remainingTokens: Int { - max(0, contextWindow - totalTokens) - } -} - -struct CopilotObservedUsage: Equatable { - var promptTokens: Int = 0 - var cachedTokens: Int = 0 - var completionTokens: Int = 0 - - var totalTokens: Int { - promptTokens + cachedTokens + completionTokens - } -} - -struct CopilotState: Equatable { - var isActive: Bool = false - var isConnected: Bool = false - var currentIDE: String = "VS Code" - var currentWorkspaceName: String = "" - var tokenUsage: CopilotTokenUsage = CopilotTokenUsage() - var isThinking: Bool = false - var activeTool: String? = nil - var phaseDescription: String = String(localized: "En attente") - var latestEventText: String = String(localized: "Copilot prêt") - var currentSKU: String = "" - var isRateLimited: Bool = false - var rateLimitMessage: String = "" - var lastObservedUsage: CopilotObservedUsage? = nil - - var contextPercentage: Double { tokenUsage.contextPercentage } -} diff --git a/NotchIA/models/Constants.swift b/NotchIA/models/Constants.swift index f1883ed..728a5bc 100644 --- a/NotchIA/models/Constants.swift +++ b/NotchIA/models/Constants.swift @@ -104,7 +104,6 @@ enum OptionKeyAction: String, CaseIterable, Identifiable, Defaults.Serializable enum CodeAssistantProvider: String, CaseIterable, Identifiable, Defaults.Serializable { case claudeCode = "Claude Code" case codex = "ChatGPT Codex" - case copilot = "GitHub Copilot" var id: String { rawValue } @@ -114,8 +113,6 @@ enum CodeAssistantProvider: String, CaseIterable, Identifiable, Defaults.Seriali return String(localized: "Claude Code") case .codex: return "Codex" - case .copilot: - return "Copilot" } } } diff --git a/updater/appcast.xml b/updater/appcast.xml index 8bccda5..d8bed70 100644 --- a/updater/appcast.xml +++ b/updater/appcast.xml @@ -32,6 +32,16 @@ --> + + 2.9.4 + Wed, 03 Jun 2026 20:16:41 +0000 + https://github.com/coaxel2/NotchIA/releases + 20904 + 2.9.4 + 15.0 + + + 2.9.3 Wed, 03 Jun 2026 19:57:23 +0000 @@ -52,16 +62,6 @@ - - 2.9.1 - Tue, 26 May 2026 01:23:20 +0000 - https://github.com/coaxel2/NotchIA/releases - 20901 - 2.9.1 - 15.0 - - - 2.7.3 Mon, 24 Nov 2025 08:07:37 +0000