diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ffedc4..e8692d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.35.0 - May 19, 2025 +### Added +- Launched Agent Mode. Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors. +- Introduced Model Context Protocol (MCP) support in Agent Mode, allowing you to configure MCP tools to extend capabilities. + +### Changed +- Added a button to enable/disable referencing current file in conversations +- Added an animated progress icon in the response section +- Refined onboarding experience with updated instruction screens and welcome views +- Improved conversation reliability with extended timeout limits for agent requests + +### Fixed +- Addressed critical error handling issues in core functionality +- Resolved UI inconsistencies with chat interface padding adjustments +- Implemented custom certificate handling using system environment variables `NODE_EXTRA_CA_CERTS` and `NODE_TLS_REJECT_UNAUTHORIZED`, fixing network access issues + ## 0.34.0 - April 29, 2025 ### Added - Added support for new models in Chat: OpenAI GPT-4.1, o3 and o4-mini, Gemini 2.5 Pro diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index 6a5c9516..d8ed3cdd 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -6,6 +6,7 @@ import SharedUIComponents import UpdateChecker import XPCShared import HostAppActivator +import ComposableArchitecture struct VisualEffect: NSViewRepresentable { func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() } @@ -192,14 +193,16 @@ struct CopilotForXcodeApp: App { } var body: some Scene { - Settings { - TabContainer() - .frame(minWidth: 800, minHeight: 600) - .background(VisualEffect().ignoresSafeArea()) - .environment(\.updateChecker, UpdateChecker( - hostBundle: Bundle.main, - checkerDelegate: AppUpdateCheckerDelegate() - )) + WithPerceptionTracking { + Settings { + TabContainer() + .frame(minWidth: 800, minHeight: 600) + .background(VisualEffect().ignoresSafeArea()) + .environment(\.updateChecker, UpdateChecker( + hostBundle: Bundle.main, + checkerDelegate: AppUpdateCheckerDelegate() + )) + } } } } diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 5d483a75..8cad9e5b 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -379,7 +379,7 @@ public final class ChatService: ChatServiceType, ObservableObject { public func stopReceivingMessage() async { if let activeRequestId = activeRequestId { do { - try await conversationProvider?.stopReceivingMessage(activeRequestId) + try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } @@ -393,7 +393,7 @@ public final class ChatService: ChatServiceType, ObservableObject { await memory.clearHistory() if let activeRequestId = activeRequestId { do { - try await conversationProvider?.stopReceivingMessage(activeRequestId) + try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } @@ -491,13 +491,20 @@ public final class ChatService: ChatServiceType, ObservableObject { try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: []) } } - + + public func getWorkspaceURL() -> URL? { + guard !chatTabInfo.workspacePath.isEmpty else { + return nil + } + return URL(fileURLWithPath: chatTabInfo.workspacePath) + } + public func upvote(_ id: String, _ rating: ConversationRating) async { - try? await conversationProvider?.rateConversation(turnId: id, rating: rating) + try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func downvote(_ id: String, _ rating: ConversationRating) async { - try? await conversationProvider?.rateConversation(turnId: id, rating: rating) + try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func copyCode(_ id: String) async { @@ -725,7 +732,7 @@ public final class ChatService: ChatServiceType, ObservableObject { do { if let conversationId = conversationId { - try await conversationProvider?.createTurn(with: conversationId, request: request) + try await conversationProvider?.createTurn(with: conversationId, request: request, workspaceURL: getWorkspaceURL()) } else { var requestWithTurns = request @@ -738,7 +745,7 @@ public final class ChatService: ChatServiceType, ObservableObject { requestWithTurns.turns = turns } - try await conversationProvider?.createConversation(requestWithTurns) + try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) } } catch { resetOngoingRequest() diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 2ca29c96..51ee178d 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -66,6 +66,7 @@ struct Chat { var fileEditMap: OrderedDictionary = [:] var diffViewerController: DiffViewWindowController? = nil var isAgentMode: Bool = AppState.shared.isAgentModeEnabled() + var workspaceURL: URL? = nil enum Field: String, Hashable { case textField case fileSearchBar @@ -564,4 +565,3 @@ private actor TimedDebounceFunction { await block() } } - diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index f164d760..1ce5ecde 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -488,7 +488,7 @@ struct ChatPanelInputArea: View { } ) .onAppear() { - allFiles = ContextUtils.getFilesInActiveWorkspace() + allFiles = ContextUtils.getFilesInActiveWorkspace(workspaceURL: chat.workspaceURL) } } diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 84517df2..51cab9bf 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -6,7 +6,11 @@ import Workspace public struct ContextUtils { - public static func getFilesInActiveWorkspace() -> [FileReference] { + public static func getFilesInActiveWorkspace(workspaceURL: URL?) -> [FileReference] { + if let workspaceURL = workspaceURL, let info = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { + return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: info.workspaceURL, workspaceRootURL: info.projectURL) + } + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, let workspaceRootURL = XcodeInspector.shared.realtimeActiveProjectURL else { return [] diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 5d6d5014..54a5b621 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -114,7 +114,7 @@ public class ConversationTab: ChatTab { let service = ChatService.service(for: info) self.service = service - chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) }) super.init(store: store) // Start to observe changes of Chat Message @@ -128,7 +128,7 @@ public class ConversationTab: ChatTab { @MainActor public init(service: ChatService, store: StoreOf, with chatTabInfo: ChatTabInfo) { self.service = service - chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) }) super.init(store: store) } diff --git a/Core/Sources/HostApp/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift index b151a9c6..3f72daf7 100644 --- a/Core/Sources/HostApp/MCPConfigView.swift +++ b/Core/Sources/HostApp/MCPConfigView.swift @@ -6,6 +6,7 @@ import SwiftUI import Toast import ConversationServiceProvider import GitHubCopilotService +import ComposableArchitecture struct MCPConfigView: View { @State private var mcpConfig: String = "" @@ -16,20 +17,24 @@ struct MCPConfigView: View { @State private var fileMonitorTask: Task? = nil @Environment(\.colorScheme) var colorScheme + private static var lastSyncTimestamp: Date? = nil + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - MCPIntroView() - MCPToolsListView() - } - .padding(20) - .onAppear { - setupConfigFilePath() - startMonitoringConfigFile() - refreshConfiguration(()) - } - .onDisappear { - stopMonitoringConfigFile() + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + MCPIntroView() + MCPToolsListView() + } + .padding(20) + .onAppear { + setupConfigFilePath() + startMonitoringConfigFile() + refreshConfiguration(()) + } + .onDisappear { + stopMonitoringConfigFile() + } } } } @@ -145,6 +150,12 @@ struct MCPConfigView: View { } func refreshConfiguration(_: Any) { + if MCPConfigView.lastSyncTimestamp == lastModificationDate { + return + } + + MCPConfigView.lastSyncTimestamp = lastModificationDate + let fileURL = URL(fileURLWithPath: configFilePath) if let jsonString = readAndValidateJSON(from: fileURL) { UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig) diff --git a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift index 3a0c6cab..98327a96 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPIntroView.swift @@ -13,7 +13,10 @@ struct MCPIntroView: View { "my-mcp-server": { "type": "stdio", "command": "my-command", - "args": [] + "args": [], + "env": { + "TOKEN": "my_token" + } } } } @@ -75,7 +78,7 @@ struct MCPIntroView: View { .overlay( RoundedRectangle(cornerRadius: 4) .inset(by: 0.5) - .stroke(Color(red: 0.9, green: 0.9, blue: 0.9), lineWidth: 1) + .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1) ) } diff --git a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift index 04c591da..5464a6f3 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift @@ -1,6 +1,8 @@ import SwiftUI import Persist import GitHubCopilotService +import Client +import Logger /// Section for a single server's tools struct MCPServerToolsSection: View { @@ -10,6 +12,54 @@ struct MCPServerToolsSection: View { @State private var toolEnabledStates: [String: Bool] = [:] @State private var isExpanded: Bool = true private var originalServerName: String { serverTools.name } + + private var serverToggleLabel: some View { + HStack(spacing: 8) { + Text("MCP Server: \(serverTools.name)").fontWeight(.medium) + if serverTools.status == .error { + if hasUnsupportedServerType() { + Badge(text: getUnsupportedServerTypeMessage(), level: .danger, icon: "xmark.circle.fill") + } else { + let message = extractErrorMessage(serverTools.error?.description ?? "") + Badge(text: message, level: .danger, icon: "xmark.circle.fill") + } + } + Spacer() + } + } + + private var serverToggle: some View { + Toggle(isOn: Binding( + get: { isServerEnabled }, + set: { updateAllToolsStatus(enabled: $0) } + )) { + serverToggleLabel + } + .toggleStyle(.checkbox) + .padding(.leading, 4) + .disabled(serverTools.status == .error) + } + + private var divider: some View { + Divider() + .padding(.leading, 36) + .padding(.top, 2) + .padding(.bottom, 4) + } + + private var toolsList: some View { + VStack(spacing: 0) { + divider + ForEach(serverTools.tools, id: \.name) { tool in + MCPToolRow( + tool: tool, + isServerEnabled: isServerEnabled, + isToolEnabled: toolBindingFor(tool), + onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } + ) + } + } + } // Function to check if the MCP config contains unsupported server types private func hasUnsupportedServerType() -> Bool { @@ -37,61 +87,36 @@ struct MCPServerToolsSection: View { } var body: some View { - VStack(spacing: 0) { - DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + // Conditional view rendering based on error state + if serverTools.status == .error { + // No disclosure group for error state VStack(spacing: 0) { - Divider() - .padding(.leading, 32) - .padding(.top, 2) - .padding(.bottom, 4) - ForEach(serverTools.tools, id: \.name) { tool in - MCPToolRow( - tool: tool, - isServerEnabled: isServerEnabled, - isToolEnabled: toolBindingFor(tool), - onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } - ) - } + serverToggle.padding(.leading, 12) + divider.padding(.top, 4) } - } label: { - // Server name with checkbox - Toggle(isOn: Binding( - get: { isServerEnabled }, - set: { updateAllToolsStatus(enabled: $0) } - )) { - HStack(spacing: 8) { - Text("MCP Server: \(serverTools.name)").fontWeight(.medium) - if serverTools.status == .error { - if hasUnsupportedServerType() { - Badge(text: getUnsupportedServerTypeMessage(), level: .danger, icon: "xmark.circle.fill") - } else { - let message = extractErrorMessage(serverTools.error?.description ?? "") - Badge(text: message, level: .danger, icon: "xmark.circle.fill") - } - } - } + } else { + // Regular DisclosureGroup for non-error state + DisclosureGroup(isExpanded: $isExpanded) { + toolsList + } label: { + serverToggle } - .toggleStyle(.checkbox) - .padding(.leading, 4) - .disabled(serverTools.status == .error) - } - .onAppear { - initializeToolStates() - if forceExpand { - isExpanded = true + .onAppear { + initializeToolStates() + if forceExpand { + isExpanded = true + } } - } - .onChange(of: forceExpand) { newForceExpand in - if newForceExpand { - isExpanded = true + .onChange(of: forceExpand) { newForceExpand in + if newForceExpand { + isExpanded = true + } } - } - if !isExpanded { - Divider() - .padding(.leading, 32) - .padding(.top, 2) - .padding(.bottom, 4) + if !isExpanded { + divider + } } } } @@ -158,8 +183,7 @@ struct MCPServerToolsSection: View { tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)] ) - AppState.shared.updateMCPToolsStatus([serverUpdate]) - CopilotMCPToolManager.updateMCPToolsStatus([serverUpdate]) + updateMCPStatus([serverUpdate]) } private func updateAllToolsStatus(enabled: Bool) { @@ -182,7 +206,37 @@ struct MCPServerToolsSection: View { } ) - AppState.shared.updateMCPToolsStatus([serverUpdate]) - CopilotMCPToolManager.updateMCPToolsStatus([serverUpdate]) + updateMCPStatus([serverUpdate]) + } + + private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) { + // Update status in AppState and CopilotMCPToolManager + AppState.shared.updateMCPToolsStatus(serverUpdates) + + // Encode and save status to UserDefaults + let encoder = JSONEncoder() + if let jsonData = try? encoder.encode(serverUpdates), + let jsonString = String(data: jsonData, encoding: .utf8) + { + UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPUpdatedStatus) + } + + // In-process update + NotificationCenter.default.post( + name: .gitHubCopilotShouldUpdateMCPToolsStatus, + object: nil + ) + + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldUpdateMCPToolsStatus.rawValue + ) + } catch { + Logger.client.error("Failed to post MCP status update notification: \(error.localizedDescription)") + } + } } } diff --git a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift b/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift index 21dd6b87..f6a8e20f 100644 --- a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift +++ b/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift @@ -23,6 +23,7 @@ struct MCPToolRow: View { .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) + .help(description) } } @@ -30,7 +31,7 @@ struct MCPToolRow: View { } } } - .padding(.leading, 32) + .padding(.leading, 36) .padding(.vertical, 0) .onChange(of: tool._status) { isToolEnabled = $0 == .enabled } .onChange(of: isServerEnabled) { if !$0 { isToolEnabled = false } } diff --git a/Docs/welcome.png b/Docs/welcome.png deleted file mode 100644 index de2da42b..00000000 Binary files a/Docs/welcome.png and /dev/null differ diff --git a/README.md b/README.md index 9d6a8d72..d9c550d1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode GitHub Copilot Chat provides suggestions to your specific coding tasks via chat. Chat of GitHub Copilot for Xcode +## Agent Mode + +GitHub Copilot Agent Mode provides AI-powered assistance that can understand and modify your codebase directly. With Agent Mode, you can: +- Get intelligent code edits applied directly to your files +- Run terminal commands and view their output without leaving the interface +- Search through your codebase to find relevant files and code snippets +- Create new files and directories as needed for your project +- Get assistance with enhanced context awareness across multiple files and folders +- Run Model Context Protocol (MCP) tools you configured to extend the capabilities + +Agent Mode integrates with Xcode's environment, creating a seamless development experience where Copilot can help implement features, fix bugs, and refactor code with comprehensive understanding of your project. + ## Code Completion You can receive auto-complete type suggestions from GitHub Copilot either by starting to write the code you want to use, or by writing a natural language comment describing what you want the code to do. @@ -98,10 +110,6 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta 1. Press `tab` to accept the first line of a suggestion, hold `option` to view the full suggestion, and press `option` + `tab` to accept the full suggestion. -

