diff --git a/.github/workflows/auto-close-pr.yml b/.github/workflows/auto-close-pr.yml index 752e32b3..90beda84 100644 --- a/.github/workflows/auto-close-pr.yml +++ b/.github/workflows/auto-close-pr.yml @@ -15,6 +15,7 @@ jobs: "At the moment we are not accepting contributions to the repository. Feedback for GitHub Copilot for Xcode can be given in the [Copilot community discussions](https://github.com/github/CopilotForXcode/discussions)." + if: ${{ !(startsWith(github.head_ref, 'release/') && github.event.pull_request.head.repo.full_name == github.repository) }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/auto-create-release-pr.yml b/.github/workflows/auto-create-release-pr.yml new file mode 100644 index 00000000..78dfb844 --- /dev/null +++ b/.github/workflows/auto-create-release-pr.yml @@ -0,0 +1,50 @@ +name: Auto-create Release PR + +on: + push: + branches: + - 'release/**' + +jobs: + create-pr: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + existing_pr_count="$(gh pr list \ + --state open \ + --base main \ + --head "${{ github.ref_name }}" \ + --json number \ + --jq 'length')" + if [ "${existing_pr_count}" -gt 0 ]; then + echo "Open pull request already exists for branch '${{ github.ref_name }}' into 'main'; skipping creation." + else + gh pr create \ + --title "$(git log -1 --pretty=%s)" \ + --body "Automated release PR." \ + --base main \ + --head "${{ github.ref_name }}" + fi + + - name: Approve pull request + env: + # PAT stored in github/CopilotForXcode, with write permissions to pull requests + GH_TOKEN: ${{ secrets.XCODE_AUTO_APPROVE }} + run: | + gh pr review --approve "${{ github.ref_name }}" + + - name: Auto-merge pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge "${{ github.ref_name }}" \ + --auto \ + --delete-branch diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 78e35963..9c414bc1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,6 +27,10 @@ jobs: fail-fast: false matrix: include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none - language: python build-mode: none - language: swift @@ -37,7 +41,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -66,6 +70,6 @@ jobs: CODE_SIGNING_ALLOWED="NO" - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index c762e625..d232491a 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -845,7 +845,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -874,7 +874,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -936,7 +936,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -991,7 +991,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -1022,7 +1022,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -1056,7 +1056,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -1072,7 +1072,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = VEKTX9H2N7; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1087,7 +1087,7 @@ DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1117,7 +1117,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1151,7 +1151,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1172,7 +1172,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1193,7 +1193,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index 0da62760..f01bb0ad 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -26,19 +26,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { - if #available(macOS 13.0, *) { - checkBackgroundPermissions() - } - + checkBackgroundPermissions() + let launchMode = determineLaunchMode() handleLaunchMode(launchMode) } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if #available(macOS 13.0, *) { - checkBackgroundPermissions() - } - + checkBackgroundPermissions() + let launchMode = determineLaunchMode() handleLaunchMode(launchMode) return true @@ -113,7 +109,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - @available(macOS 13.0, *) private func checkBackgroundPermissions() { Task { // Direct check of permission status @@ -122,7 +117,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if !isPermissionGranted { // Only show alert if permission isn't granted - DispatchQueue.main.async { + await MainActor.run { if !self.permissionAlertShown { showBackgroundPermissionAlert() self.permissionAlertShown = true @@ -130,7 +125,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } else { // Permission is granted, reset flag - self.permissionAlertShown = false + await MainActor.run { + self.permissionAlertShown = false + } } } } @@ -272,10 +269,8 @@ func activateAndOpenSettings() { if #available(macOS 14.0, *) { let environment = SettingsEnvironment() environment.open() - } else if #available(macOS 13.0, *) { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } else { - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } } diff --git a/Core/Package.swift b/Core/Package.swift index 8e2e58cc..08ce8d4a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -8,7 +8,7 @@ import PackageDescription let package = Package( name: "Core", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "Service", diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index ac75d819..f69afe52 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -64,11 +64,14 @@ public final class ChatService: ChatServiceType, ObservableObject { public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false + @Published public internal(set) var isSummarizingConversation = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] + @Published public internal(set) var contextSizeInfo: ContextSizeInfo? = nil public internal(set) var requestType: RequestType? = nil public private(set) var chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler + private let compressionHandler: CompressionHandler private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared // sync all the files in the workspace to watch for changes. private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared @@ -85,10 +88,12 @@ public final class ChatService: ChatServiceType, ObservableObject { init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared, + compressionHandler: CompressionHandler = CompressionHandlerImpl.shared, chatTabInfo: ChatTabInfo) { self.memory = memory self.conversationProvider = provider self.conversationProgressHandler = conversationProgressHandler + self.compressionHandler = compressionHandler self.chatTabInfo = chatTabInfo memory.chatService = self @@ -134,6 +139,19 @@ public final class ChatService: ChatServiceType, ObservableObject { conversationProgressHandler.onEnd.sink { [weak self] (token, progress) in self?.handleProgressEnd(token: token, progress: progress) }.store(in: &cancellables) + + compressionHandler.onCompressionStarted.sink { [weak self] compressionConversationId in + guard let self, self.conversationId == compressionConversationId else { return } + self.isSummarizingConversation = true + }.store(in: &cancellables) + + compressionHandler.onCompressionCompleted.sink { [weak self] completedNotification in + guard let self, self.conversationId == completedNotification.conversationId else { return } + self.isSummarizingConversation = false + if let contextInfo = completedNotification.contextInfo { + self.contextSizeInfo = contextInfo + } + }.store(in: &cancellables) } private func subscribeToConversationContextRequest() { @@ -745,7 +763,11 @@ public final class ChatService: ChatServiceType, ObservableObject { guard let workDownToken = activeRequestId, workDownToken == token else { return } - + + if let contextSize = progress.contextSize { + self.contextSizeInfo = contextSize + } + let id = progress.turnId var content = "" var references: [ConversationReference] = [] @@ -895,6 +917,7 @@ public final class ChatService: ChatServiceType, ObservableObject { private func resetOngoingRequest(with turnStatus: ChatMessage.TurnStatus = .success) { activeRequestId = nil isReceivingMessage = false + isSummarizingConversation = false requestType = nil // Clear turn tracking data diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index e6ca55e0..3c570890 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -295,16 +295,22 @@ struct Chat { struct ConversationState: Equatable { var history: [DisplayedChatMessage] var isReceivingMessage: Bool + var isSummarizingConversation: Bool var requestType: RequestType? + var contextSizeInfo: ContextSizeInfo? init( history: [DisplayedChatMessage] = [], isReceivingMessage: Bool = false, - requestType: RequestType? = nil + isSummarizingConversation: Bool = false, + requestType: RequestType? = nil, + contextSizeInfo: ContextSizeInfo? = nil ) { self.history = history self.isReceivingMessage = isReceivingMessage + self.isSummarizingConversation = isSummarizingConversation self.requestType = requestType + self.contextSizeInfo = contextSizeInfo } func subsequentMessages(after messageId: MessageID) -> [DisplayedChatMessage] { @@ -454,11 +460,21 @@ struct Chat { set { conversation.isReceivingMessage = newValue } } + var isSummarizingConversation: Bool { + get { conversation.isSummarizingConversation } + set { conversation.isSummarizingConversation = newValue } + } + var requestType: RequestType? { get { conversation.requestType } set { conversation.requestType = newValue } } + var contextSizeInfo: ContextSizeInfo? { + get { conversation.contextSizeInfo } + set { conversation.contextSizeInfo = newValue } + } + var handOffClicked: Bool { get { editor.handOffClicked } set { editor.handOffClicked = newValue } @@ -590,10 +606,12 @@ struct Chat { case observeHistoryChange case observeIsReceivingMessageChange case observeFileEditChange + case observeContextSizeInfoChange case historyChanged case isReceivingMessageChanged case fileEditChanged + case contextSizeInfoChanged case chatMenu(ChatMenu.Action) @@ -651,6 +669,7 @@ struct Chat { case observeIsReceivingMessageChange(UUID) case sendMessage(UUID) case observeFileEditChange(UUID) + case observeContextSizeInfoChange(UUID) case observeFixErrorNotification(UUID) } @@ -942,6 +961,7 @@ struct Chat { await send(.observeHistoryChange) await send(.observeIsReceivingMessageChange) await send(.observeFileEditChange) + await send(.observeContextSizeInfoChange) } case .observeHistoryChange: @@ -967,6 +987,7 @@ struct Chat { return .run { send in let stream = AsyncStream { continuation in let cancellable = service.$isReceivingMessage + .merge(with: service.$isSummarizingConversation) .sink { _ in continuation.yield() } @@ -1001,6 +1022,25 @@ struct Chat { cancelInFlight: true ) + case .observeContextSizeInfoChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$contextSizeInfo + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.contextSizeInfoChanged) + } + }.cancellable( + id: CancelID.observeContextSizeInfoChange(id), + cancelInFlight: true + ) + case .historyChanged: state.history = service.chatHistory.flatMap { message in var all = [DisplayedChatMessage]() @@ -1045,9 +1085,14 @@ struct Chat { case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage + state.isSummarizingConversation = service.isSummarizingConversation state.requestType = service.requestType return .none - + + case .contextSizeInfoChanged: + state.conversation.contextSizeInfo = service.contextSizeInfo + return .none + case .fileEditChanged: state.fileEditMap = service.fileEditMap let fileEditMap = state.fileEditMap diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index f4794206..ed1498f1 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -183,24 +183,13 @@ struct ChatPanelMessages: View { ) }) } - .modify { view in - if #available(macOS 13.0, *) { - view - .listRowSeparator(.hidden) - } else { - view - } - } + .listRowSeparator(.hidden) } .listStyle(.plain) .scaledPadding(.leading, 8) .listRowBackground(EmptyView()) .modify { view in - if #available(macOS 13.0, *) { - view.scrollContentBackground(.hidden) - } else { - view - } + view.scrollContentBackground(.hidden) } .coordinateSpace(name: scrollSpace) .preference( diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift index ec1abafb..7e03aad1 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift @@ -13,13 +13,10 @@ struct ModeAndModelPicker: View { @Binding var selectedAgent: ConversationMode @State private var selectedModel: LLMModel? - @State private var isHovered = false - @State private var isPressed = false @ObservedObject private var modelManager = CopilotModelManagerObservable.shared static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0) @State private var chatMode = "Ask" - @State private var isAgentPickerHovered = false // Separate caches for both scopes @State private var askScopeCache: ScopeCache = ScopeCache() @@ -27,14 +24,7 @@ struct ModeAndModelPicker: View { @State var isMCPFFEnabled: Bool @State var isBYOKFFEnabled: Bool - @State var isEditorPreviewEnabled: Bool @State private var cancellables = Set() - - @StateObject private var fontScaleManager = FontScaleManager.shared - - var fontScale: Double { - fontScaleManager.currentScale - } let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes @@ -46,7 +36,6 @@ struct ModeAndModelPicker: View { self._selectedModel = State(initialValue: initialModel) self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok - self.isEditorPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures updateAgentPicker() } @@ -54,7 +43,6 @@ struct ModeAndModelPicker: View { FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in isMCPFFEnabled = featureFlags.mcp isBYOKFFEnabled = featureFlags.byok - isEditorPreviewEnabled = featureFlags.editorPreviewFeatures }) .store(in: &cancellables) } @@ -78,26 +66,11 @@ struct ModeAndModelPicker: View { AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache } - // Helper method to format multiplier text - func formatMultiplierText(for billing: CopilotModelBilling?) -> String { - guard let billingInfo = billing else { return "" } - - let multiplier = billingInfo.multiplier - if multiplier == 0 { - return "Included" - } else { - let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 - ? String(format: "%.0f", multiplier) - : String(format: "%.2f", multiplier) - return "\(numberPart)x" - } - } - // Update cache for specific scope only if models changed func updateModelCacheIfNeeded(for scope: PromptTemplateScope) { - let currentModels = scope == .agentPanel ? - modelManager.availableAgentModels + modelManager.availableAgentBYOKModels : - modelManager.availableChatModels + modelManager.availableChatBYOKModels + let clsModels = scope == .agentPanel ? modelManager.availableAgentModels : modelManager.availableChatModels + let byokModels = isBYOKFFEnabled ? (scope == .agentPanel ? modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels) : [] + let currentModels = clsModels + byokModels let modelsHash = currentModels.hashValue if scope == .agentPanel { @@ -143,28 +116,13 @@ struct ModeAndModelPicker: View { allAvailableModels += byokModels } - // If editor preview is disabled and current model is auto, switch away from it - if !isEditorPreviewEnabled && currentModel?.isAutoModel == true { - // Try default model first - if let defaultModel = defaultModel, !defaultModel.isAutoModel { - AppState.shared.setSelectedModel(defaultModel) - selectedModel = defaultModel - return - } - // If default is also auto, use first non-auto available model - if let firstNonAuto = allAvailableModels.first(where: { !$0.isAutoModel }) { - AppState.shared.setSelectedModel(firstNonAuto) - selectedModel = firstNonAuto - return - } - } - - // Check if current model exists in available models for current scope using model comparison - let modelExists = allAvailableModels.contains { model in + // Find the fresh model from available models that matches the persisted selection. + // This ensures transient fields like degradationReason stay up to date. + let freshModel = allAvailableModels.first { model in model == currentModel } - - if !modelExists && currentModel != nil { + + if freshModel == nil && currentModel != nil { // Switch to default model if current model is not available if let fallbackModel = defaultModel { AppState.shared.setSelectedModel(fallbackModel) @@ -177,7 +135,7 @@ struct ModeAndModelPicker: View { selectedModel = nil } } else { - selectedModel = currentModel ?? defaultModel + selectedModel = freshModel ?? defaultModel } } @@ -258,85 +216,6 @@ struct ModeAndModelPicker: View { } } - // Model picker menu component - private var modelPickerMenu: some View { - Menu { - // Group models by premium status - let premiumModels = copilotModels.filter { $0.isPremiumModel } - let standardModels = copilotModels.filter { - $0.isStandardModel && !$0.isAutoModel - } - let autoModel = isEditorPreviewEnabled ? copilotModels.first(where: { $0.isAutoModel }) : nil - - // Always `Auto Model` on top if available - if let autoModel { - modelButton(for: autoModel) - } - - // Display standard models section if available - modelSection(title: "Standard Models", models: standardModels) - - // Display premium models section if available - modelSection(title: "Premium Models", models: premiumModels) - - if isBYOKFFEnabled { - // Display byok models section if available - modelSection(title: "Other Models", models: byokModels) - - Button("Manage Models...") { - try? launchHostAppBYOKSettings() - } - } - - if standardModels.isEmpty { - Link("Add Premium Models", destination: URL(string: "https://aka.ms/github-copilot-upgrade-plan")!) - } - } label: { - Text(selectedModel?.displayName ?? selectedModel?.modelName ?? "") - // scaledFont not work here. workaround by direclty use the fontScale - .font(.system(size: 13 * fontScale)) - } - .menuStyle(BorderlessButtonMenuStyle()) - .frame(maxWidth: labelWidth()) - .scaledPadding(4) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) - ) - .onHover { hovering in - isHovered = hovering - } - } - - // Helper function to create a section of model options - @ViewBuilder - private func modelSection(title: String, models: [LLMModel]) -> some View { - if !models.isEmpty { - Section(title) { - ForEach(models, id: \.self) { model in - modelButton(for: model) - } - } - } - } - - // Helper function to create a model selection button - private func modelButton(for model: LLMModel) -> some View { - Button { - AppState.shared.setSelectedModel(model) - } label: { - Text(createModelMenuItemAttributedString( - modelName: model.displayName ?? model.modelName, - isSelected: selectedModel == model, - cachedMultiplierText: currentCache.modelMultiplierCache[model.id.appending(model.providerName ?? "")] ?? "" - )) - } - .help( - model.isAutoModel - ? "Auto selects the best model for your request based on capacity and performance." - : model.displayName ?? model.modelName) - } - private var mcpButton: some View { Group { if isMCPFFEnabled { @@ -393,7 +272,13 @@ struct ModeAndModelPicker: View { // Model Picker Group { if !copilotModels.isEmpty && selectedModel != nil { - modelPickerMenu + ChatModelPicker( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache + ) } else { EmptyView() } @@ -433,9 +318,6 @@ struct ModeAndModelPicker: View { .onChange(of: isBYOKFFEnabled) { _ in updateCurrentModel() } - .onChange(of: isEditorPreviewEnabled) { _ in - updateCurrentModel() - } .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in updateCurrentModel() } @@ -445,15 +327,6 @@ struct ModeAndModelPicker: View { } } - func labelWidth() -> CGFloat { - guard let selectedModel = selectedModel else { return 100 } - let displayName = selectedModel.displayName ?? selectedModel.modelName - let width = displayName.size( - withAttributes: attributes - ).width - return CGFloat(width * fontScale + 20) - } - @MainActor func refreshModels() async { let now = Date() @@ -468,18 +341,6 @@ struct ModeAndModelPicker: View { } } - private func createModelMenuItemAttributedString( - modelName: String, - isSelected: Bool, - cachedMultiplierText: String - ) -> AttributedString { - return ModelMenuItemFormatter.createModelMenuItemAttributedString( - modelName: modelName, - isSelected: isSelected, - multiplierText: cachedMultiplierText, - targetWidth: currentCache.cachedMaxWidth - ) - } } struct ModelPicker_Previews: PreviewProvider { diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift index 322bac6d..5ed180f8 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift @@ -466,27 +466,11 @@ class AgentModeButtonMenuItem: NSView { super.draw(dirtyRect) if isHovered { - NSGraphicsContext.saveGraphicsState() - - let hoverColor = NSColor(.accentColor) - hoverColor.setFill() - - let cornerRadius: CGFloat - if #available(macOS 26.0, *) { - cornerRadius = 8.0 * fontScale - } else { - cornerRadius = 4.0 * fontScale - } - - // Use frame dimensions instead of bounds to avoid layout recursion - let viewWidth = frame.width - let viewHeight = frame.height - let hoverWidth = viewWidth - (scaledConstants.hoverEdgeInset * 2) - let insetRect = NSRect(x: scaledConstants.hoverEdgeInset, y: 0, width: hoverWidth, height: viewHeight) - let path = NSBezierPath(roundedRect: insetRect, xRadius: cornerRadius, yRadius: cornerRadius) - path.fill() - - NSGraphicsContext.restoreGraphicsState() + ModelMenuItemFormatter.drawMenuItemHighlight( + in: frame, + fontScale: fontScale, + hoverEdgeInset: scaledConstants.hoverEdgeInset + ) } } diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift index 97268560..641a4489 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift @@ -23,7 +23,6 @@ public struct ChatModePicker: View { let projectRootURL: URL? @Environment(\.colorScheme) var colorScheme @State var isAgentModeFFEnabled: Bool - @State var isEditorPreviewFFEnabled: Bool @State var isCustomAgentPolicyEnabled: Bool @State private var cancellables = Set() @State private var builtInAgents: [ConversationMode] = [] @@ -44,7 +43,6 @@ public struct ChatModePicker: View { self.projectRootURL = projectRootURL self.onScopeChange = onScopeChange isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode - isEditorPreviewFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures isCustomAgentPolicyEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled } @@ -78,7 +76,6 @@ public struct ChatModePicker: View { private func subscribeToFeatureFlagsDidChangeEvent() { FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in isAgentModeFFEnabled = featureFlags.agentMode - isEditorPreviewFFEnabled = featureFlags.editorPreviewFeatures }) .store(in: &cancellables) } @@ -188,7 +185,7 @@ public struct ChatModePicker: View { customAgents: customAgents, selectedAgent: selectedAgent, selectedIconName: displayIconName, - isCustomAgentEnabled: isEditorPreviewFFEnabled && isCustomAgentPolicyEnabled, + isCustomAgentEnabled: isCustomAgentPolicyEnabled, onSelectAgent: { setAgentMode($0) }, onEditAgent: { openAgentFileInXcode($0) }, onDeleteAgent: { deleteCustomAgent($0) }, @@ -219,13 +216,6 @@ public struct ChatModePicker: View { setAskMode() } } - .onChange(of: isEditorPreviewFFEnabled) { newValue in - // If editor preview is disabled and current agent is not the default agent, reset to default - if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { - let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent - setAgentMode(defaultAgent) - } - } .onChange(of: isCustomAgentPolicyEnabled) { newValue in // If custom agent policy is disabled and current agent is not the default agent, reset to default if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { @@ -277,7 +267,7 @@ public struct ChatModePicker: View { // Try to find the agent if let agent = findAgent(byId: subMode) { // If it's not the default agent and custom agents are disabled, reset to default - if !agent.isDefaultAgent && (!isEditorPreviewFFEnabled || !isCustomAgentPolicyEnabled) { + if !agent.isDefaultAgent && !isCustomAgentPolicyEnabled { selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent AppState.shared.setSelectedAgentSubMode("Agent") return diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift index 53eeeb6e..3e0100e7 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift @@ -34,7 +34,8 @@ public extension AppState { let displayName = savedModel["displayName"]?.stringValue let providerName = savedModel["providerName"]?.stringValue let supportVision = savedModel["supportVision"]?.boolValue ?? false - + let degradationReason = savedModel["degradationReason"]?.stringValue + // Try to reconstruct billing info if available var billing: CopilotModelBilling? if let isPremium = savedModel["billing"]?["isPremium"]?.boolValue, @@ -44,7 +45,7 @@ public extension AppState { multiplier: Float(multiplier) ) } - + return LLMModel( displayName: displayName, modelName: modelName, @@ -52,7 +53,8 @@ public extension AppState { id: id, billing: billing, providerName: providerName, - supportVision: supportVision + supportVision: supportVision, + degradationReason: degradationReason ) } @@ -154,7 +156,8 @@ public class CopilotModelManagerObservable: ObservableObject { modelFamily: fallbackModel.modelFamily, id: fallbackModel.id, billing: fallbackModel.billing, - supportVision: fallbackModel.capabilities.supports.vision + supportVision: fallbackModel.capabilities.supports.vision, + degradationReason: fallbackModel.degradationReason ) ) } @@ -175,7 +178,8 @@ public extension CopilotModelManager { modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, id: $0.id, billing: $0.billing, - supportVision: $0.capabilities.supports.vision + supportVision: $0.capabilities.supports.vision, + degradationReason: $0.degradationReason ) } } @@ -183,7 +187,8 @@ public extension CopilotModelManager { static func getDefaultChatModel(scope: PromptTemplateScope = .chatPanel) -> LLMModel? { let LLMs = CopilotModelManager.getAvailableLLMs() let LLMsInScope = LLMs.filter({ $0.scopes.contains(scope) }) - let defaultModel = LLMsInScope.first(where: { $0.isChatDefault && !$0.isAutoModel }) + let defaultModel = LLMsInScope.first(where: { $0.isChatDefault && $0.isAutoModel }) + ?? LLMsInScope.first(where: { $0.isChatDefault }) // If a default model is found, return it if let defaultModel = defaultModel { return LLMModel( @@ -191,7 +196,8 @@ public extension CopilotModelManager { modelFamily: defaultModel.modelFamily, id: defaultModel.id, billing: defaultModel.billing, - supportVision: defaultModel.capabilities.supports.vision + supportVision: defaultModel.capabilities.supports.vision, + degradationReason: defaultModel.degradationReason ) } @@ -203,18 +209,20 @@ public extension CopilotModelManager { modelFamily: gpt4_1.modelFamily, id: gpt4_1.id, billing: gpt4_1.billing, - supportVision: gpt4_1.capabilities.supports.vision + supportVision: gpt4_1.capabilities.supports.vision, + degradationReason: gpt4_1.degradationReason ) } // If no default model is found, fallback to the first available model - if let firstModel = LLMsInScope.first(where: { !$0.isAutoModel }) { + if let firstModel = LLMsInScope.first { return LLMModel( modelName: firstModel.modelName, modelFamily: firstModel.modelFamily, id: firstModel.id, billing: firstModel.billing, - supportVision: firstModel.capabilities.supports.vision + supportVision: firstModel.capabilities.supports.vision, + degradationReason: firstModel.degradationReason ) } @@ -253,6 +261,7 @@ public struct LLMModel: Codable, Hashable, Equatable { public let billing: CopilotModelBilling? public let providerName: String? public let supportVision: Bool + public let degradationReason: String? public init( displayName: String? = nil, @@ -261,7 +270,8 @@ public struct LLMModel: Codable, Hashable, Equatable { id: String, billing: CopilotModelBilling?, providerName: String? = nil, - supportVision: Bool + supportVision: Bool, + degradationReason: String? = nil ) { self.displayName = displayName self.modelName = modelName @@ -270,6 +280,28 @@ public struct LLMModel: Codable, Hashable, Equatable { self.billing = billing self.providerName = providerName self.supportVision = supportVision + self.degradationReason = degradationReason + } + + // Exclude degradationReason from equality — it's transient status, not model identity + public static func == (lhs: LLMModel, rhs: LLMModel) -> Bool { + lhs.displayName == rhs.displayName && + lhs.modelName == rhs.modelName && + lhs.modelFamily == rhs.modelFamily && + lhs.id == rhs.id && + lhs.billing == rhs.billing && + lhs.providerName == rhs.providerName && + lhs.supportVision == rhs.supportVision + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(displayName) + hasher.combine(modelName) + hasher.combine(modelFamily) + hasher.combine(id) + hasher.combine(billing) + hasher.combine(providerName) + hasher.combine(supportVision) } } diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift index 7b32efc8..e97027cc 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift @@ -9,7 +9,7 @@ public struct ScopeCache { // MARK: - Model Menu Item Formatting public struct ModelMenuItemFormatter { - public static let minimumPadding: Int = 48 + public static let minimumPadding: Int = 24 public static let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] @@ -26,9 +26,18 @@ public struct ModelMenuItemFormatter { modelName: String, isSelected: Bool, multiplierText: String, - targetWidth: CGFloat? = nil + targetWidth: CGFloat? = nil, + isDegraded: Bool = false ) -> AttributedString { - let displayName = isSelected ? "✓ \(modelName)" : " \(modelName)" + let prefix: String + if isDegraded { + prefix = "⚠ " + } else if isSelected { + prefix = "✓ " + } else { + prefix = " " + } + let displayName = "\(prefix)\(modelName)" var fullString = displayName var attributedString = AttributedString(fullString) @@ -84,4 +93,36 @@ public struct ModelMenuItemFormatter { return "" } } + + /// Draws the standard menu-item highlight background (accent-colored rounded rect). + static func drawMenuItemHighlight( + in frame: NSRect, + fontScale: Double, + hoverEdgeInset: CGFloat + ) { + NSGraphicsContext.saveGraphicsState() + NSColor.controlAccentColor.setFill() + + let cornerRadius: CGFloat + if #available(macOS 26.0, *) { + cornerRadius = 8.0 * fontScale + } else { + cornerRadius = 4.0 * fontScale + } + + let hoverWidth = frame.width - (hoverEdgeInset * 2) + let insetRect = NSRect( + x: hoverEdgeInset, + y: 0, + width: hoverWidth, + height: frame.height + ) + let path = NSBezierPath( + roundedRect: insetRect, + xRadius: cornerRadius, + yRadius: cornerRadius + ) + path.fill() + NSGraphicsContext.restoreGraphicsState() + } } diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift new file mode 100644 index 00000000..be81b51a --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift @@ -0,0 +1,28 @@ +import SharedUIComponents +import SwiftUI + +struct ChatModelPicker: View { + let selectedModel: LLMModel? + let copilotModels: [LLMModel] + let byokModels: [LLMModel] + let isBYOKFFEnabled: Bool + let currentCache: ScopeCache + + @StateObject private var fontScaleManager = FontScaleManager.shared + + private var fontScale: Double { + fontScaleManager.currentScale + } + + var body: some View { + ModelPickerButton( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache, + fontScale: fontScale + ) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift new file mode 100644 index 00000000..d2a92b5f --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift @@ -0,0 +1,270 @@ +import AppKit +import SwiftUI + +// MARK: - Model Picker Button (NSViewRepresentable) + +struct ModelPickerButton: NSViewRepresentable { + let selectedModel: LLMModel? + let copilotModels: [LLMModel] + let byokModels: [LLMModel] + let isBYOKFFEnabled: Bool + let currentCache: ScopeCache + let fontScale: Double + + func makeNSView(context: Context) -> NSView { + let container = ModelPickerContainerView(fontScale: fontScale) + container.translatesAutoresizingMaskIntoConstraints = false + + let button = ClickThroughButton() + button.title = "" + button.bezelStyle = .inline + button.setButtonType(.momentaryPushIn) + button.isBordered = false + button.target = context.coordinator + button.action = #selector(Coordinator.buttonClicked(_:)) + button.translatesAutoresizingMaskIntoConstraints = false + button.wantsLayer = true + + let titleLabel = NSTextField(labelWithString: "") + titleLabel.isEditable = false + titleLabel.isBordered = false + titleLabel.backgroundColor = .clear + titleLabel.drawsBackground = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + titleLabel.alignment = .center + titleLabel.usesSingleLineMode = true + titleLabel.lineBreakMode = .byTruncatingMiddle + + let chevronView = NSImageView() + let chevronImage = NSImage( + systemSymbolName: "chevron.down", + accessibilityDescription: nil + ) + let symbolConfig = NSImage.SymbolConfiguration( + pointSize: 8 * fontScale, weight: .semibold + ) + chevronView.image = chevronImage?.withSymbolConfiguration(symbolConfig) + chevronView.translatesAutoresizingMaskIntoConstraints = false + + let stackView = NSStackView(views: [titleLabel, chevronView]) + stackView.orientation = .horizontal + stackView.spacing = 2 * fontScale + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .centerY + stackView.setHuggingPriority(.required, for: .horizontal) + + button.addSubview(stackView) + container.addSubview(button) + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: container.leadingAnchor), + button.trailingAnchor.constraint(equalTo: container.trailingAnchor), + button.topAnchor.constraint(equalTo: container.topAnchor), + button.bottomAnchor.constraint(equalTo: container.bottomAnchor), + + stackView.leadingAnchor.constraint( + equalTo: button.leadingAnchor, constant: 6 * fontScale + ), + stackView.trailingAnchor.constraint( + equalTo: button.trailingAnchor, constant: -6 * fontScale + ), + stackView.topAnchor.constraint( + equalTo: button.topAnchor, constant: 2 * fontScale + ), + stackView.bottomAnchor.constraint( + equalTo: button.bottomAnchor, constant: -2 * fontScale + ), + + chevronView.widthAnchor.constraint(equalToConstant: 8 * fontScale), + chevronView.heightAnchor.constraint(equalToConstant: 8 * fontScale), + ]) + + context.coordinator.button = button + context.coordinator.titleLabel = titleLabel + context.coordinator.chevronView = chevronView + + // Setup tracking for hover + let trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], + owner: context.coordinator, + userInfo: nil + ) + button.addTrackingArea(trackingArea) + context.coordinator.trackingArea = trackingArea + + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let titleLabel = context.coordinator.titleLabel, + let button = context.coordinator.button, + let chevronView = context.coordinator.chevronView + else { return } + + let label = selectedModelLabel + titleLabel.stringValue = label + titleLabel.font = NSFont.systemFont(ofSize: 13 * fontScale) + titleLabel.textColor = .labelColor + + let chevronConfig = NSImage.SymbolConfiguration( + pointSize: 8 * fontScale, weight: .semibold + ) + chevronView.image = NSImage( + systemSymbolName: "chevron.down", + accessibilityDescription: nil + )?.withSymbolConfiguration(chevronConfig) + chevronView.contentTintColor = .tertiaryLabelColor + + // Update coordinator data + context.coordinator.selectedModel = selectedModel + context.coordinator.copilotModels = copilotModels + context.coordinator.byokModels = byokModels + context.coordinator.isBYOKFFEnabled = isBYOKFFEnabled + context.coordinator.currentCache = currentCache + context.coordinator.fontScale = fontScale + + // Hover background + let isHovered = context.coordinator.isHovered + button.layer?.backgroundColor = isHovered + ? NSColor.gray.withAlphaComponent(0.1).cgColor + : NSColor.clear.cgColor + button.layer?.cornerRadius = 5 * fontScale + button.layer?.cornerCurve = .continuous + + // Ideal width based on text (allows shrinking when parent is tight) + let textWidth = labelWidth(label: label) + context.coordinator.widthConstraint?.constant = textWidth + if context.coordinator.widthConstraint == nil { + let wc = nsView.widthAnchor.constraint(lessThanOrEqualToConstant: textWidth) + wc.priority = .defaultHigh + wc.isActive = true + context.coordinator.widthConstraint = wc + } + + // Report ideal width so SwiftUI can size us properly + if let container = nsView as? ModelPickerContainerView { + container.fontScale = fontScale + container.idealWidth = textWidth + container.invalidateIntrinsicContentSize() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache, + fontScale: fontScale + ) + } + + private var selectedModelLabel: String { + let name = selectedModel?.displayName ?? selectedModel?.modelName ?? "" + if selectedModel?.degradationReason != nil { + return "\u{26A0} \(name)" + } + return name + } + + private func labelWidth(label: String) -> CGFloat { + let font = NSFont.systemFont(ofSize: 13 * fontScale) + let attrs: [NSAttributedString.Key: Any] = [.font: font] + let textWidth = ceil((label as NSString).size(withAttributes: attrs).width) + // text + left padding(6) + right padding(6) + chevron(8) + stack spacing(2) + text field internal margin(6) + return textWidth + 28 * fontScale + } + + // MARK: - Coordinator + + class Coordinator: NSObject { + var selectedModel: LLMModel? + var copilotModels: [LLMModel] + var byokModels: [LLMModel] + var isBYOKFFEnabled: Bool + var currentCache: ScopeCache + var fontScale: Double + + var button: NSButton? + var titleLabel: NSTextField? + var chevronView: NSImageView? + var trackingArea: NSTrackingArea? + var widthConstraint: NSLayoutConstraint? + var isHovered = false + + init( + selectedModel: LLMModel?, + copilotModels: [LLMModel], + byokModels: [LLMModel], + isBYOKFFEnabled: Bool, + currentCache: ScopeCache, + fontScale: Double + ) { + self.selectedModel = selectedModel + self.copilotModels = copilotModels + self.byokModels = byokModels + self.isBYOKFFEnabled = isBYOKFFEnabled + self.currentCache = currentCache + self.fontScale = fontScale + } + + @objc func buttonClicked(_ sender: NSButton) { + let menuBuilder = ModelPickerMenu( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache, + fontScale: fontScale + ) + menuBuilder.showMenu(relativeTo: sender) + } + + @objc(mouseEntered:) func mouseEntered(with event: NSEvent) { + isHovered = true + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + button?.animator().layer?.backgroundColor = NSColor.gray + .withAlphaComponent(0.1).cgColor + } + NSCursor.pointingHand.push() + } + + @objc(mouseExited:) func mouseExited(with event: NSEvent) { + isHovered = false + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + button?.animator().layer?.backgroundColor = NSColor.clear.cgColor + } + NSCursor.pop() + } + } +} + +// MARK: - Container view that constrains intrinsic height + +private class ModelPickerContainerView: NSView { + var fontScale: Double + var idealWidth: CGFloat = NSView.noIntrinsicMetric + + init(fontScale: Double) { + self.fontScale = fontScale + super.init(frame: .zero) + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + setContentHuggingPriority(.defaultHigh, for: .horizontal) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + let height = 20 * fontScale + return NSSize(width: idealWidth, height: height) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift new file mode 100644 index 00000000..7ea03f64 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift @@ -0,0 +1,256 @@ +import AppKit + +// MARK: - Floating Detail Panel (shown on menu item hover) + +class ModelPickerDetailPanel: NSPanel { + static let shared = ModelPickerDetailPanel() + + private let contentLabel = NSTextField(wrappingLabelWithString: "") + private let nameLabel = NSTextField(labelWithString: "") + private let separatorView = NSBox() + private let containerView = NSView() + private var hideTimer: Timer? + + private var containerConstraints: [NSLayoutConstraint] = [] + private var currentFontScale: CGFloat = 1.0 + + private init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 100), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + self.isFloatingPanel = true + self.level = .popUpMenu + 1 + self.isOpaque = false + self.backgroundColor = .clear + self.hidesOnDeactivate = false + self.hasShadow = true + self.isMovable = false + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + setupContent() + } + + private func setupContent() { + let visual = NSVisualEffectView() + visual.material = .popover + visual.state = .active + visual.wantsLayer = true + visual.layer?.cornerRadius = 8 + visual.layer?.masksToBounds = true + visual.translatesAutoresizingMaskIntoConstraints = false + + containerView.translatesAutoresizingMaskIntoConstraints = false + + nameLabel.textColor = .labelColor + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.backgroundColor = .clear + nameLabel.drawsBackground = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.lineBreakMode = .byTruncatingTail + + separatorView.boxType = .separator + separatorView.translatesAutoresizingMaskIntoConstraints = false + + contentLabel.isEditable = false + contentLabel.isBordered = false + contentLabel.backgroundColor = .clear + contentLabel.drawsBackground = false + contentLabel.textColor = .secondaryLabelColor + contentLabel.usesSingleLineMode = false + contentLabel.maximumNumberOfLines = 0 + contentLabel.translatesAutoresizingMaskIntoConstraints = false + + containerView.addSubview(nameLabel) + containerView.addSubview(separatorView) + containerView.addSubview(contentLabel) + + visual.addSubview(containerView) + self.contentView = visual + + // Static constraints that don't depend on font scale + NSLayoutConstraint.activate([ + nameLabel.topAnchor.constraint(equalTo: containerView.topAnchor), + nameLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + nameLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + + separatorView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + separatorView.trailingAnchor.constraint( + equalTo: containerView.trailingAnchor + ), + + contentLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + contentLabel.trailingAnchor.constraint( + equalTo: containerView.trailingAnchor + ), + contentLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + applyScaledConstraints(to: visual, fontScale: 1.0) + } + + private func applyScaledConstraints( + to visual: NSView, + fontScale: CGFloat + ) { + NSLayoutConstraint.deactivate(containerConstraints) + + let padding: CGFloat = 10 * fontScale + let horizontalPadding: CGFloat = 12 * fontScale + let spacing: CGFloat = 6 * fontScale + + containerConstraints = [ + containerView.topAnchor.constraint( + equalTo: visual.topAnchor, constant: padding + ), + containerView.leadingAnchor.constraint( + equalTo: visual.leadingAnchor, constant: horizontalPadding + ), + containerView.trailingAnchor.constraint( + equalTo: visual.trailingAnchor, constant: -horizontalPadding + ), + containerView.bottomAnchor.constraint( + equalTo: visual.bottomAnchor, constant: -padding + ), + separatorView.topAnchor.constraint( + equalTo: nameLabel.bottomAnchor, constant: spacing + ), + contentLabel.topAnchor.constraint( + equalTo: separatorView.bottomAnchor, constant: spacing + ), + ] + + NSLayoutConstraint.activate(containerConstraints) + + nameLabel.font = NSFont.systemFont( + ofSize: 13 * fontScale, weight: .semibold + ) + contentLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + contentLabel.preferredMaxLayoutWidth = 236 * fontScale + + visual.layer?.cornerRadius = 8 * fontScale + + currentFontScale = fontScale + } + + func show( + for model: LLMModel, + nearRect: NSRect, + preferRight: Bool = true, + fontScale: CGFloat = 1.0 + ) { + hideTimer?.invalidate() + hideTimer = nil + + if let visual = self.contentView { + applyScaledConstraints(to: visual, fontScale: fontScale) + } + + let displayName = model.displayName ?? model.modelName + nameLabel.stringValue = displayName + + var details: [String] = [] + + // Provider + if let provider = model.providerName, !provider.isEmpty { + details.append("Provider: \(provider)") + } + + // Billing + if let billing = model.billing { + if billing.multiplier == 0 { + details.append("Cost: Included") + } else { + let formatted = billing.multiplier + .truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", billing.multiplier) + : String(format: "%.2f", billing.multiplier) + details.append("Cost: \(formatted)x premium") + } + } + + // Vision support + if model.supportVision { + details.append("Supports: Vision") + } + + // Degradation + if let reason = model.degradationReason { + details.append("\n\u{26A0} \(reason)") + } + + // Auto model description + if model.isAutoModel { + details = [ + "Automatically selects the best model for your request based on capacity and performance.", + "\nCost may vary based on the selected model.", + ] + } + + contentLabel.stringValue = details.joined(separator: "\n") + + // Size to fit content + let fittingSize = containerView.fittingSize + let panelWidth: CGFloat = 260 * fontScale + let panelHeight = fittingSize.height + 20 * fontScale + + let gap: CGFloat = 4 * fontScale + var origin: NSPoint + if preferRight { + origin = NSPoint( + x: nearRect.maxX + gap, y: nearRect.midY - panelHeight / 2 + ) + } else { + origin = NSPoint( + x: nearRect.minX - panelWidth - gap, + y: nearRect.midY - panelHeight / 2 + ) + } + + // Find the screen that contains the menu item + let menuScreen = NSScreen.screens.first(where: { + $0.frame.contains(nearRect.origin) + }) ?? NSScreen.main + + // Ensure the panel stays fully visible on that screen + if let screen = menuScreen { + let screenFrame = screen.visibleFrame + if origin.x + panelWidth > screenFrame.maxX { + origin.x = nearRect.minX - panelWidth - gap + } + if origin.x < screenFrame.minX { + origin.x = nearRect.maxX + gap + } + // Clamp horizontally as last resort + origin.x = max(origin.x, screenFrame.minX) + origin.x = min(origin.x, screenFrame.maxX - panelWidth) + // Clamp vertically + origin.y = max(origin.y, screenFrame.minY) + origin.y = min(origin.y, screenFrame.maxY - panelHeight) + } + + setContentSize(NSSize(width: panelWidth, height: panelHeight)) + setFrameOrigin(origin) + orderFront(nil) + } + + func scheduleHide() { + hideTimer?.invalidate() + hideTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in + self?.orderOut(nil) + } + } + + func cancelHide() { + hideTimer?.invalidate() + hideTimer = nil + } + + override func close() { + hideTimer?.invalidate() + hideTimer = nil + super.close() + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift new file mode 100644 index 00000000..a11f8b6f --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift @@ -0,0 +1,418 @@ +import AppKit +import HostAppActivator +import Persist + +// MARK: - Search Field View for Menu + +private class ModelSearchFieldView: NSView, NSSearchFieldDelegate { + let searchField = NSSearchField() + var onSearchTextChanged: ((String) -> Void)? + weak var parentMenu: NSMenu? + + init(fontScale: Double, width: CGFloat) { + let height = 30 * fontScale + super.init(frame: NSRect(x: 0, y: 0, width: width, height: height + 8 * fontScale)) + + searchField.placeholderString = "Search models..." + searchField.font = NSFont.systemFont(ofSize: 12 * fontScale) + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.focusRingType = .none + searchField.delegate = self + addSubview(searchField) + + NSLayoutConstraint.activate([ + searchField.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: 8 * fontScale + ), + searchField.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -8 * fontScale + ), + searchField.centerYAnchor.constraint(equalTo: centerYAnchor), + searchField.heightAnchor.constraint(equalToConstant: height), + ]) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSSearchField else { return } + onSearchTextChanged?(field.stringValue) + } + + /// Intercept Return / Enter in the search field to select the highlighted + /// menu item. NSMenu doesn't do this automatically for custom-view items. + func control( + _ control: NSControl, + textView _: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + if let menu = parentMenu, + let highlightedItem = menu.highlightedItem, + let menuItemView = highlightedItem.view as? ModelPickerMenuItem + { + menuItemView.performSelect() + return true + } + } + return false + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + DispatchQueue.main.async { [weak self] in + self?.searchField.becomeFirstResponder() + } + } + } +} + +// MARK: - Custom Menu (allows key events to reach search field) + +private class ModelPickerNSMenu: NSMenu { + weak var searchField: NSSearchField? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown else { + return super.performKeyEquivalent(with: event) + } + + // Return / Enter: NSMenu won't fire the action for items with custom + // views, so we find the currently highlighted ModelPickerMenuItem and + // invoke its selection callback directly. + let confirmKeyCodes: Set = [ + 36, // return + 76, // enter (numpad) + ] + if confirmKeyCodes.contains(event.keyCode) { + if let highlightedItem = highlightedItem, + let menuItemView = highlightedItem.view as? ModelPickerMenuItem + { + menuItemView.performSelect() + return true + } + return super.performKeyEquivalent(with: event) + } + + // Forward printable character input and delete keys to the search + // field. Navigation keys (arrows, Escape, Space, Tab) fall through + // to super so NSMenu handles them normally. + if let searchField = searchField, + Self.shouldForwardToSearchField(event) + { + if let window = searchField.window { + window.makeFirstResponder(searchField) + searchField.currentEditor()?.keyDown(with: event) + return true + } + } + return super.performKeyEquivalent(with: event) + } + + /// Returns `true` for key events that should be forwarded to the search + /// field: printable characters and delete/backspace. Returns `false` for + /// navigation and control keys so NSMenu can handle them. + private static func shouldForwardToSearchField(_ event: NSEvent) -> Bool { + // Always allow delete / forward-delete so the user can edit the query + let deleteKeyCodes: Set = [ + 51, // delete (backspace) + 117, // forward delete + ] + if deleteKeyCodes.contains(event.keyCode) { + return true + } + + // Reject keys that NSMenu uses for navigation / activation + let navigationKeyCodes: Set = [ + 123, // left arrow + 124, // right arrow + 125, // down arrow + 126, // up arrow + 53, // escape + 49, // space + 48, // tab + ] + if navigationKeyCodes.contains(event.keyCode) { + return false + } + + // Don't forward Cmd-key shortcuts (Cmd+A, Cmd+C, etc.) + if event.modifierFlags.contains(.command) { + return false + } + + // Forward if the key produces printable characters + if let chars = event.characters, !chars.isEmpty { + return true + } + + return false + } +} + +// MARK: - Model Picker Menu Builder + +struct ModelPickerMenu { + let selectedModel: LLMModel? + let copilotModels: [LLMModel] + let byokModels: [LLMModel] + let isBYOKFFEnabled: Bool + let currentCache: ScopeCache + let fontScale: Double + + private let detailPanel = ModelPickerDetailPanel.shared + + func showMenu(relativeTo button: NSButton) { + let menu = createMenu(allCopilotModels: copilotModels, allBYOKModels: byokModels) + let buttonFrame = button.frame + let menuOrigin = NSPoint(x: buttonFrame.minX, y: buttonFrame.maxY) + menu.popUp(positioning: nil, at: menuOrigin, in: button.superview) + detailPanel.orderOut(nil) + } + + private func createMenu( + allCopilotModels: [LLMModel], + allBYOKModels: [LLMModel] + ) -> NSMenu { + let menu = ModelPickerNSMenu() + menu.autoenablesItems = false + + let maxWidth = calculateMaxWidth( + copilotModels: allCopilotModels, + byokModels: allBYOKModels + ) + + // Search bar at top (sized to match content) + let searchItem = NSMenuItem() + let searchView = ModelSearchFieldView(fontScale: fontScale, width: maxWidth) + searchView.parentMenu = menu + searchItem.view = searchView + menu.addItem(searchItem) + menu.searchField = searchView.searchField + + // Separator after search + menu.addItem(.separator()) + + // Build initial menu items + rebuildMenuItems( + menu: menu, + copilotModels: allCopilotModels, + byokModels: allBYOKModels, + maxWidth: maxWidth, + searchText: "" + ) + + // Handle search + searchView.onSearchTextChanged = { [weak menu] searchText in + guard let menu = menu else { return } + self.rebuildMenuItems( + menu: menu, + copilotModels: allCopilotModels, + byokModels: allBYOKModels, + maxWidth: maxWidth, + searchText: searchText + ) + } + + return menu + } + + private func rebuildMenuItems( + menu: NSMenu, + copilotModels: [LLMModel], + byokModels: [LLMModel], + maxWidth: CGFloat, + searchText: String + ) { + // Remove all items except the search bar and separator (first 2 items) + while menu.items.count > 2 { + menu.removeItem(at: menu.items.count - 1) + } + + let query = searchText.lowercased().trimmingCharacters(in: .whitespaces) + + let filteredCopilotModels: [LLMModel] + let filteredBYOKModels: [LLMModel] + if query.isEmpty { + filteredCopilotModels = copilotModels + filteredBYOKModels = byokModels + } else { + filteredCopilotModels = copilotModels.filter { + ($0.displayName ?? $0.modelName).lowercased().contains(query) + || $0.modelFamily.lowercased().contains(query) + } + filteredBYOKModels = byokModels.filter { + ($0.displayName ?? $0.modelName).lowercased().contains(query) + || $0.modelFamily.lowercased().contains(query) + || ($0.providerName ?? "").lowercased().contains(query) + } + } + + let premiumModels = filteredCopilotModels.filter { $0.isPremiumModel } + let standardModels = filteredCopilotModels.filter { + $0.isStandardModel && !$0.isAutoModel + } + let autoModel = filteredCopilotModels.first(where: { $0.isAutoModel }) + + // Auto model + if let autoModel = autoModel { + addModelItem( + to: menu, model: autoModel, maxWidth: maxWidth + ) + } + + // Standard models section + addSection( + to: menu, title: "Standard Models", models: standardModels, + maxWidth: maxWidth + ) + + // Premium models section + addSection( + to: menu, title: "Premium Models", models: premiumModels, + maxWidth: maxWidth + ) + + // BYOK models section + if isBYOKFFEnabled { + addSection( + to: menu, title: "Other Models", models: filteredBYOKModels, + maxWidth: maxWidth + ) + + if query.isEmpty { + menu.addItem(.separator()) + let manageItem = NSMenuItem( + title: "Manage Models...", + action: #selector(ModelPickerMenuActions.manageModels), + keyEquivalent: "" + ) + manageItem.target = ModelPickerMenuActions.shared + menu.addItem(manageItem) + } + } + + if standardModels.isEmpty, premiumModels.isEmpty, autoModel == nil, + filteredBYOKModels.isEmpty + { + if query.isEmpty { + let addItem = NSMenuItem( + title: "Add Premium Models", + action: #selector(ModelPickerMenuActions.addPremiumModels), + keyEquivalent: "" + ) + addItem.target = ModelPickerMenuActions.shared + menu.addItem(addItem) + } else { + let noResults = NSMenuItem(title: "No models found", action: nil, keyEquivalent: "") + noResults.isEnabled = false + menu.addItem(noResults) + } + } + } + + private func addSection( + to menu: NSMenu, + title: String, + models: [LLMModel], + maxWidth: CGFloat + ) { + guard !models.isEmpty else { return } + + // Section header + menu.addItem(.separator()) + let headerItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") + headerItem.isEnabled = false + let headerFont = NSFont.systemFont(ofSize: 11 * fontScale, weight: .semibold) + headerItem.attributedTitle = NSAttributedString( + string: title, + attributes: [ + .font: headerFont, + .foregroundColor: NSColor.secondaryLabelColor, + ] + ) + menu.addItem(headerItem) + + for model in models { + addModelItem(to: menu, model: model, maxWidth: maxWidth) + } + } + + private func addModelItem( + to menu: NSMenu, + model: LLMModel, + maxWidth: CGFloat + ) { + let item = NSMenuItem() + let multiplierText = currentCache + .modelMultiplierCache[model.id.appending(model.providerName ?? "")] + ?? ModelMenuItemFormatter.getMultiplierText(for: model) + + let menuItemView = ModelPickerMenuItem( + model: model, + isSelected: selectedModel == model, + multiplierText: multiplierText, + fontScale: fontScale, + fixedWidth: maxWidth, + onSelect: { + AppState.shared.setSelectedModel(model) + menu.cancelTracking() + self.detailPanel.orderOut(nil) + }, + onHover: { hoveredModel, itemRect in + self.detailPanel.show( + for: hoveredModel, + nearRect: itemRect, + fontScale: self.fontScale + ) + }, + onHoverExit: { + self.detailPanel.scheduleHide() + } + ) + item.view = menuItemView + menu.addItem(item) + } + + private func calculateMaxWidth( + copilotModels: [LLMModel], + byokModels: [LLMModel] + ) -> CGFloat { + var maxWidth: CGFloat = 0 + let allModels = isBYOKFFEnabled ? copilotModels + byokModels : copilotModels + + for model in allModels { + let multiplierText = currentCache + .modelMultiplierCache[model.id.appending(model.providerName ?? "")] + ?? ModelMenuItemFormatter.getMultiplierText(for: model) + let width = ModelPickerMenuItem.calculateItemWidth( + model: model, + multiplierText: multiplierText, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + return maxWidth + } +} + +// MARK: - Menu Action Target + +private class ModelPickerMenuActions: NSObject { + static let shared = ModelPickerMenuActions() + + @objc func manageModels() { + try? launchHostAppBYOKSettings() + } + + @objc func addPremiumModels() { + if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift new file mode 100644 index 00000000..a395256e --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift @@ -0,0 +1,296 @@ +import AppKit + +// MARK: - Model Menu Item View + +class ModelPickerMenuItem: NSView { + private let fontScale: Double + private let model: LLMModel + private let isSelected: Bool + private let multiplierText: String + private let onSelect: () -> Void + private let onHover: ((LLMModel, NSRect) -> Void)? + private let onHoverExit: (() -> Void)? + + private var wasHighlighted = false + + private let nameLabel = NSTextField(labelWithString: "") + private let multiplierLabel = NSTextField(labelWithString: "") + private let checkmarkImageView = NSImageView() + private let warningImageView = NSImageView() + + private struct LayoutConstants { + let fontScale: Double + + var menuHeight: CGFloat { 22 * fontScale } + var checkmarkSize: CGFloat { 13 * fontScale } + var hoverEdgeInset: CGFloat { 5 * fontScale } + var fontSize: CGFloat { 13 * fontScale } + var leadingPadding: CGFloat { 9 * fontScale } + var trailingPadding: CGFloat { 9 * fontScale } + var checkmarkToText: CGFloat { 5 * fontScale } + var nameToMultiplier: CGFloat { 8 * fontScale } + } + + private lazy var constants = LayoutConstants(fontScale: fontScale) + + init( + model: LLMModel, + isSelected: Bool, + multiplierText: String, + fontScale: Double, + fixedWidth: CGFloat, + onSelect: @escaping () -> Void, + onHover: ((LLMModel, NSRect) -> Void)? = nil, + onHoverExit: (() -> Void)? = nil + ) { + self.model = model + self.isSelected = isSelected + self.multiplierText = multiplierText + self.fontScale = fontScale + self.onSelect = onSelect + self.onHover = onHover + self.onHoverExit = onHoverExit + + let constants = LayoutConstants(fontScale: fontScale) + super.init( + frame: NSRect(x: 0, y: 0, width: fixedWidth, height: constants.menuHeight) + ) + setupView() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Highlight state (driven by NSMenu) + + private var isHighlighted: Bool { + enclosingMenuItem?.isHighlighted ?? false + } + + private func setupView() { + wantsLayer = true + layer?.masksToBounds = true + + setupCheckmark() + setupWarningIcon() + setupLabels() + } + + private func setupCheckmark() { + let config = NSImage.SymbolConfiguration( + pointSize: constants.checkmarkSize, + weight: .medium + ) + checkmarkImageView.image = NSImage( + systemSymbolName: "checkmark", + accessibilityDescription: nil + )?.withSymbolConfiguration(config) + checkmarkImageView.contentTintColor = .labelColor + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + checkmarkImageView.isHidden = !isSelected || model.degradationReason != nil + addSubview(checkmarkImageView) + + NSLayoutConstraint.activate([ + checkmarkImageView.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: constants.leadingPadding + ), + checkmarkImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + checkmarkImageView.widthAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + checkmarkImageView.heightAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + ]) + } + + private func setupWarningIcon() { + guard model.degradationReason != nil else { return } + + let config = NSImage.SymbolConfiguration( + pointSize: constants.checkmarkSize, + weight: .medium + ) + warningImageView.image = NSImage( + systemSymbolName: "exclamationmark.triangle", + accessibilityDescription: "Degraded" + )?.withSymbolConfiguration(config) + warningImageView.contentTintColor = .labelColor + warningImageView.translatesAutoresizingMaskIntoConstraints = false + warningImageView.isHidden = false + addSubview(warningImageView) + + NSLayoutConstraint.activate([ + warningImageView.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: constants.leadingPadding + ), + warningImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + warningImageView.widthAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + warningImageView.heightAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + ]) + } + + private func setupLabels() { + let displayName = model.displayName ?? model.modelName + + // Name label — left-aligned, truncates tail, fills remaining space + nameLabel.stringValue = displayName + nameLabel.font = NSFont.systemFont(ofSize: constants.fontSize, weight: .regular) + nameLabel.textColor = .labelColor + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.backgroundColor = .clear + nameLabel.drawsBackground = false + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + addSubview(nameLabel) + + // Multiplier label — right-aligned, never truncates + multiplierLabel.stringValue = multiplierText + multiplierLabel.font = NSFont.systemFont( + ofSize: constants.fontSize, weight: .regular + ) + multiplierLabel.textColor = .secondaryLabelColor + multiplierLabel.isEditable = false + multiplierLabel.isBordered = false + multiplierLabel.backgroundColor = .clear + multiplierLabel.drawsBackground = false + multiplierLabel.alignment = .right + multiplierLabel.translatesAutoresizingMaskIntoConstraints = false + multiplierLabel.setContentHuggingPriority(.required, for: .horizontal) + multiplierLabel.setContentCompressionResistancePriority( + .required, for: .horizontal + ) + multiplierLabel.isHidden = multiplierText.isEmpty + addSubview(multiplierLabel) + + let textLeading = checkmarkImageView.trailingAnchor + + NSLayoutConstraint.activate([ + nameLabel.leadingAnchor.constraint( + equalTo: textLeading, constant: constants.checkmarkToText + ), + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + multiplierLabel.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -constants.trailingPadding + ), + multiplierLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + nameLabel.trailingAnchor.constraint( + lessThanOrEqualTo: multiplierLabel.leadingAnchor, + constant: -constants.nameToMultiplier + ), + ]) + } + + // MARK: - Mouse handling + + override func mouseUp(with _: NSEvent) { + onSelect() + } + + // MARK: - Keyboard selection + + /// Called by the menu's `performKeyEquivalent` when Return/Enter is pressed + /// while this item is highlighted. Custom-view menu items don't receive + /// the default NSMenu action, so the menu triggers selection explicitly. + func performSelect() { + onSelect() + } + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + let confirmKeyCodes: Set = [ + 36, // return + 76, // enter (numpad) + ] + if confirmKeyCodes.contains(event.keyCode) { + onSelect() + } else { + super.keyDown(with: event) + } + } + + // MARK: - Drawing (highlight driven by NSMenu) + + private func updateColors() { + let highlighted = isHighlighted + if highlighted { + nameLabel.textColor = .white + multiplierLabel.textColor = .white.withAlphaComponent(0.8) + checkmarkImageView.contentTintColor = .white + warningImageView.contentTintColor = .white + } else { + nameLabel.textColor = .labelColor + multiplierLabel.textColor = .secondaryLabelColor + checkmarkImageView.contentTintColor = .labelColor + warningImageView.contentTintColor = .labelColor + } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let highlighted = isHighlighted + + // Trigger detail panel on highlight change + if highlighted != wasHighlighted { + wasHighlighted = highlighted + if highlighted { + if let onHover = onHover { + let screenRect = + window?.convertToScreen(convert(bounds, to: nil)) ?? .zero + onHover(model, screenRect) + } + } else { + onHoverExit?() + } + } + + updateColors() + + if highlighted { + ModelMenuItemFormatter.drawMenuItemHighlight( + in: frame, + fontScale: fontScale, + hoverEdgeInset: constants.hoverEdgeInset + ) + } + } + + // MARK: - Width Calculation + + static func calculateItemWidth( + model: LLMModel, + multiplierText: String, + fontScale: Double + ) -> CGFloat { + let constants = LayoutConstants(fontScale: fontScale) + let font = NSFont.systemFont(ofSize: constants.fontSize, weight: .regular) + let attrs: [NSAttributedString.Key: Any] = [.font: font] + let displayName = model.displayName ?? model.modelName + let nameWidth = (displayName as NSString).size(withAttributes: attrs).width + + var width = constants.leadingPadding + constants.checkmarkSize + + constants.checkmarkToText + ceil(nameWidth) + constants.trailingPadding + + if !multiplierText.isEmpty { + let multWidth = ceil( + (multiplierText as NSString).size(withAttributes: attrs).width + ) + width += constants.nameToMultiplier + multWidth + } + + return width + } +} diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index 7ac6ef3c..7f6725a5 100644 --- a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift +++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift @@ -161,8 +161,7 @@ struct RunInTerminalToolView: View { .scaledFont(.body) } - if #available(macOS 13.0, *), - FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && + if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled, let command, !command.isEmpty { SplitButton( @@ -191,7 +190,6 @@ struct RunInTerminalToolView: View { } } - @available(macOS 13.0, *) private func terminalMenuItems(command: String) -> [SplitButtonMenuItem] { var items: [SplitButtonMenuItem] = [] diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index c690a208..fcc5ad9a 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -126,7 +126,10 @@ struct BotMessage: View { HStack { if shouldShowTurnStatus() { - TurnStatusView(message: message) + TurnStatusView( + message: message, + isSummarizingConversation: chat.isSummarizingConversation + ) .modify { view in if message.turnStatus == .inProgress { view @@ -241,14 +244,17 @@ struct BotMessage: View { } private struct TurnStatusView: View { - + let message: DisplayedChatMessage - + let isSummarizingConversation: Bool + @AppStorage(\.chatFontSize) var chatFontSize - + var body: some View { HStack(spacing: 0) { - if let turnStatus = message.turnStatus { + if isSummarizingConversation { + summarizingStatus + } else if let turnStatus = message.turnStatus { switch turnStatus { case .inProgress: inProgressStatus @@ -271,12 +277,25 @@ private struct TurnStatusView: View { .controlSize(.small) .scaledScaleEffect(0.7) .scaledFrame(width: 16, height: 16) - + Text("Generating...") .scaledFont(size: chatFontSize - 1) .foregroundColor(.secondary) } } + + private var summarizingStatus: some View { + HStack(spacing: 4) { + ProgressView() + .controlSize(.small) + .scaledScaleEffect(0.7) + .scaledFrame(width: 16, height: 16) + + Text("Summarizing conversation...") + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + } + } private var completedStatus: some View { statusView(icon: "checkmark.circle.fill", iconColor: .successLightGreen, text: "Completed") diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift index 251f4022..346981ef 100644 --- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift @@ -19,22 +19,15 @@ struct ChatPanelInputArea: View { Button(action: { chat.send(.clearButtonTap) }) { - Group { - if #available(macOS 13.0, *) { - Image(systemName: "eraser.line.dashed.fill") - .scaledFont(.body) - } else { - Image(systemName: "trash.fill") - .scaledFont(.body) + Image(systemName: "eraser.line.dashed.fill") + .scaledFont(.body) + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) } - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) - } } .buttonStyle(.plain) } diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift new file mode 100644 index 00000000..fdf3be11 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift @@ -0,0 +1,196 @@ +import ConversationServiceProvider +import SharedUIComponents +import SwiftUI + +struct ContextSizeButton: View { + let contextSizeInfo: ContextSizeInfo + @State private var isHovering = false + @State private var showPopover = false + @State private var isClickTriggered = false + @State private var hoverTask: Task? + @State private var dismissTask: Task? + + private let ringSize: CGFloat = 11 + private let lineWidth: CGFloat = 1.5 + + var body: some View { + Button(action: { + hoverTask?.cancel() + dismissTask?.cancel() + isClickTriggered = true + showPopover = true + }) { + HStack(spacing: 4) { + DonutChart( + percentage: contextSizeInfo.utilizationPercentage, + ringColor: ringColor, + size: ringSize, + lineWidth: lineWidth + ) + + if isHovering { + Text("\(Int(contextSizeInfo.utilizationPercentage))%") + .scaledFont(size: 11, weight: .medium) + .foregroundColor(.primary) + .transition(.opacity) + } + } + .scaledPadding(.horizontal, 6) + .scaledPadding(.vertical, 4) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .accessibilityLabel("Context size") + .accessibilityValue(Text("\(Int(contextSizeInfo.utilizationPercentage)) percent of context tokens used")) + .accessibilityHint("Shows details about the current context size and token usage.") + .animation(.easeInOut(duration: 0.15), value: isHovering) + .onHover { hovering in + isHovering = hovering + hoverTask?.cancel() + if hovering { + dismissTask?.cancel() + hoverTask = Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + isClickTriggered = false + showPopover = true + } + } else if !isClickTriggered { + scheduleDismiss() + } + } + .onChange(of: showPopover) { newValue in + if !newValue { + isClickTriggered = false + } + } + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + ContextSizePopover(info: contextSizeInfo) + .onHover { hovering in + if hovering { + dismissTask?.cancel() + } else if !isClickTriggered { + scheduleDismiss() + } + } + } + } + + private func scheduleDismiss() { + dismissTask?.cancel() + dismissTask = Task { + try? await Task.sleep(nanoseconds: 200_000_000) + guard !Task.isCancelled else { return } + showPopover = false + } + } + + private var ringColor: Color { + let pct = contextSizeInfo.utilizationPercentage + if pct >= 80 { return Color("WarningYellow") } + return .secondary + } +} + +private struct DonutChart: View { + let percentage: Double + let ringColor: Color + let size: CGFloat + let lineWidth: CGFloat + + var body: some View { + ZStack { + Circle() + .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: min(percentage / 100, 1.0)) + .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .scaledFrame(width: size, height: size) + } +} + +private struct ContextSizePopover: View { + let info: ContextSizeInfo + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // MARK: Context Window + VStack(alignment: .leading, spacing: 6) { + Text("Context Window") + .scaledFont(.headline) + + HStack { + Text("\(formatTokens(info.totalUsedTokens)) / \(formatTokens(info.totalTokenLimit)) tokens") + Spacer() + Text(formatPercentage(info.utilizationPercentage)) + } + .scaledFont(.callout) + + ProgressView(value: min(info.utilizationPercentage, 100), total: 100) + .tint(progressColor) + } + + // MARK: System + VStack(alignment: .leading, spacing: 4) { + Text("System") + .scaledFont(.headline) + .scaledPadding(.bottom, 4) + + tokenRow("System Instructions", tokens: info.systemPromptTokens) + tokenRow("Tool Definitions", tokens: info.toolDefinitionTokens) + } + + // MARK: User + VStack(alignment: .leading, spacing: 4) { + Text("User") + .scaledFont(.headline) + .scaledPadding(.bottom, 4) + + tokenRow("Messages", tokens: info.userMessagesTokens + info.assistantMessagesTokens) + tokenRow("Attached Files", tokens: info.attachedFilesTokens) + tokenRow("Tool Results", tokens: info.toolResultsTokens) + } + + // TODO: Depends on CLS for manual compression + } + .scaledPadding(.vertical, 20) + .scaledPadding(.horizontal, 16) + .scaledFrame(width: 240) + } + + private var progressColor: Color { + let pct = info.utilizationPercentage + if pct >= 80 { return Color(nsColor: .systemYellow) } + return .accentColor + } + + private func tokenRow(_ label: String, tokens: Int) -> some View { + HStack { + Text(label) + Spacer() + Text(percentage(for: tokens)) + } + .scaledFont(.callout) + } + + private func percentage(for tokens: Int) -> String { + guard info.totalTokenLimit > 0 else { return "0%" } + let pct = Double(tokens) / Double(info.totalTokenLimit) * 100 + return formatPercentage(pct) + } + + private func formatPercentage(_ pct: Double) -> String { + if pct == 0 { return "0%" } + return String(format: "%.1f%%", pct) + } + + private func formatTokens(_ count: Int) -> String { + if count >= 1000 { + let k = Double(count) / 1000.0 + return String(format: "%.1fK", k) + } + return "\(count)" + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift index 88373b42..0659dbf8 100644 --- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift @@ -173,13 +173,18 @@ struct InputAreaTextEditor: View { ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent) Spacer() - - if chat.editorMode.isDefault { + + if let contextSizeInfo = chat.contextSizeInfo { + ContextSizeButton(contextSizeInfo: contextSizeInfo) + .padding(.trailing, 4) + } + + if chat.editorMode.isDefault && !isRequestingConversation { codeReviewButton .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .clear)) - .opacity(isRequestingConversation ? 0 : 1) + .padding(.trailing, 4) } - + ZStack { sendButton .opacity(isRequestingConversation || isRequestingCodeReview ? 0 : 1) diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift index 95f0e91a..fc970828 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift @@ -103,8 +103,7 @@ struct ToolConfirmationView: View { @ViewBuilder private var confirmationActionView: some View { - if #available(macOS 13.0, *), - FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && + if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled { if tool.isToolcallingLoopContinueTool { continueButton @@ -150,7 +149,6 @@ struct ToolConfirmationView: View { .buttonStyle(.borderedProminent) } - @available(macOS 13.0, *) private var sensitiveFileMenuItems: [SplitButtonMenuItem] { var items: [SplitButtonMenuItem] = [] @@ -200,7 +198,6 @@ struct ToolConfirmationView: View { return items } - @available(macOS 13.0, *) private var sensitiveFileSplitButton: some View { SplitButton( title: "Allow", @@ -213,7 +210,6 @@ struct ToolConfirmationView: View { ) } - @available(macOS 13.0, *) private func mcpMenuItems(serverName: String) -> [SplitButtonMenuItem] { var items: [SplitButtonMenuItem] = [] @@ -288,7 +284,6 @@ struct ToolConfirmationView: View { return items } - @available(macOS 13.0, *) private func mcpSplitButton(serverName: String) -> some View { SplitButton( title: "Allow", diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index fc44276e..a8910979 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -36,30 +36,31 @@ struct ChatSection: View { .padding(SettingsToggle.defaultPadding) Divider() + + } - if featureFlags.isAgentModeEnabled && copilotPolicy.isCustomAgentEnabled { - // Custom Agents - .github/agents/*.agent.md - AgentFileSetting(promptType: .agent) - .padding(SettingsToggle.defaultPadding) - - Divider() - - // SubAgent toggle - SettingsToggle( - title: "Enable Subagent", - subtitle: "Allows Copilot Agent mode to call custom agents as subagent. Requires GitHub Copilot for Xcode restart to take effect.", - isOn: Binding( - get: { enableSubagent && copilotPolicy.isSubagentEnabled }, - set: { if copilotPolicy.isSubagentEnabled { enableSubagent = $0 } } - ), - badge: copilotPolicy.isSubagentEnabled - ? nil - : .disabledByPolicy(feature: "Subagents", isPlural: true) - ) - .disabled(!copilotPolicy.isSubagentEnabled) + if featureFlags.isAgentModeEnabled && copilotPolicy.isCustomAgentEnabled { + // Custom Agents - .github/agents/*.agent.md + AgentFileSetting(promptType: .agent) + .padding(SettingsToggle.defaultPadding) - Divider() - } + Divider() + + // SubAgent toggle + SettingsToggle( + title: "Enable Subagent", + subtitle: "Allows Copilot Agent mode to call custom agents as subagent. Requires GitHub Copilot for Xcode restart to take effect.", + isOn: Binding( + get: { enableSubagent && copilotPolicy.isSubagentEnabled }, + set: { if copilotPolicy.isSubagentEnabled { enableSubagent = $0 } } + ), + badge: copilotPolicy.isSubagentEnabled + ? nil + : .disabledByPolicy(feature: "Subagents", isPlural: true) + ) + .disabled(!copilotPolicy.isSubagentEnabled) + + Divider() } // Auto Attach toggle @@ -88,13 +89,18 @@ struct ChatSection: View { FontSizeSetting() .padding(SettingsToggle.defaultPadding) + Divider() + if featureFlags.isAgentModeEnabled { - Divider() - // Agent Max Tool Calling Requests AgentMaxToolCallLoopSetting() .padding(SettingsToggle.defaultPadding) + + Divider() } + + // Auto Compress + AgentAutoCompressSetting() } } } @@ -336,6 +342,26 @@ struct AgentMaxToolCallLoopSetting: View { } } +struct AgentAutoCompressSetting: View { + @AppStorage(\.autoCompress) var autoCompress + + var body: some View { + SettingsToggle( + title: "Auto Compress", + subtitle: "Automatically compact the conversation history to save contect tokens.", + isOn: Binding( + get: { autoCompress }, + set: { + autoCompress = $0 + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentAutoCompressDidChange, object: nil) + } + ) + ) + } +} + struct CopilotInstructionSetting: View { @State var isGlobalInstructionsViewOpen = false @Environment(\.toast) var toast diff --git a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift index d869b9ca..2ccbdd71 100644 --- a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift +++ b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift @@ -5,13 +5,8 @@ import SharedUIComponents extension List { @ViewBuilder func removeBackground() -> some View { - if #available(macOS 13.0, *) { - scrollContentBackground(.hidden) - .listRowBackground(EmptyView()) - } else { - background(Color.clear) - .listRowBackground(EmptyView()) - } + scrollContentBackground(.hidden) + .listRowBackground(EmptyView()) } } @@ -80,11 +75,7 @@ struct DisabledLanguageList: View { } } .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) } } .removeBackground() diff --git a/Core/Sources/HostApp/ToolsConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift index 7dbb1ba1..6ece9ade 100644 --- a/Core/Sources/HostApp/ToolsConfigView.swift +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -23,7 +23,7 @@ struct MCPConfigView: View { @Environment(\.colorScheme) var colorScheme private var isCustomAgentEnabled: Bool { - featureFlags.isEditorPreviewEnabled && copilotPolicy.isCustomAgentEnabled + copilotPolicy.isCustomAgentEnabled } private static var lastSyncTimestamp: Date? = nil @@ -64,6 +64,8 @@ struct MCPConfigView: View { MCPRegistryURLView() } + MCPXcodeServerInstallView() + MCPToolsListView( selectedMode: $selectedMode, isCustomAgentEnabled: isCustomAgentEnabled diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift index c54712ca..d3ebbc0c 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift @@ -304,7 +304,7 @@ public class MCPRegistryService: ObservableObject { // Save configuration let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted]) - try jsonData.write(to: configFileURL) + try jsonData.write(to: configFileURL, options: .atomic) // Note: UserDefaults update and notification will be handled by ToolsConfigView's file monitor // with debouncing to prevent duplicate notifications diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift index 6c086ba6..b462519a 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift @@ -4,7 +4,6 @@ import GitHubCopilotService import SharedUIComponents import Foundation -@available(macOS 13.0, *) struct MCPServerDetailSheet: View { let server: MCPRegistryServerDetail let meta: ServerMeta? diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift index 31e138fa..0082b480 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift @@ -287,38 +287,28 @@ struct MCPServerGalleryView: View { .buttonStyle(DestructiveButtonStyle()) .help("Uninstall") } else { - if #available(macOS 13.0, *) { - SplitButton( - title: "Install", - isDisabled: viewModel.hasNoDeployments(response.server), - primaryAction: { - // Install with default configuration - Task { - await viewModel.installServer(response.server) - } - }, - menuItems: { - let options = viewModel.getInstallationOptions(for: response.server) - guard !options.isEmpty else { return [] } - return [SplitButtonMenuItem.header("Install Server With")] + options.map { option in - SplitButtonMenuItem(title: option.displayName) { - Task { - await viewModel.installServer(response.server, configuration: option.displayName) - } - } - } - }() - ) - .help("Install") - } else { - Button("Install") { + SplitButton( + title: "Install", + isDisabled: viewModel.hasNoDeployments(response.server), + primaryAction: { + // Install with default configuration Task { await viewModel.installServer(response.server) } - } - .disabled(viewModel.hasNoDeployments(response.server)) - .help("Install") - } + }, + menuItems: { + let options = viewModel.getInstallationOptions(for: response.server) + guard !options.isEmpty else { return [] } + return [SplitButtonMenuItem.header("Install Server With")] + options.map { option in + SplitButtonMenuItem(title: option.displayName) { + Task { + await viewModel.installServer(response.server, configuration: option.displayName) + } + } + } + }() + ) + .help("Install") } Button { @@ -340,11 +330,7 @@ struct MCPServerGalleryView: View { } private func infoSheet(_ response: MCPRegistryServerResponse) -> some View { - if #available(macOS 13.0, *) { - return AnyView(MCPServerDetailSheet(response: response)) - } else { - return AnyView(EmptyView()) - } + MCPServerDetailSheet(response: response) } } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift new file mode 100644 index 00000000..3727111d --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift @@ -0,0 +1,224 @@ +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI +import SystemUtils + +struct MCPXcodeServerInstallView: View { + @State private var xcodeVersion: String? = SystemUtils.xcodeVersion + @State private var isConfigured: Bool = false + @State private var isInstalling: Bool = false + @State private var installError: String? = nil + /// Server names from mcp.json whose config matches xcrun mcpbridge. + /// Cached to avoid repeated file I/O during SwiftUI rendering. + @State private var configuredXcodeServerNames: Set = [] + @ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared + + private let requiredXcodeVersion = "26.4" + private let serverName = "xcode" + + private var meetsVersionRequirement: Bool { + guard let version = xcodeVersion else { return false } + return version.compare(requiredXcodeVersion, options: .numeric) != .orderedAscending + } + + private var isConnected: Bool { + mcpToolManager.availableMCPServerTools.contains { server in + configuredXcodeServerNames.contains(server.name) && + server.status == .running && + !server.tools.isEmpty + } + } + + /// Configured in mcp.json but not yet showing in available tools from the language server + private var isConfiguredButNotConnected: Bool { + isConfigured && !isConnected + } + + private var isAlreadyInstalled: Bool { + isConfigured || isConnected + } + + var body: some View { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { + Text("Xcode MCP Server") + .font(.headline) + .padding(.vertical, 4) + + subtitleView() + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + actionsView() + .padding(.vertical, 12) + } + .padding(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)) + .background(QuaternarySystemFillColor.opacity(0.75)) + .settingsContainerStyle(isExpanded: false) + .onAppear { + checkInstallationStatus() + } + .onChange(of: mcpToolManager.availableMCPServerTools) { _ in + checkInstallationStatus() + } + } + + // MARK: - Subviews + + @ViewBuilder + private func subtitleView() -> some View { + if !meetsVersionRequirement { + let versionText = xcodeVersion ?? "unknown" + Text("Requires Xcode \(requiredXcodeVersion) or later. Current version: \(versionText).") + } else if isConnected { + Text("Xcode's built-in MCP server is connected, enabling richer editor integration.") + } else if isConfiguredButNotConnected { + Text("Please confirm in Xcode to allow the built-in MCP server.") + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Connect Copilot to Xcode’s built‑in MCP server to enable richer editor integration.") + if let installError { + Text(installError) + .font(.caption) + .foregroundColor(.red) + } + } + } + } + + @ViewBuilder + private func actionsView() -> some View { + if !meetsVersionRequirement { + EmptyView() + } else if isConnected { + Text("Connected").foregroundColor(.secondary) + } else if isConfiguredButNotConnected { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Waiting for connection...") + .foregroundColor(.secondary) + } + } else { + Button { + installXcodeMCPServer() + } label: { + HStack(spacing: 4) { + if isInstalling { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "plus.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(2) + } + Text("Install") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .disabled(isInstalling) + } + } + + // MARK: - Actions + + private func checkInstallationStatus() { + let (configured, names) = readXcodeMCPServerNamesFromConfig() + isConfigured = configured + configuredXcodeServerNames = names + } + + /// Returns (isConfigured, setOfMatchingServerNames) by reading mcp.json once. + private func readXcodeMCPServerNamesFromConfig() -> (Bool, Set) { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: configFileURL.path), + let data = try? Data(contentsOf: configFileURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let servers = json["servers"] as? [String: Any] + else { + return (false, []) + } + + var names = Set() + for (key, value) in servers { + guard let serverConfig = value as? [String: Any] else { continue } + let command = serverConfig["command"] as? String ?? "" + let args = serverConfig["args"] as? [String] ?? [] + if command.contains("xcrun") && args.contains(where: { $0.contains("mcpbridge") }) { + names.insert(key) + } + } + return (!names.isEmpty, names) + } + + private func installXcodeMCPServer() { + isInstalling = true + installError = nil + + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + let fileManager = FileManager.default + + do { + if !fileManager.fileExists(atPath: configDirectory.path) { + try fileManager.createDirectory( + at: configDirectory, + withIntermediateDirectories: true + ) + } + + var config: [String: Any] + if fileManager.fileExists(atPath: configFileURL.path), + let data = try? Data(contentsOf: configFileURL), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + config = existing + } else { + config = ["servers": [String: Any]()] + } + + var servers = config["servers"] as? [String: Any] ?? [:] + + // Skip write if the entry already points to xcrun mcpbridge + if let existing = servers[serverName] as? [String: Any], + let command = existing["command"] as? String, + let args = existing["args"] as? [String], + command.contains("xcrun") && args.contains(where: { $0.contains("mcpbridge") }) + { + isConfigured = true + configuredXcodeServerNames.insert(serverName) + isInstalling = false + return + } + + servers[serverName] = [ + "type": "stdio", + "command": "xcrun", + "args": ["mcpbridge"] + ] + + config["servers"] = servers + + let jsonData = try JSONSerialization.data( + withJSONObject: config, + options: [.prettyPrinted, .sortedKeys] + ) + try jsonData.write(to: configFileURL, options: .atomic) + + isConfigured = true + configuredXcodeServerNames.insert(serverName) + Logger.client.info("Successfully added Xcode MCP Server to configuration") + } catch { + installError = "Failed to update configuration: \(error.localizedDescription)" + Logger.client.error("Failed to install Xcode MCP Server: \(error)") + } + + isInstalling = false + } +} diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index c311439d..1cee8bf2 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -24,17 +24,10 @@ public struct LaunchAgentManager { } public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { - if #available(macOS 13, *) { - await removeObsoleteLaunchAgent() - try await setupLaunchAgent() - } else { - guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } - try await setupLaunchAgent() - await removeObsoleteLaunchAgent() - } + await removeObsoleteLaunchAgent() + try await setupLaunchAgent() } - @available(macOS 13.0, *) public func isBackgroundPermissionGranted() async -> Bool { // On macOS 13+, check SMAppService status let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") @@ -43,87 +36,41 @@ public struct LaunchAgentManager { } public func setupLaunchAgent() async throws { - if #available(macOS 13, *) { - Logger.client.info("Registering bridge launch agent") - let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") - try bridgeLaunchAgent.register() - } else { - Logger.client.info("Creating and loading bridge launch agent") - let content = """ - - - - - Label - \(serviceIdentifier) - Program - \(executablePath) - MachServices - - \(serviceIdentifier) - - - AssociatedBundleIdentifiers - - \(bundleIdentifier) - \(serviceIdentifier) - - - - """ - if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { - try FileManager.default.createDirectory( - at: launchAgentDirURL, - withIntermediateDirectories: false - ) - } - FileManager.default.createFile( - atPath: launchAgentPath, - contents: content.data(using: .utf8) - ) - try await launchctl("load", launchAgentPath) - } + Logger.client.info("Registering bridge launch agent") + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try bridgeLaunchAgent.register() let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey) } public func removeLaunchAgent() async throws { - if #available(macOS 13, *) { - Logger.client.info("Unregistering bridge launch agent") - let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") - try await bridgeLaunchAgent.unregister() - } else { - Logger.client.info("Unloading and removing bridge launch agent") - try await launchctl("unload", launchAgentPath) - try FileManager.default.removeItem(atPath: launchAgentPath) - } + Logger.client.info("Unregistering bridge launch agent") + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try await bridgeLaunchAgent.unregister() } public func reloadLaunchAgent() async throws { - if #unavailable(macOS 13) { - Logger.client.info("Reloading bridge launch agent") - try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) - } + // No-op: macOS 13+ uses SMAppService which doesn't need manual reload } public func removeObsoleteLaunchAgent() async { - if #available(macOS 13, *) { - let path = launchAgentPath - if FileManager.default.fileExists(atPath: path) { - Logger.client.info("Unloading and removing old bridge launch agent") - try? await launchctl("unload", path) - try? FileManager.default.removeItem(atPath: path) - } - } else { - let path = launchAgentPath.replacingOccurrences( - of: "ExtensionService", - with: "XPCService" - ) - if FileManager.default.fileExists(atPath: path) { - Logger.client.info("Removing old bridge launch agent plist") - try? FileManager.default.removeItem(atPath: path) - } + let path = launchAgentPath + if FileManager.default.fileExists(atPath: path) { + Logger.client.info("Unloading and removing old bridge launch agent") + try? await launchctl("unload", path) + try? FileManager.default.removeItem(atPath: path) + } + + // Also remove legacy plist that used "XPCService" instead of "ExtensionService" + let legacyIdentifier = serviceIdentifier + .replacingOccurrences(of: "ExtensionService", with: "XPCService") + let legacyPath = launchAgentDirURL + .appendingPathComponent("\(legacyIdentifier).plist").path + if FileManager.default.fileExists(atPath: legacyPath) { + Logger.client.info("Unloading and removing legacy XPCService launch agent") + try? await launchctl("unload", legacyPath) + try? FileManager.default.removeItem(atPath: legacyPath) } } } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index b3fd109a..d583d792 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -88,16 +88,9 @@ public actor RealtimeSuggestionController { ) } - if #available(macOS 13.0, *) { - for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { - if Task.isCancelled { return } - await handler() - } - } else { - for await _ in selectedTextChanged { - if Task.isCancelled { return } - await handler() - } + for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() } } diff --git a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift index fd423990..7e1fa514 100644 --- a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift +++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift @@ -1063,7 +1063,8 @@ struct AgentConfigurationWidgetView: View { Text(createModelMenuItemAttributedString( modelName: model.displayName ?? model.modelName, isSelected: isModelSelected(model), - multiplierText: modelCache[model.modelName] ?? "Variable" + multiplierText: modelCache[model.modelName] ?? "Variable", + isDegraded: model.degradationReason != nil )) } @@ -1078,7 +1079,8 @@ struct AgentConfigurationWidgetView: View { Text(createModelMenuItemAttributedString( modelName: model.displayName ?? model.modelName, isSelected: isModelSelected(model), - multiplierText: modelCache[model.modelName] ?? "" + multiplierText: modelCache[model.modelName] ?? "", + isDegraded: model.degradationReason != nil )) } } @@ -1173,13 +1175,15 @@ struct AgentConfigurationWidgetView: View { private func createModelMenuItemAttributedString( modelName: String, isSelected: Bool, - multiplierText: String + multiplierText: String, + isDegraded: Bool = false ) -> AttributedString { return ModelMenuItemFormatter.createModelMenuItemAttributedString( modelName: modelName, isSelected: isSelected, multiplierText: multiplierText, targetWidth: targetMenuItemWidth, + isDegraded: isDegraded ) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 665ccd70..0e1fc9e4 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -232,11 +232,7 @@ struct ChatTitleBar: View { private extension View { func hideScrollIndicator() -> some View { - if #available(macOS 13.0, *) { - return scrollIndicators(.hidden) - } else { - return self - } + scrollIndicators(.hidden) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 682d9c79..9462717f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -332,13 +332,7 @@ extension PromptToCodePanel { codeForegroundColor: codeForegroundColor ) } - .modify { - if #available(macOS 13.0, *) { - $0.scrollIndicators(.hidden) - } else { - $0 - } - } + .scrollIndicators(.hidden) } } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index e790da75..5a2b9c0f 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -240,42 +240,24 @@ private extension WidgetWindowsController { let valueChange = await editor.axNotifications.notifications() .filter { $0.kind == .valueChanged } - if #available(macOS 13.0, *) { - for await notification in merge( - scroll, - selectionRangeChange.debounce(for: Duration.milliseconds(0)), - valueChange.debounce(for: Duration.milliseconds(100)) - ) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } - try Task.checkCancellation() - - // for better looking - if notification.kind == .scrollPositionChanged { - await hideSuggestionPanelWindow() - } + for await notification in merge( + scroll, + selectionRangeChange.debounce(for: Duration.milliseconds(0)), + valueChange.debounce(for: Duration.milliseconds(100)) + ) { + guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + try Task.checkCancellation() - updateWindowLocation(animated: false, immediately: false) - updateWindowOpacity(immediately: false) - await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - - await handleFixErrorEditorNotification(notification: notification) + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() } - } else { - for await notification in merge(selectionRangeChange, scroll, valueChange) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } - try Task.checkCancellation() - // for better looking - if notification.kind == .scrollPositionChanged { - await hideSuggestionPanelWindow() - } + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - updateWindowLocation(animated: false, immediately: false) - updateWindowOpacity(immediately: false) - await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - - await handleFixErrorEditorNotification(notification: notification) - } + await handleFixErrorEditorNotification(notification: notification) } } } diff --git a/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json b/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json new file mode 100644 index 00000000..fb5231df --- /dev/null +++ b/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x04", + "green" : "0x7D", + "red" : "0xC2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x5C", + "green" : "0xC5", + "red" : "0xF2" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift index 02656f85..d47ea0ef 100644 --- a/ExtensionService/XPCController.swift +++ b/ExtensionService/XPCController.swift @@ -59,7 +59,7 @@ final class XPCController: XPCServiceDelegate { // No log, but you should run CommunicationBridge, too. #else if consecutiveFailures == 5 { - if #available(macOS 13.0, *) { + await MainActor.run { showBackgroundPermissionAlert() } } diff --git a/Server/package-lock.json b/Server/package-lock.json index 4ff704de..855a73b1 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,19 +8,19 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "1.411.0", - "@github/copilot-language-server-darwin-arm64": "1.411.0", - "@github/copilot-language-server-darwin-x64": "1.411.0", + "@github/copilot-language-server": "1.451.0", + "@github/copilot-language-server-darwin-arm64": "1.451.0", + "@github/copilot-language-server-darwin-x64": "1.451.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" }, "devDependencies": { "@types/node": "^22.15.17", - "copy-webpack-plugin": "^13.0.1", + "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.2", "style-loader": "^4.0.0", - "terser-webpack-plugin": "^5.3.14", + "terser-webpack-plugin": "^5.4.0", "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", @@ -38,9 +38,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.411.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.411.0.tgz", - "integrity": "sha512-KxuvWq3DT4qTujxtgDQTHmynWawDiwqsRC9BmuBVi5PyzdyejJEj6rgcuwH7WcOMgNQJlHVQmSxN6uYurqp26w==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.451.0.tgz", + "integrity": "sha512-ApkyyC0yz1tx+9Yb17SjG0/jpmIgl3H1EO744Thyg+sCt6AsonJMoNTVUPcx0YxEzzK0HafUWeA/4nacTwnTYg==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -49,17 +49,18 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.411.0", - "@github/copilot-language-server-darwin-x64": "1.411.0", - "@github/copilot-language-server-linux-arm64": "1.411.0", - "@github/copilot-language-server-linux-x64": "1.411.0", - "@github/copilot-language-server-win32-x64": "1.411.0" + "@github/copilot-language-server-darwin-arm64": "1.451.0", + "@github/copilot-language-server-darwin-x64": "1.451.0", + "@github/copilot-language-server-linux-arm64": "1.451.0", + "@github/copilot-language-server-linux-x64": "1.451.0", + "@github/copilot-language-server-win32-arm64": "1.451.0", + "@github/copilot-language-server-win32-x64": "1.451.0" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.411.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.411.0.tgz", - "integrity": "sha512-fTRodMIdHRgsLDhfhlpOT6OvyR3rLD4JwkbjlRCa+KDHAQd/kFN8+G5KnzqMckIFtGAvQ1zY7d8oKiT7Z11ayg==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.451.0.tgz", + "integrity": "sha512-aq0dv9oKLt2Y87oEnnsUCWJ0x2qc+t+nyzg3GoT2M6eWr1YrqIL6VlGlmmNB/WWvTSp3w94xy5H6kDpD7rzWgQ==", "cpu": [ "arm64" ], @@ -69,9 +70,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.411.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.411.0.tgz", - "integrity": "sha512-CA9l1MvMmfgDgaKmzP4inEx6P8sG1x+pF12HY9nwwH01XmeJre+obQM8M3Nm5BUIklmpS07Vk5fbu9X3fOpWkg==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.451.0.tgz", + "integrity": "sha512-GX1Fkl84Bh1EpnNYQpexirKrIxgtpUU4iYh58b865dAv7TBpKIyXxP1rSl/2/MCWDV6VuPWYhv5OfzHuiFgacA==", "cpu": [ "x64" ], @@ -81,9 +82,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.411.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.411.0.tgz", - "integrity": "sha512-6T0SVveZlfVTcUS98vqTPhJSFA3Ia3FCPubOeYHF3SqHdLTokJtKrYGzD6gux+0ik1/9pPmx4bj8cMfHkhp1SA==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.451.0.tgz", + "integrity": "sha512-eNemo1Nj9ZPpE+FhDqQe1iRHQdZZCgRTmXZ/hrfc7slqDyDrMuMII18l7lLHspN2Po8hNZdJjrMvnk0J9mebSw==", "cpu": [ "arm64" ], @@ -94,9 +95,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.411.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.411.0.tgz", - "integrity": "sha512-OKKZqCH2x7OL71pzDQQP4lZfsVnqiOlpcnz9UXoP4QFnkGunx5PhAmbYvZwTQiCNAwippkSaUjDnj9YneNkytw==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.451.0.tgz", + "integrity": "sha512-cnTYzJElUb3xw4Y8gylIuY3rWwTeNWPbI848yf8sZVxDo+P7U8Sfyfo0ZIxbwF2r48EohQZ0PA9Uu3Q0pX9dEA==", "cpu": [ "x64" ], @@ -106,10 +107,23 @@ "linux" ] }, + "node_modules/@github/copilot-language-server-win32-arm64": { + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.451.0.tgz", + "integrity": "sha512-9Wx2XRZJm+8Fy2Ho2kuupBQpXyj9pSJJXO+Xi2oFFBSdS9pAEpqx+62CMTqLLjlmDkFj9QW0rI5FNDynxSPBCQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.411.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.411.0.tgz", - "integrity": "sha512-kJK/qoiMeydpy1K/uBgVwTqXjeLZmkMwJupbvZFXWjYFbHU2iCe6fHiUsROYPoVbqX52tjX5C+Rw/plhARDuRA==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.451.0.tgz", + "integrity": "sha512-L9uqgeQNWGr9Vrpj8fYwazAJYn/TwqrhZ2r/euXi7wpg8fPTHh9JAmdBLI39Gr34kyclL1fxjzvNzm0UtRC0XA==", "cpu": [ "x64" ], @@ -707,20 +721,20 @@ "license": "MIT" }, "node_modules/copy-webpack-plugin": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", - "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "dev": true, "license": "MIT", "dependencies": { "glob-parent": "^6.0.1", "normalize-path": "^3.0.0", "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2", + "serialize-javascript": "^7.0.3", "tinyglobby": "^0.2.12" }, "engines": { - "node": ">= 18.12.0" + "node": ">= 20.9.0" }, "funding": { "type": "opencollective", @@ -1550,16 +1564,6 @@ "dev": true, "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -1627,27 +1631,6 @@ "node": ">=8" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/schema-utils": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", @@ -1682,13 +1665,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/shallow-clone": { @@ -1834,16 +1817,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { diff --git a/Server/package.json b/Server/package.json index 62a20e1d..3599040a 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,19 +7,19 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "1.411.0", - "@github/copilot-language-server-darwin-arm64": "1.411.0", - "@github/copilot-language-server-darwin-x64": "1.411.0", + "@github/copilot-language-server": "1.451.0", + "@github/copilot-language-server-darwin-arm64": "1.451.0", + "@github/copilot-language-server-darwin-x64": "1.451.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" }, "devDependencies": { "@types/node": "^22.15.17", - "copy-webpack-plugin": "^13.0.1", + "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.2", "style-loader": "^4.0.0", - "terser-webpack-plugin": "^5.3.14", + "terser-webpack-plugin": "^5.4.0", "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", diff --git a/Tool/Package.swift b/Tool/Package.swift index b54bd789..64c6d2fb 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "Tool", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index d16369a7..bb2b0573 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -451,6 +451,18 @@ public struct ConversationProgressStep: Codable, Equatable, Identifiable { } } +public struct ContextSizeInfo: Codable, Equatable { + public let totalTokenLimit: Int + public let systemPromptTokens: Int + public let toolDefinitionTokens: Int + public let userMessagesTokens: Int + public let assistantMessagesTokens: Int + public let attachedFilesTokens: Int + public let toolResultsTokens: Int + public let totalUsedTokens: Int + public let utilizationPercentage: Double +} + public struct DidChangeWatchedFilesEvent: Codable { public var workspaceUri: String public var changes: [FileEvent] diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 289fcdbd..f688777a 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -24,6 +24,7 @@ public enum PromptTemplateScope: String, Codable, Equatable { case agentPanel = "agent-panel" case editor = "editor" case inline = "inline" + case inlineAgent = "inline-agent" case completion = "completion" } @@ -46,6 +47,7 @@ public struct CopilotModel: Codable, Equatable { public let isChatFallback: Bool public let capabilities: CopilotModelCapabilities public let billing: CopilotModelBilling? + public let degradationReason: String? } public struct CopilotModelPolicy: Codable, Equatable { @@ -76,6 +78,7 @@ public enum ChatMode: String, Codable { case Ask = "Ask" case Edit = "Edit" case Agent = "Agent" + case InlineAgent = "InlineAgent" } public struct ConversationMode: Codable, Equatable { diff --git a/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift new file mode 100644 index 00000000..841d75d7 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift @@ -0,0 +1,14 @@ +import Combine +import Foundation + +public protocol CompressionHandler { + var onCompressionStarted: PassthroughSubject { get } // conversationId + var onCompressionCompleted: PassthroughSubject { get } +} + +public final class CompressionHandlerImpl: CompressionHandler { + public static let shared = CompressionHandlerImpl() + + public var onCompressionStarted = PassthroughSubject() + public var onCompressionCompleted = PassthroughSubject() +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 7b380443..4c8b2721 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -225,6 +225,12 @@ class CopilotLocalProcessServer { case "policy/didChange": notificationPublisher.send(anyNotification) return true + case "$/copilot/compressionStarted": + notificationPublisher.send(anyNotification) + return true + case "$/copilot/compressionCompleted": + notificationPublisher.send(anyNotification) + return true case "conversation/preconditionsNotification", "statusNotification": // Ignore return true diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 0ada31e5..1f952728 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -108,6 +108,9 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { let trustToolAnnotations = UserDefaults.shared.value(for: \.trustToolAnnotations) d["trustToolAnnotations"] = .bool(trustToolAnnotations) + let autoCompress = UserDefaults.shared.value(for: \.autoCompress) + d["autoCompress"] = .bool(autoCompress) + let state = UserDefaults.autoApproval.value(for: \.sensitiveFilesGlobalApprovals) var autoApproveList: [JSONValue] = [] for (key, rule) in state.rules { @@ -750,6 +753,36 @@ public enum GitHubCopilotNotification { } } + public enum CompressionTrigger: String, Codable { + case preTurn = "pre-turn" + case postToolCall = "post-tool-call" + case manual = "manual" + } + + public struct CompressionStartedNotification: Codable { + public var conversationId: String + public var partitionId: Int + public var reason: CompressionTrigger + + public static func decode(fromParams params: JSONValue?) -> CompressionStartedNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + + public struct CompressionCompletedNotification: Codable { + public var conversationId: String + public var archivedPartitionId: Int + public var newPartitionId: Int + public var summaryLength: Int + public var turnCount: Int + public var durationMs: Int + public var contextInfo: ContextSizeInfo? + + public static func decode(fromParams params: JSONValue?) -> CompressionCompletedNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + public struct MCPRuntimeNotification: Codable { public enum MCPRuntimeLogLevel: String, Codable { case Info = "info" diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift index 95bff025..85d199b2 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift @@ -35,6 +35,7 @@ public struct ConversationProgressReport: BaseConversationProgress { public let steps: [ConversationProgressStep]? public let editAgentRounds: [AgentRound]? public let parentTurnId: String? + public let contextSize: ContextSizeInfo? } public struct ConversationProgressEnd: BaseConversationProgress { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 978c6e92..9770d70d 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -170,6 +170,8 @@ public extension Notification.Name { .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoApprovalDidChange") static let githubCopilotAgentTrustToolAnnotationsDidChange = Notification .Name("com.github.CopilotForXcode.GithubCopilotAgentTrustToolAnnotationsDidChange") + static let githubCopilotAgentAutoCompressDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoCompressDidChange") } public class GitHubCopilotBaseService { @@ -1483,6 +1485,10 @@ public final class GitHubCopilotService: DistributedNotificationCenter.default() .publisher(for: .githubCopilotAgentTrustToolAnnotationsDidChange) .map { _ in "agentTrustToolAnnotations" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentAutoCompressDidChange) + .map { _ in "agentAutoCompress" } .eraseToAnyPublisher() ) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift index e7f9eba9..c1cf94a5 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -14,6 +14,7 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared + var compressionHandler: CompressionHandler = CompressionHandlerImpl.shared init() { self.protocolProgressSubject = PassthroughSubject() @@ -54,6 +55,18 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { copilotPolicyNotifier.handleCopilotPolicyNotification(policy) } break + case "$/copilot/compressionStarted": + if let payload = GitHubCopilotNotification.CompressionStartedNotification + .decode(fromParams: notification.params) { + compressionHandler.onCompressionStarted.send(payload.conversationId) + } + break + case "$/copilot/compressionCompleted": + if let payload = GitHubCopilotNotification.CompressionCompletedNotification + .decode(fromParams: notification.params) { + compressionHandler.onCompressionCompleted.send(payload) + } + break default: break } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index b99c854f..4153e1ce 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -116,9 +116,8 @@ public final class GitHubCopilotConversationService: ConversationServiceType { public func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? { guard let service = await serviceLocator.getService(from: workspace) else { return nil } - let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures let isCustomAgentEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled - let workspaceFolders = isPreviewEnabled && isCustomAgentEnabled ? getWorkspaceFolders( + let workspaceFolders = isCustomAgentEnabled ? getWorkspaceFolders( workspace: workspace ) : nil return try await service.modes(workspaceFolders: workspaceFolders) diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift index 6e52319b..2274c13d 100644 --- a/Tool/Sources/HostAppActivator/HostAppActivator.swift +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -45,17 +45,14 @@ public func launchHostAppSettings() throws { let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) Logger.ui.info("\(hostAppName()) activated: \(activated)") - let scriptSuccess = tryLaunchWithAppleScript() - - // If AppleScript fails, fall back to notification center - if !scriptSuccess { - DistributedNotificationCenter.default().postNotificationName( - .openSettingsWindowRequest, - object: nil - ) - Logger.ui.info("\(hostAppName()) settings notification sent after activation") - return - } + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) settings notification sent after activation") + return } else { // If app is not running, launch it with the settings flag try launchHostAppWithArgs(args: ["--settings"]) diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 50ead5c8..400c07e3 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -336,6 +336,10 @@ public extension UserDefaultPreferenceKeys { var enableSubagent: PreferenceKey { .init(defaultValue: true, key: "EnableSubagent") } + + var autoCompress: PreferenceKey { + .init(defaultValue: true, key: "AutoCompress") + } } // MARK: - Theme diff --git a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift index 55cc15c7..b09c94ba 100644 --- a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift +++ b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift @@ -8,11 +8,7 @@ public struct ConditionalFontWeight: ViewModifier { } public func body(content: Content) -> some View { - if #available(macOS 13.0, *), weight != nil { - content.fontWeight(weight) - } else { - content - } + content.fontWeight(weight) } } diff --git a/Tool/Sources/SharedUIComponents/CustomScrollView.swift b/Tool/Sources/SharedUIComponents/CustomScrollView.swift index 0eb486f0..91c73fae 100644 --- a/Tool/Sources/SharedUIComponents/CustomScrollView.swift +++ b/Tool/Sources/SharedUIComponents/CustomScrollView.swift @@ -44,11 +44,7 @@ public struct CustomScrollView: View { } .listStyle(.plain) .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) } .frame(idealHeight: max(10, height)) .onPreferenceChange(CustomScrollViewHeightPreferenceKey.self) { newHeight in diff --git a/Tool/Sources/SharedUIComponents/FontPicker.swift b/Tool/Sources/SharedUIComponents/FontPicker.swift index 2f91c9d0..cc2f4f4a 100644 --- a/Tool/Sources/SharedUIComponents/FontPicker.swift +++ b/Tool/Sources/SharedUIComponents/FontPicker.swift @@ -14,17 +14,10 @@ public struct FontPicker: View { } public var body: some View { - if #available(macOS 13.0, *) { - LabeledContent { - button - } label: { - label - } - } else { - HStack { - label - button - } + LabeledContent { + button + } label: { + label } } diff --git a/Tool/Sources/SharedUIComponents/SplitButton.swift b/Tool/Sources/SharedUIComponents/SplitButton.swift index 8ddead7b..b3388850 100644 --- a/Tool/Sources/SharedUIComponents/SplitButton.swift +++ b/Tool/Sources/SharedUIComponents/SplitButton.swift @@ -35,7 +35,6 @@ public struct SplitButtonMenuItem: Identifiable { } } -@available(macOS 13.0, *) private enum SplitButtonMenuBuilder { static func buildMenu( items: [SplitButtonMenuItem], @@ -87,7 +86,6 @@ private enum SplitButtonMenuBuilder { // MARK: - SplitButton using NSComboButton -@available(macOS 13.0, *) public struct SplitButton: View { let title: String let primaryAction: () -> Void @@ -155,7 +153,6 @@ public struct SplitButton: View { } } -@available(macOS 13.0, *) private struct ProminentMenuButton: NSViewRepresentable { let menuItems: [SplitButtonMenuItem] let isDisabled: Bool @@ -215,7 +212,6 @@ private struct ProminentMenuButton: NSViewRepresentable { } } -@available(macOS 13.0, *) struct SplitButtonRepresentable: NSViewRepresentable { let title: String let primaryAction: () -> Void diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift index 704af7df..bb989885 100644 --- a/Tool/Sources/Toast/Toast.swift +++ b/Tool/Sources/Toast/Toast.swift @@ -265,28 +265,11 @@ public extension NSWorkspace { /// Opens the System Preferences/Settings app at the Extensions pane /// - Parameter extensionPointIdentifier: Optional identifier for specific extension type static func openExtensionsPreferences(extensionPointIdentifier: String? = nil) { - if #available(macOS 13.0, *) { - var urlString = "x-apple.systempreferences:com.apple.ExtensionsPreferences" - if let extensionPointIdentifier = extensionPointIdentifier { - urlString += "?extensionPointIdentifier=\(extensionPointIdentifier)" - } - NSWorkspace.shared.open(URL(string: urlString)!) - } else { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/open") - process.arguments = [ - "-b", - "com.apple.systempreferences", - "/System/Library/PreferencePanes/Extensions.prefPane" - ] - - do { - try process.run() - } catch { - // Handle error silently - return - } + var urlString = "x-apple.systempreferences:com.apple.ExtensionsPreferences" + if let extensionPointIdentifier = extensionPointIdentifier { + urlString += "?extensionPointIdentifier=\(extensionPointIdentifier)" } + NSWorkspace.shared.open(URL(string: urlString)!) } /// Opens the Xcode Extensions preferences directly diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift index 4b7d09cb..d54976d4 100644 --- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -80,7 +80,6 @@ extension XPCCommunicationBridge { } } -@available(macOS 13.0, *) public func showBackgroundPermissionAlert() { let alert = NSAlert() alert.messageText = "Background Permission Required" diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 6a8c5d5e..8c93c60b 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -326,15 +326,13 @@ public final class XcodeInspector: ObservableObject { .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in - if #available(macOS 13.0, *) { - let notifications = await xcode.axNotifications.notifications().filter { - $0.kind == .uiElementDestroyed - }.debounce(for: .milliseconds(1000)) - for await _ in notifications { - guard let self else { return } - try Task.checkCancellation() - self.checkForAccessibilityMalfunction("Element Destroyed") - } + let notifications = await xcode.axNotifications.notifications().filter { + $0.kind == .uiElementDestroyed + }.debounce(for: .milliseconds(1000)) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + self.checkForAccessibilityMalfunction("Element Destroyed") } }