- Screenshot of welcome screen -

- ## How to use Chat Open Copilot Chat in GitHub Copilot. diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 20b05455..38eb6c14 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,14 +1,19 @@ -### GitHub Copilot for Xcode 0.34.0 +### GitHub Copilot for Xcode 0.35.0 **🚀 Highlights** -* **New Models**: OpenAI GPT-4.1, o3 and o4-mini, Gemini 2.5 Pro are now available in the Copilot Chat model selector. +* **Agent Mode**: Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors. +* **Model Context Protocol (MCP)**: Integrated with Agent Mode, allowing you to configure MCP tools to extend capabilities. **💪 Improvements** -* Switched default model to GPT-4.1 for new installations -* Enhanced model selection interface +* Added a button to enable/disable referencing current file in conversations +* Added an animated progress icon in the response section +* Refined onboarding experience with updated instruction screens and welcome views +* Improved conversation reliability with extended timeout limits for agent requests **🛠️ Bug Fixes** -* Resolved critical error handling issues +* Addressed critical error handling issues in core functionality +* Resolved UI inconsistencies with chat interface padding adjustments +* Improved network access with automatic detection of system environment variables for custom certificates diff --git a/Server/package-lock.json b/Server/package-lock.json index f83bc5af..d2271df6 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,7 +8,7 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.319.0", + "@github/copilot-language-server": "^1.321.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -36,9 +36,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.319.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.319.0.tgz", - "integrity": "sha512-SicoidG61WNUs/EJRglJEry6j8ZaJrKKcx/ZznDMxorVAQp7fTeNoE+fbM2lH+qgieZIt/f+pVagYePFIxsMVg==", + "version": "1.321.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.321.0.tgz", + "integrity": "sha512-IblryaajOPfGOSaeVSpu+NUxiodXIInmWcV1YQgmvmKSdcclzt4FxAnu/szRHuh0yIaZlldQ6lBRPFIVeuXv+g==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index 8eddbeab..245bad10 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,7 +7,7 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.319.0", + "@github/copilot-language-server": "^1.321.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 091e7fe5..e60ea435 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -109,6 +109,13 @@ "identifier" : "ChatServiceTests", "name" : "ChatServiceTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SystemUtilsTests", + "name" : "SystemUtilsTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index 725385da..cfdc50b7 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -90,7 +90,7 @@ let package = Package( .target(name: "Preferences", dependencies: ["Configs"]), - .target(name: "Terminal", dependencies: ["Logger"]), + .target(name: "Terminal", dependencies: ["Logger", "SystemUtils"]), .target(name: "Logger"), @@ -307,6 +307,7 @@ let package = Package( "Status", "SystemUtils", "Workspace", + "Persist", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -346,7 +347,11 @@ let package = Package( // MARK: - SystemUtils - .target(name: "SystemUtils") + .target( + name: "SystemUtils", + dependencies: ["Logger"] + ), + .testTarget(name: "SystemUtilsTests", dependencies: ["SystemUtils"]), ] ) diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index aca37267..811f2b65 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -3,6 +3,7 @@ import CopilotForXcodeKit import Foundation import Logger import XcodeInspector +import Workspace public final class BuiltinExtensionConversationServiceProvider< T: BuiltinExtension @@ -21,7 +22,13 @@ public final class BuiltinExtensionConversationServiceProvider< extensionManager.extensions.first { $0 is T }?.conversationService } - private func activeWorkspace() async -> WorkspaceInfo? { + private func activeWorkspace(_ workspaceURL: URL? = nil) async -> WorkspaceInfo? { + if let workspaceURL = workspaceURL { + if let workspaceBinding = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) { + return workspaceBinding + } + } + guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL, let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL else { return nil } @@ -35,12 +42,12 @@ public final class BuiltinExtensionConversationServiceProvider< } } - public func createConversation(_ request: ConversationRequest) async throws { + public func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -48,12 +55,12 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.createConversation(request, workspace: workspaceInfo) } - public func createTurn(with conversationId: String, request: ConversationRequest) async throws { + public func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -61,12 +68,12 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.createTurn(with: conversationId, request: request, workspace: workspaceInfo) } - public func stopReceivingMessage(_ workDoneToken: String) async throws { + public func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } @@ -74,24 +81,24 @@ public final class BuiltinExtensionConversationServiceProvider< try await conversationService.cancelProgress(workDoneToken, workspace: workspaceInfo) } - public func rateConversation(turnId: String, rating: ConversationRating) async throws { + public func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } try? await conversationService.rateConversation(turnId: turnId, rating: rating, workspace: workspaceInfo) } - public func copyCode(_ request: CopyCodeRequest) async throws { + public func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return } - guard let workspaceInfo = await activeWorkspace() else { + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") return } diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 3e67a6c9..bb53fbc9 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -16,11 +16,11 @@ public protocol ConversationServiceType { } public protocol ConversationServiceProvider { - func createConversation(_ request: ConversationRequest) async throws - func createTurn(with conversationId: String, request: ConversationRequest) async throws - func stopReceivingMessage(_ workDoneToken: String) async throws - func rateConversation(turnId: String, rating: ConversationRating) async throws - func copyCode(_ request: CopyCodeRequest) async throws + func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws + func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws + func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws + func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws + func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws func templates() async throws -> [ChatTemplate]? func models() async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift index 3718f849..f64c58e7 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -7,7 +7,6 @@ public extension Notification.Name { public class CopilotMCPToolManager { private static var availableMCPServerTools: [MCPServerToolsCollection] = [] - private static var updatedMCPToolsStatusParams: UpdateMCPToolsStatusParams = .init(servers: []) public static func updateMCPTools(_ serverToolsCollections: [MCPServerToolsCollection]) { let sortedMCPServerTools = serverToolsCollections.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }) @@ -30,21 +29,6 @@ public class CopilotMCPToolManager { public static func hasMCPTools() -> Bool { return !availableMCPServerTools.isEmpty } - - public static func updateMCPToolsStatus(_ servers: [UpdateMCPToolsStatusServerCollection]) { - updatedMCPToolsStatusParams = .init(servers: servers) - DispatchQueue.main.async { - NotificationCenter.default - .post( - name: .gitHubCopilotShouldUpdateMCPToolsStatus, - object: nil - ) - } - } - - public static func getUpdatedMCPToolsStatusParams() -> UpdateMCPToolsStatusParams { - return updatedMCPToolsStatusParams - } public static func clearMCPTools() { availableMCPServerTools = [] diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 6992d42f..ae9ac1a9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -11,6 +11,7 @@ import Preferences import Status import SuggestionBasic import SystemUtils +import Persist public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus @@ -175,6 +176,8 @@ public class GitHubCopilotBaseService { } } + environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "") + let versionNumber = JSONValue( stringLiteral: SystemUtils.editorPluginVersion ?? "" ) @@ -285,6 +288,8 @@ public class GitHubCopilotBaseService { let notifications = NotificationCenter.default .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) + let mcpNotifications = NotificationCenter.default + .notifications(named: .gitHubCopilotShouldUpdateMCPToolsStatus) Task { [weak self] in if projectRootURL.path != "/" { try? await server.sendNotification( @@ -299,6 +304,9 @@ public class GitHubCopilotBaseService { .init(settings: editorConfiguration()) ) ) + if let copilotService = self as? GitHubCopilotService { + _ = await copilotService.initializeMCP() + } for await _ in notifications { guard self != nil else { return } _ = try? await server.sendNotification( @@ -307,6 +315,10 @@ public class GitHubCopilotBaseService { ) ) } + for await _ in mcpNotifications { + guard self != nil else { return } + _ = await GitHubCopilotService.updateAllMCP() + } } } @@ -383,41 +395,16 @@ func getTerminalEnvironmentVariables(_ variableNames: [String]) -> [String: Stri guard let shell = userShell else { return results } - - let process = Process() - let pipe = Pipe() - - process.executableURL = URL(fileURLWithPath: shell) - process.arguments = ["-l", "-c", "env"] - process.standardOutput = pipe - - do { - try process.run() - process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let outputString = String(data: data, encoding: .utf8) else { - Logger.gitHubCopilot.info("Failed to decode shell output for variables: \(variableNames.joined(separator: ", "))") - return results - } - - // Process each line of env output - for line in outputString.split(separator: "\n") { - // Each env line is in the format NAME=VALUE - if let idx = line.firstIndex(of: "=") { - let key = String(line[.. { .init(defaultValue: "", key: "GitHubCopilotMCPConfig") } + + var gitHubCopilotMCPUpdatedStatus: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotMCPUpdatedStatus") + } var gitHubCopilotEnterpriseURI: PreferenceKey { .init(defaultValue: "", key: "GitHubCopilotEnterpriseURI") diff --git a/Tool/Sources/SystemUtils/SystemUtils.swift b/Tool/Sources/SystemUtils/SystemUtils.swift index c2db343a..e5b0c79a 100644 --- a/Tool/Sources/SystemUtils/SystemUtils.swift +++ b/Tool/Sources/SystemUtils/SystemUtils.swift @@ -1,4 +1,5 @@ import Foundation +import Logger import IOKit import CryptoKit @@ -172,4 +173,55 @@ public class SystemUtils { return false #endif } + + /// Returns the environment of a login shell (to get correct PATH and other variables) + public func getLoginShellEnvironment(shellPath: String = "/bin/zsh") -> [String: String]? { + let task = Process() + let pipe = Pipe() + task.executableURL = URL(fileURLWithPath: shellPath) + task.arguments = ["-i", "-l", "-c", "env"] + task.standardOutput = pipe + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + var env: [String: String] = [:] + for line in output.split(separator: "\n") { + if let idx = line.firstIndex(of: "=") { + let key = String(line[.. String { + let homeDirectory = NSHomeDirectory() + let commonPaths = [ + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + homeDirectory + "/.local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + ] + + let paths = path.split(separator: ":").map { String($0) } + var newPath = path + for commonPath in commonPaths { + if FileManager.default.fileExists(atPath: commonPath) && !paths.contains(commonPath) { + newPath += (newPath.isEmpty ? "" : ":") + commonPath + } + } + + return newPath + } } diff --git a/Tool/Sources/Terminal/TerminalSession.swift b/Tool/Sources/Terminal/TerminalSession.swift index 237d2a05..6db53ef6 100644 --- a/Tool/Sources/Terminal/TerminalSession.swift +++ b/Tool/Sources/Terminal/TerminalSession.swift @@ -1,4 +1,5 @@ import Foundation +import SystemUtils import Logger import Combine @@ -45,7 +46,7 @@ class ShellProcessManager { // Configure the process process?.executableURL = URL(fileURLWithPath: "/bin/zsh") - process?.arguments = ["-i"] + process?.arguments = ["-i", "-l"] // Create temporary file for shell integration let tempDir = FileManager.default.temporaryDirectory @@ -68,11 +69,13 @@ class ShellProcessManager { var environment = ProcessInfo.processInfo.environment // Fetch login shell environment to get correct PATH - if let shellEnv = ShellProcessManager.getLoginShellEnvironment() { + if let shellEnv = SystemUtils.shared.getLoginShellEnvironment(shellPath: "/bin/zsh") { for (key, value) in shellEnv { environment[key] = value } } + // Append common bin paths to PATH + environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "") let userZdotdir = environment["ZDOTDIR"] ?? NSHomeDirectory() environment["ZDOTDIR"] = zshdir.path @@ -108,33 +111,6 @@ class ShellProcessManager { } } - /// Returns the environment of a login shell (to get correct PATH and other variables) - private static func getLoginShellEnvironment() -> [String: String]? { - let task = Process() - let pipe = Pipe() - task.executableURL = URL(fileURLWithPath: "/bin/zsh") - task.arguments = ["-l", "-c", "env"] - task.standardOutput = pipe - do { - try task.run() - task.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8) else { return nil } - var env: [String: String] = [:] - for line in output.split(separator: "\n") { - if let idx = line.firstIndex(of: "=") { - let key = String(line[.. = ["swift", "m", "mm", "h", "cpp", "c", "js", "ts", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements", "md", "json", "xml", "txt", "yaml", "yml", "html", "css"] public let skipPatterns: [String] = [ @@ -98,6 +99,15 @@ public struct WorkspaceFile { return false } + public static func getWorkspaceInfo(workspaceURL: URL) -> WorkspaceInfo? { + guard let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) else { + return nil + } + + let workspaceInfo = WorkspaceInfo(workspaceURL: workspaceURL, projectURL: projectURL) + return workspaceInfo + } + public static func getProjects(workspace: WorkspaceInfo) -> [ProjectInfo] { var subprojects: [ProjectInfo] = [] if isXCWorkspace(workspace.workspaceURL) { diff --git a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift index a01a5a31..95313c0d 100644 --- a/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift +++ b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift @@ -1,8 +1,5 @@ -import CopilotForXcodeKit -import LanguageServerProtocol import XCTest -@testable import Workspace @testable import SystemUtils final class SystemUtilsTests: XCTestCase { @@ -17,4 +14,56 @@ final class SystemUtilsTests: XCTestCase { XCTAssertTrue(versionTest.evaluate(with: version), "The Xcode version should match the expected format.") XCTAssertFalse(version.isEmpty, "The Xcode version should not be an empty string.") } + + func test_getLoginShellEnvironment() throws { + // Test with a valid shell path + let validShellPath = "/bin/zsh" + let env = SystemUtils.shared.getLoginShellEnvironment(shellPath: validShellPath) + + XCTAssertNotNil(env, "Environment should not be nil for valid shell path") + XCTAssertFalse(env?.isEmpty ?? true, "Environment should contain variables") + + // Check for essential environment variables + XCTAssertNotNil(env?["PATH"], "PATH should be present in environment") + XCTAssertNotNil(env?["HOME"], "HOME should be present in environment") + XCTAssertNotNil(env?["USER"], "USER should be present in environment") + + // Test with an invalid shell path + let invalidShellPath = "/nonexistent/shell" + let invalidEnv = SystemUtils.shared.getLoginShellEnvironment(shellPath: invalidShellPath) + XCTAssertNil(invalidEnv, "Environment should be nil for invalid shell path") + } + + func test_appendCommonBinPaths() { + // Test with an empty path + let appendedEmptyPath = SystemUtils.shared.appendCommonBinPaths(path: "") + XCTAssertFalse(appendedEmptyPath.isEmpty, "Result should not be empty when starting with empty path") + XCTAssertTrue(appendedEmptyPath.contains("/usr/bin"), "Common path /usr/bin should be added") + XCTAssertFalse(appendedEmptyPath.hasPrefix(":"), "Result should not start with ':'") + + // Test with a custom path + let customPath = "/custom/bin:/another/custom/bin" + let appendedCustomPath = SystemUtils.shared.appendCommonBinPaths(path: customPath) + + // Verify original paths are preserved + XCTAssertTrue(appendedCustomPath.hasPrefix(customPath), "Original paths should be preserved") + + // Verify common paths are added + XCTAssertTrue(appendedCustomPath.contains(":/usr/local/bin"), "Should contain /usr/local/bin") + XCTAssertTrue(appendedCustomPath.contains(":/usr/bin"), "Should contain /usr/bin") + XCTAssertTrue(appendedCustomPath.contains(":/bin"), "Should contain /bin") + + // Test with a path that already includes some common paths + let existingCommonPath = "/usr/bin:/custom/bin" + let appendedExistingPath = SystemUtils.shared.appendCommonBinPaths(path: existingCommonPath) + + // Check that /usr/bin wasn't added again + let pathComponents = appendedExistingPath.split(separator: ":") + let usrBinCount = pathComponents.filter { $0 == "/usr/bin" }.count + XCTAssertEqual(usrBinCount, 1, "Common path should not be duplicated") + + // Make sure the result is a valid PATH string + // First component should be the initial path components + XCTAssertTrue(appendedExistingPath.hasPrefix(existingCommonPath), "Should preserve original path at the beginning") + } }