diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 434de549..8928b689 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions - url: https://github.com/orgs/community/discussions/categories/copilot + url: https://github.com/github/CopilotForXcode/discussions about: Please ask and answer questions about GitHub Copilot here diff --git a/.github/workflows/auto-close-pr.yml b/.github/workflows/auto-close-pr.yml index de2ca780..752e32b3 100644 --- a/.github/workflows/auto-close-pr.yml +++ b/.github/workflows/auto-close-pr.yml @@ -14,7 +14,7 @@ jobs: gh pr close ${{ github.event.pull_request.number }} --comment \ "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/orgs/community/discussions/categories/copilot)." + Feedback for GitHub Copilot for Xcode can be given in the [Copilot community discussions](https://github.com/github/CopilotForXcode/discussions)." env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9aa8393c..2a7f67ef 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,4 @@ Server/dist /releases/ /release/ /appcast.xml + diff --git a/CHANGELOG.md b/CHANGELOG.md index 9554f9dd..f380bf27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,75 @@ 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.47.0 - February 4, 2026 +### Added +- Auto approval for MCP tools, sensitive files, and terminal commands. +- MCP registry and allowlist are now available (requires editor preview feature flag). + +### Changed +- Improved UI for MCP tool call details. +- Improved UI for working set header. + +### Fixed +- Fixed toolcall layout issue. +- Fixed NES display issue. +- Fixed error message for SSL certificate errors. +- Fixed several performance issues. + +## 0.46.0 - December 11, 2025 +### Added +- MCP: Support delete MCP server from list. + +### Changed +- Refine built-in tools layout and displaying error and output details. +- Better support toolCallingLoop continue operation for subagent turn. +- Update feedback forum link. +- Update client-side MCP restore and persist. +- Adopt NES notification. + +### Fixed +- Disable auto focus for fix error window. +- Fixed an issue where no file change was made when insert_edit_into_file tool succeeds. +- Fixed an issue where insert edit was applied to the incorrect file. +- Fixed model picker to use model id instead of model family. +- Fixed read_file, read_directory tool randomly failing. + +## 0.45.0 - November 14, 2025 +### Added +- New models: GPT-5.1, GPT-5.1-Codex, GPT-5.1-Codex-Mini, Claude Haiku 4.5, and Auto (preview). +- Added support for custom agents (preview). +- Introduced the built-in Plan agent (preview). +- Added support for subagent execution (preview). +- Added support for Next Edit Suggestions (preview). + +### Changed +- MCP servers now support dynamic OAuth setup for third-party authentication providers. +- Added a setting to configure the maximum number of tool requests allowed. + +### Fixed +- Fixed an issue that the terminal view in Agent conversation was clipped +- Fixed an issue that the Chat panel failed to recognize newly created workspaces. + +## 0.44.0 - October 15, 2025 +### Added +- Added support for new models in Chat: Grok Code Fast 1, Claude Sonnet 4.5, Claude Opus 4, Claude Opus 4.1 and GPT-5 mini. +- Added support for restoring to a saved checkpoint snapshot. +- Added support for tool selection in agent mode. +- Added the ability to adjust the chat panel font size. +- Added the ability to edit a previous chat message and resend it. +- Introduced a new setting to disable the Copilot โ€œFix Errorโ€ button. +- Added support for custom instructions in the Code Review feature. + +### Changed +- Switched authentication to a new OAuth app "GitHub Copilot IDE Plugin". +- Updated the chat layout to a messenger-style conversation view (user messages on the right, responses on the left). +- Now shows a clearer, more user-friendly message when Copilot finishes responding. +- Added support for skipping a tool call without ending the conversation. + +### Fixed +- Fixed a command injection vulnerability when opening referenced chat files. +- Resolved display issues in the chat view on macOS 26. + ## 0.43.0 - September 4, 2025 ### Fixed - Cannot type non-Latin characters in the chat input field. diff --git a/Config.debug.xcconfig b/Config.debug.xcconfig index 63fae668..da143524 100644 --- a/Config.debug.xcconfig +++ b/Config.debug.xcconfig @@ -10,7 +10,7 @@ EXTENSION_BUNDLE_NAME = GitHub Copilot Dev EXTENSION_BUNDLE_DISPLAY_NAME = GitHub Copilot Dev EXTENSION_SERVICE_NAME = GitHub Copilot for Xcode Extension COPILOT_DOCS_URL = https:$(SLASH)$(SLASH)docs.github.com/en/copilot -COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/orgs/community/discussions/categories/copilot +COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/github/CopilotForXcode/discussions // see also target Configs diff --git a/Config.xcconfig b/Config.xcconfig index 5fba3479..eef78ad4 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -10,6 +10,6 @@ EXTENSION_BUNDLE_NAME = GitHub Copilot EXTENSION_BUNDLE_DISPLAY_NAME = GitHub Copilot EXTENSION_SERVICE_NAME = GitHub Copilot for Xcode Extension COPILOT_DOCS_URL = https:$(SLASH)$(SLASH)docs.github.com/en/copilot -COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/orgs/community/discussions/categories/copilot +COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/github/CopilotForXcode/discussions // see also target Configs diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 2cd753c1..c762e625 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; 5EC511E52C90CFD600632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; 5EC511E62C90CFD700632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; + 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */; }; + 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */; }; C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */; }; C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; @@ -193,6 +195,8 @@ 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; 424ACA202CA4697200FA20F2 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsCommand.swift; sourceTree = ""; }; + 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectNESSuggestionCommand.swift; sourceTree = ""; }; + 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptNESSuggestionCommand.swift; sourceTree = ""; }; C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTextSettingsCommand.swift; sourceTree = ""; }; C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; }; @@ -330,8 +334,10 @@ C8520300293C4D9000460097 /* Helpers.swift */, C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */, C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */, + 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */, C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */, C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */, + 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */, C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */, C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */, C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */, @@ -734,12 +740,14 @@ C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */, C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */, C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */, + 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */, C8520301293C4D9000460097 /* Helpers.swift in Sources */, C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */, C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */, 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */, C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */, C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */, + 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */, C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */, C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */, diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3db257ec..064955a6 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -279,6 +279,15 @@ "version" : "510.0.3" } }, + { + "identity" : "swift-tree-sitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/swift-tree-sitter.git", + "state" : { + "revision" : "08ef81eb8620617b55b08868126707ad72bf754f", + "version" : "0.25.0" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", @@ -306,6 +315,24 @@ "version" : "1.4.0" } }, + { + "identity" : "tree-sitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter", + "state" : { + "revision" : "da6fe9beb4f7f67beb75914ca8e0d48ae48d6406", + "version" : "0.25.10" + } + }, + { + "identity" : "tree-sitter-bash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-bash", + "state" : { + "revision" : "a06c2e4415e9bc0346c6b86d401879ffb44058f7", + "version" : "0.25.1" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index c2efb015..0da62760 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -21,6 +21,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { case chat case settings case tools + case toolsAutoApprove case byok } @@ -51,6 +52,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { return .settings } else if launchArgs.contains("--tools") { return .tools + } else if launchArgs.contains("--tools-auto-approve") { + return .toolsAutoApprove } else if launchArgs.contains("--byok") { return .byok } else { @@ -64,6 +67,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { openSettings() case .tools: openToolsSettings() + case .toolsAutoApprove: + openToolsSettingsAutoApprove() case .byok: openBYOKSettings() case .chat: @@ -92,6 +97,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { hostAppStore.send(.setActiveTab(.tools)) } } + + private func openToolsSettingsAutoApprove() { + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.tools)) + hostAppStore.send(.setActiveToolsSubTab(.AutoApprove)) + } + } private func openBYOKSettings() { DispatchQueue.main.async { @@ -202,6 +215,18 @@ struct CopilotForXcodeApp: App { hostAppStore.send(.setActiveTab(.tools)) } } + + DistributedNotificationCenter.default().addObserver( + forName: .openToolsSettingsAutoApproveWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.tools)) + hostAppStore.send(.setActiveToolsSubTab(.AutoApprove)) + } + } DistributedNotificationCenter.default().addObserver( forName: .openBYOKSettingsWindowRequest, @@ -213,6 +238,17 @@ struct CopilotForXcodeApp: App { hostAppStore.send(.setActiveTab(.byok)) } } + + DistributedNotificationCenter.default().addObserver( + forName: .openAdvancedSettingsWindowRequest, + object: nil, + queue: .main + ) { _ in + DispatchQueue.main.async { + activateAndOpenSettings() + hostAppStore.send(.setActiveTab(.advanced)) + } + } } var body: some Scene { diff --git a/Copilot for Xcode/Credits.rtf b/Copilot for Xcode/Credits.rtf index 13a16781..941fbb70 100644 --- a/Copilot for Xcode/Credits.rtf +++ b/Copilot for Xcode/Credits.rtf @@ -3349,4 +3349,90 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ SOFTWARE.\ \ \ +Dependency: https://github.com/tree-sitter/tree-sitter\ +Version: 0.25.10\ +License Content:\ +The MIT License\ +\ +Copyright (c) 2018 Max Brunsfeld +\ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +\ +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +\ +\ +Dependency: https://github.com/tree-sitter/swift-tree-sitter\ +Version: 0.25.0\ +License Content:\ +BSD 3-Clause License\ +\ +Copyright (c) 2021, Chime +All rights reserved. +\ +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +\ +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +\ +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +\ +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. +\ +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +\ +\ +Dependency: https://github.com/tree-sitter/tree-sitter-bash\ +Version: 0.25.1\ +License Content:\ +The MIT License\ +\ +Copyright (c) 2017 Max Brunsfeld +\ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +\ +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +\ +\ } \ No newline at end of file diff --git a/Core/Package.swift b/Core/Package.swift index 33ad1c48..8e2e58cc 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -53,7 +53,9 @@ let package = Package( .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), .package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), - .package(url: "https://github.com/globulus/swiftui-flow-layout", from: "1.0.5") + .package(url: "https://github.com/globulus/swiftui-flow-layout", from: "1.0.5"), + .package(url: "https://github.com/tree-sitter/swift-tree-sitter.git", from: "0.25.0"), + .package(url: "https://github.com/tree-sitter/tree-sitter-bash", from: "0.25.1") ], targets: [ // MARK: - Main @@ -93,6 +95,7 @@ let package = Package( .product(name: "ChatAPIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "AXHelper", package: "Tool"), + .product(name: "WorkspaceSuggestionService", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), @@ -131,6 +134,7 @@ let package = Package( .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Persist", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), ]), // MARK: - Suggestion Service @@ -184,7 +188,10 @@ let package = Package( .product(name: "AppKitExtension", package: "Tool"), .product(name: "WebContentExtractor", package: "Tool"), .product(name: "GitHelper", package: "Tool"), - .product(name: "SuggestionBasic", package: "Tool") + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "SwiftTreeSitter", package: "swift-tree-sitter"), + .product(name: "SwiftTreeSitterLayer", package: "swift-tree-sitter"), + .product(name: "TreeSitterBash", package: "tree-sitter-bash"), ]), .testTarget( name: "ChatServiceTests", @@ -213,6 +220,7 @@ let package = Package( .target( name: "SuggestionWidget", dependencies: [ + "ChatService", "PromptToCodeService", "ConversationTab", "GitHubCopilotViewModel", @@ -253,6 +261,7 @@ let package = Package( .target( name: "GitHubCopilotViewModel", dependencies: [ + "Client", .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Status", package: "Tool"), @@ -264,6 +273,7 @@ let package = Package( .target( name: "KeyBindingManager", dependencies: [ + "SuggestionWidget", .product(name: "Workspace", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Logger", package: "Tool"), diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index cfbc068c..ac75d819 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -31,6 +31,7 @@ public protocol ChatServiceType { model: String?, modelProviderName: String?, agentMode: Bool, + customChatModeId: String?, userLanguage: String?, turnId: String? ) async throws @@ -48,43 +49,18 @@ struct ToolCallRequest { let completion: (AnyJSONRPCResponse) -> Void } -public struct FileEdit: Equatable { +struct ConversationTurnTrackingState { + var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID + var validConversationIds: Set = [] // Tracks all valid conversation IDs including subagents - public enum Status: String { - case none = "none" - case kept = "kept" - case undone = "undone" - } - - public let fileURL: URL - public let originalContent: String - public var modifiedContent: String - public var status: Status - - /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file` - public var toolName: ToolName - - public init( - fileURL: URL, - originalContent: String, - modifiedContent: String, - status: Status = .none, - toolName: ToolName - ) { - self.fileURL = fileURL - self.originalContent = originalContent - self.modifiedContent = modifiedContent - self.status = status - self.toolName = toolName + mutating func reset() { + turnParentMap.removeAll() + validConversationIds.removeAll() } } public final class ChatService: ChatServiceType, ObservableObject { - public enum RequestType: String, Equatable { - case conversation, codeReview - } - public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false @@ -103,6 +79,9 @@ public final class ChatService: ChatServiceType, ObservableObject { private var lastUserRequest: ConversationRequest? private var isRestored: Bool = false private var pendingToolCallRequests: [String: ToolCallRequest] = [:] + // Workaround: toolConfirmation request does not have parent turnId + private var conversationTurnTracking = ConversationTurnTrackingState() + init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared, @@ -170,28 +149,19 @@ public final class ChatService: ChatServiceType, ObservableObject { private func subscribeToClientToolConfirmationEvent() { ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in - guard let params = request.params, params.conversationId == self?.conversationId else { return } - let editAgentRounds: [AgentRound] = [ - AgentRound(roundId: params.roundId, - reply: "", - toolCalls: [ - AgentToolCall(id: params.toolCallId, name: params.name, status: .waitForConfirmation, invokeParams: params) - ] - ) - ] - self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds) - self?.pendingToolCallRequests[params.toolCallId] = ToolCallRequest( - requestId: request.id, - turnId: params.turnId, - roundId: params.roundId, - toolCallId: params.toolCallId, - completion: completion) + self?.handleClientToolConfirmationEvent(request: request, completion: completion) }).store(in: &cancellables) } private func subscribeToClientToolInvokeEvent() { ClientToolHandlerImpl.shared.onClientToolInvokeEvent.sink(receiveValue: { [weak self] (request, completion) in - guard let params = request.params, params.conversationId == self?.conversationId else { return } + guard let params = request.params else { return } + + // Check if this conversationId is valid (main conversation or subagent conversation) + guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else { + return + } + guard let copilotTool = CopilotToolRegistry.shared.getTool(name: params.name) else { completion(AnyJSONRPCResponse(id: request.id, result: JSONValue.array([ @@ -207,35 +177,37 @@ public final class ChatService: ChatServiceType, ObservableObject { return } - copilotTool.invokeTool(request, completion: completion, chatHistoryUpdater: self?.appendToolCallHistory, contextProvider: self) + _ = copilotTool.invokeTool(request, completion: completion, contextProvider: self) }).store(in: &cancellables) } - private func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound]) { + func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = [], parentTurnId: String? = nil) { let chatTabId = self.chatTabInfo.id Task { + let turnStatus: ChatMessage.TurnStatus? = { + guard let round = editAgentRounds.first, let toolCall = round.toolCalls?.first else { + return nil + } + + switch toolCall.status { + case .waitForConfirmation: return .waitForConfirmation + case .accepted, .running, .completed, .error: return .inProgress + case .cancelled: return .cancelled + } + }() + let message = ChatMessage( assistantMessageWithId: turnId, chatTabID: chatTabId, - editAgentRounds: editAgentRounds + editAgentRounds: editAgentRounds, + parentTurnId: parentTurnId, + fileEdits: fileEdits, + turnStatus: turnStatus ) await self.memory.appendMessage(message) } } - - public func updateFileEdits(by fileEdit: FileEdit) { - if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] { - self.fileEditMap[fileEdit.fileURL] = .init( - fileURL: fileEdit.fileURL, - originalContent: existingFileEdit.originalContent, - modifiedContent: fileEdit.modifiedContent, - toolName: existingFileEdit.toolName - ) - } else { - self.fileEditMap[fileEdit.fileURL] = fileEdit - } - } public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { try await conversationProvider?.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspaceURL: getWorkspaceURL()) @@ -262,77 +234,78 @@ public final class ChatService: ChatServiceType, ObservableObject { self.isRestored = true } + /// Updates the status of a tool call (accepted, cancelled, etc.) and notifies the server + /// + /// This method handles two key responsibilities: + /// 1. Sends confirmation response back to the server when user accepts/cancels + /// 2. Updates the tool call status in chat history UI (including subagent tool calls) public func updateToolCallStatus(toolCallId: String, status: AgentToolCall.ToolCallStatus, payload: Any? = nil) { - if status == .cancelled { - resetOngoingRequest() - return - } - - // Send the tool call result back to the server - if let toolCallRequest = self.pendingToolCallRequests[toolCallId], status == .accepted { + // Capture the pending request info before removing it from the dictionary + let toolCallRequest = self.pendingToolCallRequests[toolCallId] + + // Step 1: Send confirmation response to server (for accept/cancel actions only) + if let toolCallRequest = toolCallRequest, status == .accepted || status == .cancelled { self.pendingToolCallRequests.removeValue(forKey: toolCallId) - let toolResult = LanguageModelToolConfirmationResult(result: .Accept) - let jsonResult = try? JSONEncoder().encode(toolResult) - let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null - toolCallRequest.completion( - AnyJSONRPCResponse( - id: toolCallRequest.requestId, - result: JSONValue.array([ - jsonValue, - JSONValue.null - ]) - ) - ) + sendToolConfirmationResponse(toolCallRequest, accepted: status == .accepted) } - // Update the tool call status in the chat history + // Step 2: Update the tool call status in chat history UI Task { - guard let lastMessage = await memory.history.last, lastMessage.role == .assistant else { + guard let targetMessage = await ToolCallStatusUpdater.findMessageContainingToolCall( + toolCallRequest, + conversationTurnTracking: conversationTurnTracking, + history: await memory.history + ) else { return } - - var updatedAgentRounds: [AgentRound] = [] - for i in 0.. Bool { + conversationTurnTracking.validConversationIds.contains(conversationId) + } + + /// Workaround: toolConfirmation request does not have parent turnId. + func parentTurnIdForTurnId(_ turnId: String) -> String? { + conversationTurnTracking.turnParentMap[turnId] + } + + func storePendingToolCallRequest(toolCallId: String, request: ToolCallRequest) { + pendingToolCallRequests[toolCallId] = request + } + + /// Sends the confirmation response (accept/dismiss) back to the server + func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) { + let toolResult = LanguageModelToolConfirmationResult( + result: accepted ? .Accept : .Dismiss + ) + let jsonResult = try? JSONEncoder().encode(toolResult) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + + request.completion( + AnyJSONRPCResponse( + id: request.requestId, + result: JSONValue.array([jsonValue, JSONValue.null]) + ) + ) + } + public enum ChatServiceError: Error, LocalizedError { case conflictingImageFormats(String) @@ -354,6 +327,7 @@ public final class ChatService: ChatServiceType, ObservableObject { model: String? = nil, modelProviderName: String? = nil, agentMode: Bool = false, + customChatModeId: String? = nil, userLanguage: String? = nil, turnId: String? = nil ) async throws { @@ -459,6 +433,7 @@ public final class ChatService: ChatServiceType, ObservableObject { model: model, modelProviderName: modelProviderName, agentMode: agentMode, + customChatModeId: customChatModeId, userLanguage: userLanguage, turnId: currentTurnId, skillSet: validSkillSet @@ -466,7 +441,18 @@ public final class ChatService: ChatServiceType, ObservableObject { self.lastUserRequest = request self.skillSet = validSkillSet - try await sendConversationRequest(request) + + do { + if let response = try await sendConversationRequest(request) { + await handleConversationCreateResponse(response) + } + } catch { + // Check if this is a certificate error and show helpful message + if isCertificateError(error) { + await showCertificateErrorMessage(turnId: currentTurnId) + } + throw error + } } private func createConversationRequest( @@ -478,6 +464,7 @@ public final class ChatService: ChatServiceType, ObservableObject { model: String? = nil, modelProviderName: String? = nil, agentMode: Bool = false, + customChatModeId: String? = nil, userLanguage: String? = nil, turnId: String? = nil, skillSet: [ConversationSkill] @@ -503,10 +490,22 @@ public final class ChatService: ChatServiceType, ObservableObject { model: model, modelProviderName: modelProviderName, agentMode: agentMode, + customChatModeId: customChatModeId, userLanguage: userLanguage, turnId: turnId ) } + + private func handleConversationCreateResponse(_ response: ConversationCreateResponse) async { + await memory.mutateHistory { history in + if let index = history.firstIndex(where: { $0.id == response.turnId && $0.role.isAssistant }) { + history[index].modelName = response.modelName + history[index].billingMultiplier = response.billingMultiplier + + self.saveChatMessageToStorage(history[index]) + } + } + } public func sendAndWait(_ id: String, content: String) async throws -> String { try await send(id, content: content, skillSet: [], references: []) @@ -524,9 +523,10 @@ public final class ChatService: ChatServiceType, ObservableObject { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } } - resetOngoingRequest() + resetOngoingRequest(with: .cancelled) } + // Not used public func clearHistory() async { let messageIds = await memory.history.map { $0.id } @@ -542,10 +542,17 @@ public final class ChatService: ChatServiceType, ObservableObject { deleteAllChatMessagesFromStorage(messageIds) resetOngoingRequest() } - - public func deleteMessage(id: String) async { - await memory.removeMessage(id) - deleteChatMessageFromStorage(id) + + public func deleteMessages(ids: [String]) async { + let turnIdsFromMessages = await memory.history + .filter { ids.contains($0.id) } + .compactMap { $0.clsTurnID } + .map { String($0) } + let turnIds = Array(Set(turnIdsFromMessages)) + + await memory.removeMessages(ids) + await deleteTurns(turnIds) + deleteAllChatMessagesFromStorage(ids) } public func resendMessage(id: String, model: String? = nil, modelProviderName: String? = nil) async throws { @@ -563,6 +570,7 @@ public final class ChatService: ChatServiceType, ObservableObject { model: model != nil ? model : lastUserRequest.model, modelProviderName: modelProviderName, agentMode: lastUserRequest.agentMode, + customChatModeId: lastUserRequest.customChatModeId, userLanguage: lastUserRequest.userLanguage, turnId: id ) @@ -680,8 +688,22 @@ public final class ChatService: ChatServiceType, ObservableObject { private func handleProgressBegin(token: String, progress: ConversationProgressBegin) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } - conversationId = progress.conversationId + // Only update conversationId for main turns, not subagent turns + // Subagent turns have their own conversation ID which should not replace the parent + if progress.parentTurnId == nil { + conversationId = progress.conversationId + } + + // Track all valid conversation IDs for the current turn (main conversation + its subturns) + conversationTurnTracking.validConversationIds.insert(progress.conversationId) + let turnId = progress.turnId + let parentTurnId = progress.parentTurnId + + // Track parent-subturn relationship + if let parentTurnId = parentTurnId { + conversationTurnTracking.turnParentMap[turnId] = parentTurnId + } Task { if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) { @@ -705,10 +727,17 @@ public final class ChatService: ChatServiceType, ObservableObject { /// Display an initial assistant message immediately after the user sends a message. /// This improves perceived responsiveness, especially in Agent Mode where the first /// ProgressReport may take long time. - let message = ChatMessage(assistantMessageWithId: turnId, chatTabID: chatTabInfo.id) + /// Skip creating a new message for subturns - they will be merged into the parent turn + if parentTurnId == nil { + let message = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id, + turnStatus: .inProgress + ) - // will persist in resetOngoingRequest() - await memory.appendMessage(message) + // will persist in resetOngoingRequest() + await memory.appendMessage(message) + } } } @@ -722,6 +751,7 @@ public final class ChatService: ChatServiceType, ObservableObject { var references: [ConversationReference] = [] var steps: [ConversationProgressStep] = [] var editAgentRounds: [AgentRound] = [] + let parentTurnId = progress.parentTurnId if let reply = progress.reply { content = reply @@ -739,15 +769,15 @@ public final class ChatService: ChatServiceType, ObservableObject { editAgentRounds = progressAgentRounds } - if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty { + if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty && parentTurnId == nil { return } - // create immutable copies let messageContent = content let messageReferences = references let messageSteps = steps let messageAgentRounds = editAgentRounds + let messageParentTurnId = parentTurnId Task { let message = ChatMessage( @@ -756,10 +786,11 @@ public final class ChatService: ChatServiceType, ObservableObject { content: messageContent, references: messageReferences, steps: messageSteps, - editAgentRounds: messageAgentRounds + editAgentRounds: messageAgentRounds, + parentTurnId: messageParentTurnId, + turnStatus: .inProgress ) - // will persist in resetOngoingRequest() await memory.appendMessage(message) } } @@ -772,12 +803,22 @@ public final class ChatService: ChatServiceType, ObservableObject { // CLS Error Code 402: reached monthly chat messages limit if CLSError.code == 402 { Task { + let selectedModel = lastUserRequest?.model + let selectedModelProviderName = lastUserRequest?.modelProviderName + + var errorMessageText: String + if let selectedModel = selectedModel, let selectedModelProviderName = selectedModelProviderName { + errorMessageText = "You've reached your quota limit for your BYOK model \(selectedModel). Please check with \(selectedModelProviderName) for more information." + } else { + errorMessageText = CLSError.message + } + await Status.shared - .updateCLSStatus(.warning, busy: false, message: CLSError.message) + .updateCLSStatus(.warning, busy: false, message: errorMessageText) let errorMessage = ChatMessage( errorMessageWithId: progress.turnId, chatTabID: chatTabInfo.id, - panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)] + panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: errorMessageText, location: .Panel)] ) // will persist in resetongoingRequest() await memory.appendMessage(errorMessage) @@ -788,7 +829,7 @@ public final class ChatService: ChatServiceType, ObservableObject { guard let fallbackModel = CopilotModelManager.getFallbackLLM( scope: lastUserRequest.agentMode ? .agentPanel : .chatPanel ) else { - resetOngoingRequest() + resetOngoingRequest(with: .error) return } do { @@ -800,7 +841,7 @@ public final class ChatService: ChatServiceType, ObservableObject { ) } catch { Logger.gitHubCopilot.error(error) - resetOngoingRequest() + resetOngoingRequest(with: .error) } return } @@ -813,19 +854,25 @@ public final class ChatService: ChatServiceType, ObservableObject { errorMessages: ["Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)."] ) await memory.appendMessage(errorMessage) - resetOngoingRequest() + resetOngoingRequest(with: .error) return } } else { Task { + var clsErrorMessage = CLSError.message + if CLSError.code == ConversationErrorCode.toolRoundExceedError.rawValue { + // TODO: Remove this after `Continue` is supported. + clsErrorMessage = HardCodedToolRoundExceedErrorMessage + } + let errorMessage = ChatMessage( errorMessageWithId: progress.turnId, chatTabID: chatTabInfo.id, - errorMessages: [CLSError.message] + errorMessages: [clsErrorMessage] ) // will persist in resetOngoingRequest() await memory.appendMessage(errorMessage) - resetOngoingRequest() + resetOngoingRequest(with: .error) return } } @@ -836,18 +883,22 @@ public final class ChatService: ChatServiceType, ObservableObject { assistantMessageWithId: progress.turnId, chatTabID: chatTabInfo.id, followUp: followUp, - suggestedTitle: progress.suggestedTitle + suggestedTitle: progress.suggestedTitle, + turnStatus: .success ) // will persist in resetOngoingRequest() await memory.appendMessage(message) - resetOngoingRequest() + resetOngoingRequest(with: .success) } } - private func resetOngoingRequest() { + private func resetOngoingRequest(with turnStatus: ChatMessage.TurnStatus = .success) { activeRequestId = nil isReceivingMessage = false requestType = nil + + // Clear turn tracking data + conversationTurnTracking.reset() // cancel all pending tool call requests for (_, request) in pendingToolCallRequests { @@ -890,6 +941,20 @@ public final class ChatService: ChatServiceType, ObservableObject { history[lastIndex].editAgentRounds[i].toolCalls![j].status = .cancelled } } + + // Cancel tool calls in subagent rounds + if let subAgentRounds = history[lastIndex].editAgentRounds[i].subAgentRounds { + for k in 0.. ConversationCreateResponse? { guard !isReceivingMessage else { throw CancellationError() } isReceivingMessage = true requestType = .conversation do { if let conversationId = conversationId { - try await conversationProvider? + return try await conversationProvider? .createTurn( with: conversationId, request: request, @@ -936,45 +1003,67 @@ public final class ChatService: ChatServiceType, ObservableObject { requestWithTurns.turns = turns } - try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) + return try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) } } catch { - resetOngoingRequest() + resetOngoingRequest(with: .error) throw error } } - // MARK: - File Edit - public func undoFileEdit(for fileURL: URL) throws { - guard let fileEdit = self.fileEditMap[fileURL], - fileEdit.status == .none - else { return } - - switch fileEdit.toolName { - case .insertEditIntoFile: - InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) - case .createFile: - try CreateFileTool.undo(for: fileURL) - default: + private func deleteTurns(_ turnIds: [String]) async { + guard !turnIds.isEmpty, let conversationId = conversationId else { return } - self.fileEditMap[fileURL]!.status = .undone + let workspaceURL = getWorkspaceURL() + + for turnId in turnIds { + do { + try await conversationProvider? + .deleteTurn(with: conversationId, turnId: turnId, workspaceURL: workspaceURL) + } catch { + Logger.client.error("Failed to delete turn: \(error)") + } + } } - public func keepFileEdit(for fileURL: URL) { - guard let fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none - else { return } - self.fileEditMap[fileURL]!.status = .kept - } + // MARK: - Certificate Error Detection - public func resetFileEdits() { - self.fileEditMap = [:] + /// Checks if an error is related to SSL certificate issues + private func isCertificateError(_ error: Error) -> Bool { + let errorDescription = error.localizedDescription.lowercased() + + // Check for certificate error messages + if errorDescription.contains("unable to get local issuer certificate") || + errorDescription.contains("self-signed certificate in certificate chain") || + errorDescription.contains("unable_to_get_issuer_cert_locally") { + return true + } + + // Check GitHubCopilotError with ServerError + if let serverError = error as? ServerError, + case .serverError(_, let message, _) = serverError { + let serverMessage = message.lowercased() + if serverMessage.contains("unable to get local issuer certificate") || + serverMessage.contains("self-signed certificate in certificate chain") { + return true + } + } + + return false } - public func discardFileEdit(for fileURL: URL) throws { - try self.undoFileEdit(for: fileURL) - self.fileEditMap.removeValue(forKey: fileURL) + private func showCertificateErrorMessage(turnId: String?) async { + let messageId = turnId ?? UUID().uuidString + let errorMessage = ChatMessage( + errorMessageWithId: messageId, + chatTabID: chatTabInfo.id, + errorMessages: [ + SSLCertificateErrorMessage + ] + ) + await memory.appendMessage(errorMessage) } } @@ -982,6 +1071,7 @@ public final class ChatService: ChatServiceType, ObservableObject { public final class SharedChatService { public var chatTemplates: [ChatTemplate]? = nil public var chatAgents: [ChatAgent]? = nil + public var conversationModes: [ConversationMode]? = nil private let conversationProvider: ConversationServiceProvider? public static let shared = SharedChatService.service() @@ -1010,6 +1100,19 @@ public final class SharedChatService { return nil } + public func loadConversationModes() async -> [ConversationMode]? { + do { + if let modes = (try await conversationProvider?.modes()) { + self.conversationModes = modes + return modes + } + } catch { + // handle error if desired + } + + return nil + } + public func copilotModels() async -> [CopilotModel] { guard let models = try? await conversationProvider?.models() else { return [] } return models @@ -1156,17 +1259,18 @@ extension ChatService { } isReceivingMessage = true requestType = .codeReview + let turnId = UUID().uuidString do { await CodeReviewService.shared.resetComments() - let turnId = UUID().uuidString - await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group) let initialBotMessage = ChatMessage( assistantMessageWithId: turnId, - chatTabID: chatTabInfo.id + chatTabID: chatTabInfo.id, + turnStatus: .inProgress, + requestType: .codeReview ) await memory.appendMessage(initialBotMessage) @@ -1174,7 +1278,7 @@ extension ChatService { else { let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") await appendCodeReviewRound(round) - resetOngoingRequest() + resetOngoingRequest(with: .error) return } @@ -1198,9 +1302,9 @@ extension ChatService { status: .waitForConfirmation, request: .from(prChanges) ) - await appendCodeReviewRound(round) + await appendCodeReviewRound(round, turnStatus: .waitForConfirmation) } catch { - resetOngoingRequest() + resetOngoingRequest(with: .error) throw error } } @@ -1215,9 +1319,15 @@ extension ChatService { } } - private func appendCodeReviewRound(_ round: CodeReviewRound) async { + private func appendCodeReviewRound( + _ round: CodeReviewRound, + turnStatus: ChatMessage.TurnStatus? = nil + ) async { let message = ChatMessage( - assistantMessageWithId: round.turnId, chatTabID: chatTabInfo.id, codeReviewRound: round + assistantMessageWithId: round.turnId, + chatTabID: chatTabInfo.id, + codeReviewRound: round, + turnStatus: turnStatus ) await memory.appendMessage(message) @@ -1253,7 +1363,7 @@ extension ChatService { round.status = .accepted request.updateSelectedChanges(by: selectedFileUris) round.request = request - await appendCodeReviewRound(round) + await appendCodeReviewRound(round, turnStatus: .inProgress) round.status = .running await appendCodeReviewRound(round) @@ -1266,7 +1376,7 @@ extension ChatService { if let errorMessage = errorMessage { round = round.withError(errorMessage) await appendCodeReviewRound(round) - resetOngoingRequest() + resetOngoingRequest(with: .error) return } @@ -1290,14 +1400,19 @@ extension ChatService { round.status = .cancelled await appendCodeReviewRound(round) - resetOngoingRequest() + resetOngoingRequest(with: .cancelled) } private func addCodeReviewUserMessage(id: String, turnId: String, group: GitDiffGroup) async { let content = group == .index ? "Code review for staged changes." : "Code review for unstaged changes." - let chatMessage = ChatMessage(userMessageWithId: id, chatTabId: chatTabInfo.id, content: content) + let chatMessage = ChatMessage( + userMessageWithId: id, + chatTabId: chatTabInfo.id, + content: content, + requestType: .codeReview + ) await memory.appendMessage(chatMessage) saveChatMessageToStorage(chatMessage) } diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift index 2fddf1b3..c41eb61b 100644 --- a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift +++ b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift @@ -49,11 +49,9 @@ public struct CodeReviewProvider { ) async throws -> CodeReviewResult? { return try await context.conversationServiceProvider? .reviewChanges( - .init( - changes: changes.map { - .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent) - } - ) + changes.map { + .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent) + } ) } } diff --git a/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift new file mode 100644 index 00000000..c901a341 --- /dev/null +++ b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift @@ -0,0 +1,55 @@ +import Foundation +import ConversationServiceProvider +import ChatAPIService + +extension ChatService { + // MARK: - File Edit + + public func updateFileEdits(by fileEdit: FileEdit) { + if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] { + self.fileEditMap[fileEdit.fileURL] = .init( + fileURL: fileEdit.fileURL, + originalContent: existingFileEdit.originalContent, + modifiedContent: fileEdit.modifiedContent, + toolName: existingFileEdit.toolName + ) + } else { + self.fileEditMap[fileEdit.fileURL] = fileEdit + } + } + + public func undoFileEdit(for fileURL: URL) throws { + guard var fileEdit = self.fileEditMap[fileURL], + fileEdit.status == .none + else { return } + + switch fileEdit.toolName { + case .insertEditIntoFile: + InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent) + case .createFile: + try CreateFileTool.undo(for: fileURL) + default: + return + } + + fileEdit.status = .undone + self.fileEditMap[fileURL] = fileEdit + } + + public func keepFileEdit(for fileURL: URL) { + guard var fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none + else { return } + + fileEdit.status = .kept + self.fileEditMap[fileURL] = fileEdit + } + + public func resetFileEdits() { + self.fileEditMap = [:] + } + + public func discardFileEdit(for fileURL: URL) throws { + try self.undoFileEdit(for: fileURL) + self.fileEditMap.removeValue(forKey: fileURL) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift new file mode 100644 index 00000000..d3a47556 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift @@ -0,0 +1,11 @@ +import Foundation + +public typealias ConversationID = String + +public enum AutoApprovalScope: Hashable { + case session(ConversationID) + /// Applies to all workspaces. Persisted in `UserDefaults.autoApproval`. + case global + // Future scopes: + // case workspace(String) +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift new file mode 100644 index 00000000..77a6b1e6 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift @@ -0,0 +1,163 @@ +import Foundation +import Preferences + +struct MCPApprovalStorage { + /// Stored under `UserDefaults.autoApproval` with key `AutoApproval_MCP_GlobalApprovals`. + /// + /// Stored as native property-list types (NSDictionary/NSArray/Bool/String) + /// so users can edit values directly in the `*.prefs.plist`. + /// + /// Sample structure: + /// ``` + /// { + /// "servers": { + /// "github": { + /// "isServerAllowed": false, + /// "allowedTools": ["search_issues", "get_issue"] + /// }, + /// "my-filesystem-server": { + /// "isServerAllowed": true, + /// "allowedTools": [] + /// } + /// } + /// } + /// ``` + + private struct ServerApprovalState { + var isServerAllowed: Bool = false + var allowedTools: Set = [] + } + + private struct ConversationApprovalState { + var serverApprovals: [String: ServerApprovalState] = [:] + } + + + /// Storage for session-scoped approvals. + private var approvals: [ConversationID: ConversationApprovalState] = [:] + + private var workspaceUserDefaults: UserDefaultsType { UserDefaults.autoApproval } + + mutating func allowTool(scope: AutoApprovalScope, serverName: String, toolName: String) { + let server = normalize(serverName) + let tool = normalize(toolName) + guard !server.isEmpty, !tool.isEmpty else { return } + + switch scope { + case .session(let conversationId): + allowToolInSession(conversationId: conversationId, server: server, tool: tool) + case .global: + allowToolInGlobal(server: server, tool: tool) + } + } + + mutating func allowServer(scope: AutoApprovalScope, serverName: String) { + let server = normalize(serverName) + guard !server.isEmpty else { return } + + switch scope { + case .session(let conversationId): + allowServerInSession(conversationId: conversationId, server: server) + case .global: + allowServerInGlobal(server: server) + } + } + + func isAllowed(scope: AutoApprovalScope, serverName: String, toolName: String) -> Bool { + let server = normalize(serverName) + let tool = normalize(toolName) + guard !server.isEmpty, !tool.isEmpty else { return false } + + switch scope { + case .session(let conversationId): + return isAllowedInSession(conversationId: conversationId, server: server, tool: tool) + case .global: + return isAllowedInGlobal(server: server, tool: tool) + } + } + + mutating func clear(scope: AutoApprovalScope) { + switch scope { + case .session(let conversationId): + clearSession(conversationId: conversationId) + case .global: + clearGlobal() + } + } + + // MARK: - Session-scoped operations (in-memory) + + private mutating func allowToolInSession(conversationId: String, server: String, tool: String) { + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()] + .serverApprovals[server, default: ServerApprovalState()] + .allowedTools + .insert(tool) + } + + private mutating func allowServerInSession(conversationId: String, server: String) { + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()] + .serverApprovals[server, default: ServerApprovalState()] + .isServerAllowed = true + } + + private func isAllowedInSession(conversationId: String, server: String, tool: String) -> Bool { + guard !conversationId.isEmpty else { return false } + guard let conversationState = approvals[conversationId], + let serverState = conversationState.serverApprovals[server] else { return false } + if serverState.isServerAllowed { return true } + return serverState.allowedTools.contains(tool) + } + + private mutating func clearSession(conversationId: String) { + guard !conversationId.isEmpty else { return } + approvals.removeValue(forKey: conversationId) + } + + // MARK: - Global operations (persisted) + + private mutating func allowToolInGlobal(server: String, tool: String) { + var globalApprovals = workspaceUserDefaults.value(for: \.mcpServersGlobalApprovals) + var serverState = globalApprovals.servers[server] ?? MCPServerApprovalState() + + serverState.allowedTools.insert(tool) + globalApprovals.servers[server] = serverState + workspaceUserDefaults.set(globalApprovals, for: \.mcpServersGlobalApprovals) + + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private mutating func allowServerInGlobal(server: String) { + var globalApprovals = workspaceUserDefaults.value(for: \.mcpServersGlobalApprovals) + var serverState = globalApprovals.servers[server] ?? MCPServerApprovalState() + + serverState.isServerAllowed = true + globalApprovals.servers[server] = serverState + workspaceUserDefaults.set(globalApprovals, for: \.mcpServersGlobalApprovals) + + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private func isAllowedInGlobal(server: String, tool: String) -> Bool { + let globalApprovals = workspaceUserDefaults.value(for: \.mcpServersGlobalApprovals) + guard let serverState = globalApprovals.servers[server] else { return false } + + if serverState.isServerAllowed { return true } + return serverState.allowedTools.contains(tool) + } + + private mutating func clearGlobal() { + workspaceUserDefaults.set(AutoApprovedMCPServers(), for: \.mcpServersGlobalApprovals) + } + + private func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/SensitiveFileApprovalStorage.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/SensitiveFileApprovalStorage.swift new file mode 100644 index 00000000..0c204b70 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/SensitiveFileApprovalStorage.swift @@ -0,0 +1,141 @@ +import Foundation +import Preferences + +struct SensitiveFileApprovalStorage { + /// Stored under `UserDefaults.autoApproval` with key `AutoApproval_SensitiveFiles_GlobalApprovals`. + /// + /// Stored as native property-list types (NSDictionary/NSArray/String) + /// so users can edit values directly in the `*.prefs.plist`. + /// + /// Sample structure: + /// ``` + /// { + /// "rules": { + /// "**/*.env": { "description": "Secrets", "autoApprove": true } + /// } + /// } + /// ``` + + private struct ToolApprovalState { + var allowedFiles: Set = [] + } + + private struct ConversationApprovalState { + var toolApprovals: [String: ToolApprovalState] = [:] + } + + + /// Storage for session-scoped approvals. + private var approvals: [ConversationID: ConversationApprovalState] = [:] + + private var workspaceUserDefaults: UserDefaultsType { UserDefaults.autoApproval } + + mutating func allowFile( + scope: AutoApprovalScope, + toolName: String, + fileKey: String + ) { + guard case .session(let conversationId) = scope else { return } + + let tool = normalize(toolName) + let key = normalize(fileKey) + guard !tool.isEmpty, !key.isEmpty else { return } + + allowFileInSession(conversationId: conversationId, tool: tool, fileKey: key) + } + + mutating func allowFile( + scope: AutoApprovalScope, + description: String, + pattern: String + ) { + guard case .global = scope else { return } + + let ruleKey = normalize(pattern) + guard !ruleKey.isEmpty else { return } + + storeRuleInGlobal( + ruleKey: ruleKey, + description: normalize(description), + autoApprove: true + ) + } + + func isAllowed(scope: AutoApprovalScope, toolName: String, fileKey: String) -> Bool { + guard case .session(let conversationId) = scope else { return false } + + let tool = normalize(toolName) + let key = normalize(fileKey) + guard !conversationId.isEmpty, !tool.isEmpty, !key.isEmpty else { return false } + + return isAllowedInSession(conversationId: conversationId, tool: tool, fileKey: key) + } + + mutating func clear(scope: AutoApprovalScope) { + switch scope { + case .session(let conversationId): + clearSession(conversationId: conversationId) + case .global: + clearGlobal() + } + } + + // MARK: - Session-scoped operations (in-memory) + + private mutating func allowFileInSession(conversationId: String, tool: String, fileKey: String) { + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()] + .toolApprovals[tool, default: ToolApprovalState()] + .allowedFiles + .insert(fileKey) + } + + private func isAllowedInSession(conversationId: String, tool: String, fileKey: String) -> Bool { + guard !conversationId.isEmpty else { return false } + return approvals[conversationId]?.toolApprovals[tool]?.allowedFiles.contains(fileKey) == true + } + + private mutating func clearSession(conversationId: String) { + guard !conversationId.isEmpty else { return } + approvals.removeValue(forKey: conversationId) + } + + // MARK: - Global operations (persisted) + + private mutating func storeRuleInGlobal( + ruleKey: String, + description: String, + autoApprove: Bool + ) { + var state = loadGlobalApprovalState() + var rule = state.rules[ruleKey] ?? SensitiveFileRule(description: "", autoApprove: false) + + if !description.isEmpty { + rule.description = description + } + rule.autoApprove = autoApprove + state.rules[ruleKey] = rule + + saveGlobalApprovalState(state) + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private mutating func clearGlobal() { + workspaceUserDefaults.set(SensitiveFilesRules(), for: \.sensitiveFilesGlobalApprovals) + } + + private func loadGlobalApprovalState() -> SensitiveFilesRules { + return workspaceUserDefaults.value(for: \.sensitiveFilesGlobalApprovals) + } + + private func saveGlobalApprovalState(_ state: SensitiveFilesRules) { + workspaceUserDefaults.set(state, for: \.sensitiveFilesGlobalApprovals) + } + + private func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/TerminalApprovalStorage.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/TerminalApprovalStorage.swift new file mode 100644 index 00000000..f8641499 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/TerminalApprovalStorage.swift @@ -0,0 +1,152 @@ +import Foundation +import Preferences + +struct TerminalApprovalStorage { + /// Stored under `UserDefaults.autoApproval` with key `AutoApproval_Terminal_GlobalApprovals`. + /// + /// Stored as native property-list types (NSDictionary/NSArray/String) + /// so users can edit values directly in the `*.prefs.plist`. + /// + /// Sample structure: + /// ``` + /// { + /// "commands": { + /// "git status": true + /// } + /// } + /// ``` + + private struct ConversationApprovalState { + var isAllCommandsAllowed: Bool = false + /// Stored as normalized command names (e.g. `git`, `brew`) and/or normalized + /// exact command lines (e.g. `git status`). + /// + /// Note: command names are case-sensitive (e.g. `FOO` != `foo`). + var allowedCommands: Set = [] + } + + private var workspaceUserDefaults: UserDefaultsType { UserDefaults.autoApproval } + + /// Storage for session-scoped approvals. + private var approvals: [ConversationID: ConversationApprovalState] = [:] + + mutating func allowAllCommands(scope: AutoApprovalScope) { + guard case .session(let conversationId) = scope else { return } + guard !conversationId.isEmpty else { return } + approvals[conversationId, default: ConversationApprovalState()].isAllCommandsAllowed = true + } + + mutating func allowCommands(scope: AutoApprovalScope, commands: [String]) { + switch scope { + case .global: + allowCommandsGlobally(commands: commands) + case .session(let conversationId): + allowCommandsInSession(conversationId: conversationId, commands: commands) + } + } + + func isAllowed(scope: AutoApprovalScope, commandLine: String) -> Bool { + guard case .session(let conversationId) = scope else { return false } + + let normalizedCommandLine = normalizeCommandLine(commandLine) + guard !normalizedCommandLine.isEmpty else { return false } + + return isAllowedInSession(conversationId: conversationId, commandLine: normalizedCommandLine) + } + + func isAllCommandsAllowedInSession(conversationId: ConversationID) -> Bool { + guard !conversationId.isEmpty else { return false } + return approvals[conversationId]?.isAllCommandsAllowed == true + } + + mutating func clear(scope: AutoApprovalScope) { + switch scope { + case .session(let conversationId): + approvals.removeValue(forKey: conversationId) + case .global: + workspaceUserDefaults.set(TerminalCommandsRules(), for: \.terminalCommandsGlobalApprovals) + } + } + + // MARK: - Global operations (persisted) + + private mutating func storeRuleInGlobal(commandKey: String, autoApprove: Bool) { + var state = loadGlobalApprovalState() + state.commands[commandKey] = autoApprove + + saveGlobalApprovalState(state) + NotificationCenter.default.post( + name: .githubCopilotAgentAutoApprovalDidChange, + object: nil + ) + } + + private mutating func allowCommandsGlobally(commands: [String]) { + let keys = commands + .map { normalizeCommandLine($0) } + .filter { !$0.isEmpty } + + guard !keys.isEmpty else { return } + + for key in keys { + storeRuleInGlobal(commandKey: key, autoApprove: true) + } + } + + private mutating func allowCommandsInSession(conversationId: String, commands: [String]) { + guard !conversationId.isEmpty else { return } + + let trimmed = commands.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return } + + var state = approvals[conversationId, default: ConversationApprovalState()] + + for item in trimmed { + // Heuristic: + // - entries containing whitespace are treated as exact command lines + // - otherwise treated as command names (matching `cmd ...`) + if item.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { + let exact = normalizeCommandLine(item) + if !exact.isEmpty { + state.allowedCommands.insert(exact) + } + } else { + let name = normalizeCommandLine(item) + if !name.isEmpty { + state.allowedCommands.insert(name) + } + } + } + + approvals[conversationId] = state + } + + private func isAllowedInSession(conversationId: String, commandLine: String) -> Bool { + guard !conversationId.isEmpty else { return false } + guard let state = approvals[conversationId] else { return false } + + if state.isAllCommandsAllowed { return true } + if state.allowedCommands.contains(commandLine) { return true } + + let requiredCommandNames = ToolAutoApprovalManager.extractTerminalCommandNames(from: commandLine) + .map { normalizeCommandLine($0) } + .filter { !$0.isEmpty } + + guard !requiredCommandNames.isEmpty else { return false } + return requiredCommandNames.allSatisfy { state.allowedCommands.contains($0) } + } + + private func loadGlobalApprovalState() -> TerminalCommandsRules { + workspaceUserDefaults.value(for: \.terminalCommandsGlobalApprovals) + } + + private func saveGlobalApprovalState(_ state: TerminalCommandsRules) { + workspaceUserDefaults.set(state, for: \.terminalCommandsGlobalApprovals) + } + + // MARK: - Key normalization + + private func normalizeCommandLine(_ commandLine: String) -> String { + commandLine.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift new file mode 100644 index 00000000..71757fa8 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift @@ -0,0 +1,179 @@ +import Foundation + +public actor ToolAutoApprovalManager { + public static let shared = ToolAutoApprovalManager() + + public enum AutoApproval: Equatable, Sendable { + case mcpTool(scope: AutoApprovalScope, serverName: String, toolName: String) + case mcpServer(scope: AutoApprovalScope, serverName: String) + case sensitiveFile( + scope: AutoApprovalScope, + toolName: String, + description: String, + pattern: String? + ) + case terminal(scope: AutoApprovalScope, commands: [String]) + } + + private var mcpStorage = MCPApprovalStorage() + private var sensitiveFileStorage = SensitiveFileApprovalStorage() + private var terminalStorage = TerminalApprovalStorage() + + public init() {} + + public func approve(_ approval: AutoApproval) { + switch approval { + case let .mcpTool(scope, serverName, toolName): + switch scope { + case .session(let conversationId): + allowMCPTool(conversationId: conversationId, serverName: serverName, toolName: toolName) + case .global: + allowMCPToolGlobally(serverName: serverName, toolName: toolName) + } + + case let .mcpServer(scope, serverName): + switch scope { + case .session(let conversationId): + allowMCPServer(conversationId: conversationId, serverName: serverName) + case .global: + allowMCPServerGlobally(serverName: serverName) + } + + case let .sensitiveFile(scope, toolName, description, pattern): + switch scope { + case .session(let conversationId): + let key = resolveFileKey(description: description, pattern: pattern) + allowSensitiveFile(conversationId: conversationId, toolName: toolName, fileKey: key) + case .global: + guard let pattern, !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + // Global approvals require an explicit pattern. + return + } + allowSensitiveRuleGlobally(description: description, pattern: pattern) + } + + case let .terminal(scope, commands): + switch scope { + case .global: + allowTerminalCommandGlobally(commands: commands) + case .session(let conversationId): + if commands.isEmpty { + allowTerminalAllCommandsInSession(conversationId: conversationId) + } else { + allowTerminalCommandsInSession(conversationId: conversationId, commands: commands) + } + } + } + } + + // MARK: - MCP approvals + + public func allowMCPTool(conversationId: String, serverName: String, toolName: String) { + mcpStorage.allowTool(scope: .session(conversationId), serverName: serverName, toolName: toolName) + } + + public func allowMCPServer(conversationId: String, serverName: String) { + mcpStorage.allowServer(scope: .session(conversationId), serverName: serverName) + } + + public func isMCPAllowed( + conversationId: String, + serverName: String, + toolName: String + ) -> Bool { + mcpStorage.isAllowed(scope: .session(conversationId), serverName: serverName, toolName: toolName) + } + + // MARK: - Global MCP approvals + + public func allowMCPToolGlobally(serverName: String, toolName: String) { + mcpStorage.allowTool(scope: .global, serverName: serverName, toolName: toolName) + } + + public func allowMCPServerGlobally(serverName: String) { + mcpStorage.allowServer(scope: .global, serverName: serverName) + } + + public func isMCPAllowedGlobally(serverName: String, toolName: String) -> Bool { + mcpStorage.isAllowed(scope: .global, serverName: serverName, toolName: toolName) + } + + // MARK: - Sensitive file approvals + + public func allowSensitiveFile(conversationId: String, toolName: String, fileKey: String) { + sensitiveFileStorage.allowFile(scope: .session(conversationId), toolName: toolName, fileKey: fileKey) + } + + public func isSensitiveFileAllowed( + conversationId: String, + toolName: String, + fileKey: String + ) -> Bool { + sensitiveFileStorage.isAllowed(scope: .session(conversationId), toolName: toolName, fileKey: fileKey) + } + + // MARK: - Global Sensitive file approvals + + public func allowSensitiveRuleGlobally(description: String, pattern: String) { + // toolName is intentionally ignored for global sensitive-file approvals. + sensitiveFileStorage.allowFile( + scope: .global, + description: description, + pattern: pattern + ) + } + + // MARK: - Global terminal approvals + + /// Stores global auto-approvals for one or more terminal command lines. + public func allowTerminalCommandGlobally(commands: [String]) { + terminalStorage.allowCommands(scope: .global, commands: commands) + } + + /// Stores session-scoped auto-approvals. + /// + /// Heuristic: + /// - entries containing whitespace are treated as exact command lines + /// - otherwise treated as command names (matching `cmd ...`) + public func allowTerminalCommandsInSession(conversationId: String, commands: [String]) { + terminalStorage.allowCommands(scope: .session(conversationId), commands: commands) + } + + public func allowTerminalAllCommandsInSession(conversationId: String) { + terminalStorage.allowAllCommands(scope: .session(conversationId)) + } + + public func isTerminalAllowed(conversationId: String, commandLine: String?) -> Bool { + guard let commandLine, !commandLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return terminalStorage.isAllCommandsAllowedInSession(conversationId: conversationId) + } + + return terminalStorage.isAllowed(scope: .session(conversationId), commandLine: commandLine) + } + + private func resolveFileKey(description: String, pattern: String?) -> String { + if let pattern, !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return pattern + } + return SensitiveFileConfirmationInfo( + description: description, + pattern: pattern + ).sessionKey + } + + // MARK: - Cleanup + + public func clearConversationData(conversationId: String?) { + guard let conversationId else { return } + mcpStorage.clear(scope: .session(conversationId)) + sensitiveFileStorage.clear(scope: .session(conversationId)) + terminalStorage.clear(scope: .session(conversationId)) + } + + public func clearGlobalData() { + mcpStorage.clear(scope: .global) + sensitiveFileStorage.clear(scope: .global) + terminalStorage.clear(scope: .global) + } +} + diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift new file mode 100644 index 00000000..3b8f97f5 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift @@ -0,0 +1,305 @@ +import Foundation +import ConversationServiceProvider +import SwiftTreeSitter +import SwiftTreeSitterLayer +import TreeSitterBash + +extension ToolAutoApprovalManager { + private static let mcpToolCallPattern = try? NSRegularExpression( + pattern: #"Confirm MCP Tool: .+ - (.+)\(MCP Server\)"#, + options: [] + ) + + private static let sensitiveRuleDescriptionRegex = try? NSRegularExpression( + pattern: #"^(.*?)\s*needs confirmation\."#, + options: [.caseInsensitive] + ) + + private static let sensitiveRulePatternRegex = try? NSRegularExpression( + pattern: #"matching pattern\s+`([^`]+)`"#, + options: [.caseInsensitive] + ) + + public struct SensitiveFileConfirmationInfo: Sendable, Equatable { + public let description: String + // Optional pattern for create_file operations only + public let pattern: String? + + public var sessionKey: String { + if let pattern, !pattern.isEmpty { + return pattern + } + if !description.isEmpty { + return description.lowercased() + } + return "sensitive files" + } + } + + public nonisolated static func extractMCPServerName(from message: String) -> String? { + let fullRange = NSRange(message.startIndex ..< message.endIndex, in: message) + + if let regex = mcpToolCallPattern, + let match = regex.firstMatch(in: message, options: [], range: fullRange), + match.numberOfRanges >= 2, + let range = Range(match.range(at: 1), in: message) { + return String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + return nil + } + + public nonisolated static func isSensitiveFileOperation(message: String) -> Bool { + message.range(of: "sensitive files", options: [.caseInsensitive, .diacriticInsensitive]) != nil + } + + public nonisolated static func isTerminalOperation(name: String) -> Bool { + name == ToolName.runInTerminal.rawValue + } + + public nonisolated static func extractSensitiveFileConfirmationInfo(from message: String) -> SensitiveFileConfirmationInfo { + let fullRange = NSRange(message.startIndex ..< message.endIndex, in: message) + + var description = "" + if let regex = sensitiveRuleDescriptionRegex, + let match = regex.firstMatch(in: message, options: [], range: fullRange), + match.numberOfRanges >= 2, + let range = Range(match.range(at: 1), in: message) { + description = String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + var pattern: String? + if let regex = sensitiveRulePatternRegex, + let match = regex.firstMatch(in: message, options: [], range: fullRange), + match.numberOfRanges >= 2, + let range = Range(match.range(at: 1), in: message) { + let extracted = String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines) + if !extracted.isEmpty { + pattern = extracted + } + } + + return SensitiveFileConfirmationInfo(description: description, pattern: pattern) + } + + public nonisolated static func sensitiveFileKey(from message: String) -> String { + extractSensitiveFileConfirmationInfo(from: message).sessionKey + } + + // MARK: - Terminal command parsing + + /// Best-effort splitter for injection protection. + /// + /// Splits a command line into sub-commands on common shell separators while respecting + /// basic quoting and escaping rules. + public nonisolated static func splitTerminalCommandLineIntoSubCommands(_ commandLine: String) -> [String] { + let input = commandLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !input.isEmpty else { return [] } + + var subCommands: [String] = [] + var current = "" + + var isInSingleQuotes = false + var isInDoubleQuotes = false + var isEscaping = false + + func flushCurrent() { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + subCommands.append(trimmed) + } + current = "" + } + + let scalars = Array(input.unicodeScalars) + var i = 0 + + while i < scalars.count { + let scalar = scalars[i] + let ch = Character(scalar) + + if isEscaping { + current.append(ch) + isEscaping = false + i += 1 + continue + } + + if ch == "\\" { + // Honor backslash escaping outside single-quotes, and inside double-quotes. + if !isInSingleQuotes { + isEscaping = true + } + current.append(ch) + i += 1 + continue + } + + if ch == "\"" && !isInSingleQuotes { + isInDoubleQuotes.toggle() + current.append(ch) + i += 1 + continue + } + + if ch == "'" && !isInDoubleQuotes { + isInSingleQuotes.toggle() + current.append(ch) + i += 1 + continue + } + + if !isInSingleQuotes && !isInDoubleQuotes { + // Separators: newline, semicolon, pipe, &&, || + if ch == "\n" || ch == ";" { + flushCurrent() + i += 1 + continue + } + + if ch == "&" { + if i + 1 < scalars.count, Character(scalars[i + 1]) == "&" { + flushCurrent() + i += 2 + continue + } + + // Check for &> (Redirection to stdout+stderr) + if i + 1 < scalars.count, Character(scalars[i + 1]) == ">" { + current.append(ch) + i += 1 + continue + } + + // Check for >& (Redirection, e.g. 2>&1) + if current.last == ">" { + current.append(ch) + i += 1 + continue + } + + flushCurrent() + i += 1 + continue + } + + if ch == "|" { + if i + 1 < scalars.count, Character(scalars[i + 1]) == "|" { + flushCurrent() + i += 2 + continue + } + flushCurrent() + i += 1 + continue + } + + if ch == "(" || ch == ")" { + flushCurrent() + i += 1 + continue + } + } + + current.append(ch) + i += 1 + } + + flushCurrent() + return subCommands + } + + /// Extracts command names (e.g. `git`, `brew`) from a potentially compound command line. + public nonisolated static func extractTerminalCommandNames(from commandLine: String) -> [String] { + extractSubCommandsWithTreeSitter(commandLine) + .compactMap { extractTerminalCommandName(fromSubCommand: $0) } + } + + /// Extracts the best-effort primary command name from a sub-command. + public nonisolated static func extractTerminalCommandName(fromSubCommand subCommand: String) -> String? { + let trimmed = subCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(whereSeparator: { $0.isWhitespace }) + guard !parts.isEmpty else { return nil } + + func isEnvAssignment(_ token: Substring) -> Bool { + guard let eq = token.firstIndex(of: "=") else { return false } + let key = token[.. Language { + return Language(language: tree_sitter_bash()) + } + + public nonisolated static func extractSubCommandsWithTreeSitter(_ commandLine: String) -> [String] { + // macOS typically uses zsh or bash, both are close enough for basic command extraction using tree-sitter-bash + do { + let treeSitterLanguage = loadBashLanguage() + let parser = Parser() + try parser.setLanguage(treeSitterLanguage) + + guard let tree = parser.parse(commandLine) else { + return [commandLine.trimmingCharacters(in: .whitespacesAndNewlines)] + } + + let queryData = "(simple_command) @command".data(using: .utf8)! + let query = try Query(language: treeSitterLanguage, data: queryData) + + let matches = query.execute(in: tree) + let captures = matches.flatMap(\.captures) + + let subCommands = captures + .filter { query.captureName(for: $0.index) == "command" } + .compactMap { capture -> String? in + let node = capture.node + let startByte = Int(node.byteRange.lowerBound) + let endByte = Int(node.byteRange.upperBound) + + let utf8 = commandLine.utf8 + guard let startIndex = utf8.index(utf8.startIndex, offsetBy: startByte, limitedBy: utf8.endIndex), + let endIndex = utf8.index(utf8.startIndex, offsetBy: endByte, limitedBy: utf8.endIndex), + let cmd = String(utf8[startIndex ..< endIndex]) else { return nil } + + let trimmed = cmd.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + return subCommands + // return subCommands.isEmpty ? splitTerminalCommandLineIntoSubCommands(commandLine) : subCommands + + } catch { + // Fallback + return splitTerminalCommandLineIntoSubCommands(commandLine) + } + } +} diff --git a/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift b/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift new file mode 100644 index 00000000..262bd3f6 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift @@ -0,0 +1,115 @@ +import Foundation +import ConversationServiceProvider +import JSONRPC + +extension ChatService { + typealias ToolConfirmationCompletion = (AnyJSONRPCResponse) -> Void + + func handleClientToolConfirmationEvent( + request: InvokeClientToolConfirmationRequest, + completion: @escaping ToolConfirmationCompletion + ) { + guard let params = request.params else { return } + guard isConversationIdValid(params.conversationId) else { return } + + Task { [weak self] in + guard let self else { return } + let shouldAutoApprove = await shouldAutoApprove(params: params) + let parentTurnId = parentTurnIdForTurnId(params.turnId) + + let toolCallStatus: AgentToolCall.ToolCallStatus = shouldAutoApprove + ? .accepted + : .waitForConfirmation + + appendToolCallHistory( + turnId: params.turnId, + editAgentRounds: makeEditAgentRounds(params: params, status: toolCallStatus), + parentTurnId: parentTurnId + ) + + let toolCallRequest = ToolCallRequest( + requestId: request.id, + turnId: params.turnId, + roundId: params.roundId, + toolCallId: params.toolCallId, + completion: completion + ) + + if shouldAutoApprove { + sendToolConfirmationResponse(toolCallRequest, accepted: true) + } else { + storePendingToolCallRequest(toolCallId: params.toolCallId, request: toolCallRequest) + } + } + } + + private func shouldAutoApprove(params: InvokeClientToolParams) async -> Bool { + let mcpServerName = ToolAutoApprovalManager.extractMCPServerName(from: params.title ?? "") + let confirmationMessage = params.message ?? "" + + if ToolAutoApprovalManager.isTerminalOperation(name: params.name) { + let commandLine = params.input?["command"]?.value as? String + let allowed = await ToolAutoApprovalManager.shared.isTerminalAllowed( + conversationId: params.conversationId, + commandLine: commandLine + ) + if allowed { + return true + } + } + + if let mcpServerName { + let allowed = await ToolAutoApprovalManager.shared.isMCPAllowed( + conversationId: params.conversationId, + serverName: mcpServerName, + toolName: params.name + ) + + if allowed { + return true + } + + let globalAllowed = await ToolAutoApprovalManager.shared.isMCPAllowedGlobally( + serverName: mcpServerName, + toolName: params.name + ) + if globalAllowed { + return true + } + } + + if ToolAutoApprovalManager.isSensitiveFileOperation(message: confirmationMessage) { + let info = ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: confirmationMessage) + let fileKey = info.sessionKey + let allowed = await ToolAutoApprovalManager.shared.isSensitiveFileAllowed( + conversationId: params.conversationId, + toolName: params.name, + fileKey: fileKey + ) + + if allowed { + return true + } + } + + return false + } + + func makeEditAgentRounds(params: InvokeClientToolParams, status: AgentToolCall.ToolCallStatus) -> [AgentRound] { + [ + AgentRound( + roundId: params.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: params.toolCallId, + name: params.name, + status: status, + invokeParams: params, + title: params.title + ) + ] + ) + ] + } +} diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift index f811901a..702ade22 100644 --- a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift @@ -3,6 +3,7 @@ import AppKit import ConversationServiceProvider import Foundation import Logger +import ChatAPIService public class CreateFileTool: ICopilotTool { public static let name = ToolName.createFile @@ -10,7 +11,6 @@ public class CreateFileTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)? ) -> Bool { guard let params = request.params, @@ -50,12 +50,14 @@ public class CreateFileTool: ICopilotTool { return true } - contextProvider?.updateFileEdits(by: .init( + let fileEdit: FileEdit = .init( fileURL: URL(fileURLWithPath: filePath), originalContent: "", modifiedContent: writtenContent, toolName: CreateFileTool.name - )) + ) + + contextProvider?.updateFileEdits(by: fileEdit) NSWorkspace.openFileInXcode(fileURL: URL(fileURLWithPath: filePath)) { _, error in if let error = error { @@ -78,9 +80,7 @@ public class CreateFileTool: ICopilotTool { ) ] - if let chatHistoryUpdater { - chatHistoryUpdater(params.turnId, editAgentRounds) - } + contextProvider?.updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit]) completeResponse( request, diff --git a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift index 5ff5f6b9..c9f95260 100644 --- a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift +++ b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift @@ -14,7 +14,6 @@ public class FetchWebPageTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)? ) -> Bool { guard let params = request.params, diff --git a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift index f95625dc..3a464016 100644 --- a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift +++ b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift @@ -8,7 +8,6 @@ public class GetErrorsTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: ToolContextProvider? ) -> Bool { guard let params = request.params, @@ -34,9 +33,11 @@ public class GetErrorsTool: ICopilotTool { /// As the resolving should be sync. Especially when completion the JSONRPCResponse let focusedElement: AXUIElement? = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) let focusedEditor: SourceEditor? - if let editorElement = focusedElement, editorElement.isSourceEditor { + if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor { focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement) - } else if let element = focusedElement, let editorElement = element.firstParent(where: \.isSourceEditor) { + } else if let element = focusedElement, let editorElement = element.firstParent( + where: \.isNonNavigatorSourceEditor + ) { focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement) } else { focusedEditor = nil diff --git a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift index 1d298711..69a76689 100644 --- a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift +++ b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift @@ -4,7 +4,7 @@ import JSONRPC import Terminal public class GetTerminalOutputTool: ICopilotTool { - public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)?) -> Bool { + public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool { var result: String = "" if let input = request.params?.input as? [String: AnyCodable], let terminalId = input["id"]?.value as? String{ let session = TerminalSessionManager.shared.getSession(for: terminalId) diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift index cbe9e2ec..8e10fbfa 100644 --- a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift +++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift @@ -2,15 +2,16 @@ import ChatTab import ConversationServiceProvider import Foundation import JSONRPC +import ChatAPIService public protocol ToolContextProvider { // MARK: insert_edit_into_file var chatTabInfo: ChatTabInfo { get } func updateFileEdits(by fileEdit: FileEdit) -> Void func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws + func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit]) } -public typealias ChatHistoryUpdater = (String, [AgentRound]) -> Void public protocol ICopilotTool { /** @@ -18,7 +19,6 @@ public protocol ICopilotTool { * - Parameters: * - request: The tool invocation request. * - completion: Closure called with JSON-RPC response when tool execution completes. - * - chatHistoryUpdater: Optional closure to update chat history during tool execution. * - contextProvider: Optional provider that supplies additional context information * needed for tool execution, such as chat tab data and file editing capabilities. * - Returns: Boolean indicating if the tool call has completed. True if the tool call is completed, false otherwise. @@ -26,7 +26,6 @@ public protocol ICopilotTool { func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: ToolContextProvider? ) -> Bool } @@ -85,4 +84,8 @@ extension ICopilotTool { } } -extension ChatService: ToolContextProvider { } +extension ChatService: ToolContextProvider { + public func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = []) { + appendToolCallHistory(turnId: turnId, editAgentRounds: editAgentRounds, fileEdits: fileEdits) + } +} diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index db89c57c..2eb6b160 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -6,6 +6,35 @@ import Foundation import JSONRPC import Logger import XcodeInspector +import ChatAPIService +import SystemUtils +import Workspace + +public enum InsertEditError: LocalizedError { + case missingEditorElement(file: URL) + case openingApplicationUnavailable + case fileNotOpenedInXcode + case fileURLMismatch(expected: URL, actual: URL?) + case fileNotAccessible(URL) + case fileHasUnsavedChanges(URL) + + public var errorDescription: String? { + switch self { + case .missingEditorElement(let file): + return "Could not find source editor element for file \(file.lastPathComponent)." + case .openingApplicationUnavailable: + return "Failed to get the application that opened the file." + case .fileNotOpenedInXcode: + return "The file is not currently opened in Xcode." + case .fileURLMismatch(let expected, let actual): + return "The currently focused file URL \(actual?.lastPathComponent ?? "unknown") does not match the expected file URL \(expected.lastPathComponent)." + case .fileNotAccessible(let fileURL): + return "The file \(fileURL.lastPathComponent) is not accessible." + case .fileHasUnsavedChanges(let fileURL): + return "The file \(fileURL.lastPathComponent) seems to have unsaved changes in Xcode. Please save the file and try again." + } + } +} public class InsertEditIntoFileTool: ICopilotTool { public static let name = ToolName.insertEditIntoFile @@ -13,7 +42,6 @@ public class InsertEditIntoFileTool: ICopilotTool { public func invokeTool( _ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, - chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)? ) -> Bool { guard let params = request.params, @@ -30,7 +58,7 @@ public class InsertEditIntoFileTool: ICopilotTool { let fileURL = URL(fileURLWithPath: filePath) let originalContent = try String(contentsOf: fileURL, encoding: .utf8) - InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) { newContent, error in + InsertEditIntoFileTool.applyEdit(for: fileURL, content: code) { newContent, error in if let error = error { self.completeResponse( request, @@ -47,9 +75,8 @@ public class InsertEditIntoFileTool: ICopilotTool { return } - contextProvider.updateFileEdits( - by: .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) - ) + let fileEdit: FileEdit = .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name) + contextProvider.updateFileEdits(by: fileEdit) let editAgentRounds: [AgentRound] = [ .init( @@ -66,9 +93,8 @@ public class InsertEditIntoFileTool: ICopilotTool { ) ] - if let chatHistoryUpdater { - chatHistoryUpdater(params.turnId, editAgentRounds) - } + contextProvider + .updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit]) self.completeResponse(request, response: newContent, completion: completion) } @@ -88,18 +114,11 @@ public class InsertEditIntoFileTool: ICopilotTool { public static func applyEdit( for fileURL: URL, content: String, - contextProvider: any ToolContextProvider, xcodeInstance: AppInstanceInspector ) throws -> String { - // Get the focused element directly from the app (like XcodeInspector does) - guard let focusedElement: AXUIElement = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) + guard let editorElement = Self.getEditorElement(by: xcodeInstance, for: fileURL) else { - throw NSError(domain: "Failed to access xcode element", code: 0) - } - - // Find the source editor element using XcodeInspector's logic - guard let editorElement = focusedElement.findSourceEditorElement() else { - throw NSError(domain: "Could not find source editor element", code: 0) + throw InsertEditError.missingEditorElement(file: fileURL) } // Check if element supports kAXValueAttribute before reading @@ -115,10 +134,9 @@ public class InsertEditIntoFileTool: ICopilotTool { let lines = value.components(separatedBy: .newlines) - var isInjectedSuccess = false - var injectionError: Error? - do { + try Self.checkOpenedFileURL(for: fileURL, xcodeInstance: xcodeInstance) + try AXHelper().injectUpdatedCodeWithAccessibilityAPI( .init( content: content, @@ -130,48 +148,132 @@ public class InsertEditIntoFileTool: ICopilotTool { .inserted(0, [content]) ] ), - focusElement: editorElement, - onSuccess: { - Logger.client.info("Content injection succeeded") - isInjectedSuccess = true - }, - onError: { - Logger.client.error("Content injection failed in onError callback") - } + focusElement: editorElement ) } catch { - Logger.client.error("Content injection threw error: \(error)") - if let axError = error as? AXError { - Logger.client.error("AX Error code during injection: \(axError.rawValue)") + Logger.client.error("Failed to inject code for insert edit into file: \(error.localizedDescription)") + throw error + } + + // Verify the content was applied by reading it back + return try Self.getCurrentEditorContent(for: fileURL, by: xcodeInstance) + } + + public static func applyEdit( + for fileURL: URL, + content: String, + completion: ((String?, Error?) -> Void)? = nil + ) { + if SystemUtils.isDeveloperMode || SystemUtils.isPrereleaseBuild { + /// Experimental solution: Use file system write for better reliability. Only enable in dev mode or prerelease builds. + Self.applyEditWithFileSystem( + for: fileURL, + content: content, + completion: completion + ) + } else { + Self.applyEditWithAccessibilityAPI( + for: fileURL, + content: content, + completion: completion + ) + } + } + + /// Get the source editor element with retries for specific file URL + private static func getEditorElement( + by xcodeInstance: AppInstanceInspector, + for fileURL: URL, + retryTimes: Int = 6, + delay: TimeInterval = 0.5 + ) -> AXUIElement? { + var remainingAttempts = max(1, retryTimes) + + while remainingAttempts > 0 { + guard let realtimeURL = xcodeInstance.appElement.realtimeDocumentURL, + realtimeURL == fileURL, + let focusedElement = xcodeInstance.appElement.focusedElement, + let editorElement = focusedElement.findSourceEditorElement() + else { + if remainingAttempts > 1 { + Thread.sleep(forTimeInterval: delay) + } + + remainingAttempts -= 1 + continue } - injectionError = error + + return editorElement } - if !isInjectedSuccess { - let errorMessage = injectionError?.localizedDescription ?? "Failed to apply edit" - Logger.client.error("Edit application failed: \(errorMessage)") - throw NSError(domain: "Failed to apply edit: \(errorMessage)", code: 0) + Logger.client.error("Editor element not found for \(fileURL.lastPathComponent) after \(retryTimes) attempts.") + return nil + } + + // Check if current opened file is the target URL + private static func checkOpenedFileURL( + for fileURL: URL, + xcodeInstance: AppInstanceInspector + ) throws { + let realtimeDocumentURL = xcodeInstance.realtimeDocumentURL + + if realtimeDocumentURL != fileURL { + throw InsertEditError.fileURLMismatch(expected: fileURL, actual: realtimeDocumentURL) + } + } + + private static func getCurrentEditorContent(for fileURL: URL, by xcodeInstance: AppInstanceInspector) throws -> String { + guard let editorElement = getEditorElement(by: xcodeInstance, for: fileURL, retryTimes: 1) + else { + throw InsertEditError.missingEditorElement(file: fileURL) } - // Verify the content was applied by reading it back + return try editorElement.copyValue(key: kAXValueAttribute) + } +} + +private extension AppInstanceInspector { + var realtimeDocumentURL: URL? { + appElement.realtimeDocumentURL + } +} + +extension InsertEditIntoFileTool { + static func applyEditWithFileSystem( + for fileURL: URL, + content: String, + completion: ((String?, Error?) -> Void)? = nil + ) { do { - let newContent: String = try editorElement.copyValue(key: kAXValueAttribute) - Logger.client.info("Successfully read back new content, length: \(newContent.count)") - return newContent - } catch { - Logger.client.error("Failed to read back new content: \(error)") - if let axError = error as? AXError { - Logger.client.error("AX Error code when reading back: \(axError.rawValue)") + guard let diskFileContent = try? String(contentsOf: fileURL) else { + throw InsertEditError.fileNotAccessible(fileURL) } - throw error + + if let focusedElement = XcodeInspector.shared.focusedElement, + focusedElement.isNonNavigatorSourceEditor, + focusedElement.realtimeDocumentURL == fileURL, + focusedElement.value != diskFileContent + { + throw InsertEditError.fileHasUnsavedChanges(fileURL) + } + + // write content to disk + try content.write(to: fileURL, atomically: true, encoding: .utf8) + + Task { @WorkspaceActor in + await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: content) + if let completion = completion { completion(content, nil) } + } + } catch { + if let completion = completion { completion(nil, error) } + Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)") } } - public static func applyEdit( + static func applyEditWithAccessibilityAPI( for fileURL: URL, content: String, - contextProvider: any ToolContextProvider, - completion: ((String?, Error?) -> Void)? = nil + completion: ((String?, Error?) -> Void)? = nil, ) { NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in do { @@ -179,25 +281,23 @@ public class InsertEditIntoFileTool: ICopilotTool { guard let app = app else { - throw NSError(domain: "Failed to get the app that opens file.", code: 0) + throw InsertEditError.openingApplicationUnavailable } let appInstanceInspector = AppInstanceInspector(runningApplication: app) guard appInstanceInspector.isXcode else { - throw NSError(domain: "The file is not opened in Xcode.", code: 0) + throw InsertEditError.fileNotOpenedInXcode } let newContent = try applyEdit( for: fileURL, content: content, - contextProvider: contextProvider, xcodeInstance: appInstanceInspector ) Task { - // Force to notify the CLS about the new change within the document before edit_file completion. - try? await contextProvider.notifyChangeTextDocument(fileURL: fileURL, content: newContent, version: 0) + await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: newContent) if let completion = completion { completion(newContent, nil) } } } catch { diff --git a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift index fba3e4a0..1fc8306b 100644 --- a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift +++ b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift @@ -4,7 +4,7 @@ import XcodeInspector import JSONRPC public class RunInTerminalTool: ICopilotTool { - public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)?) -> Bool { + public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool { let params = request.params! Task { diff --git a/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift new file mode 100644 index 00000000..b8058ccb --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift @@ -0,0 +1,104 @@ +import ChatAPIService +import ConversationServiceProvider +import Foundation + +/// Helper methods for updating tool call status in chat history +/// Handles both main turn tool calls and subagent tool calls +struct ToolCallStatusUpdater { + /// Finds the message containing the tool call, handling both main turns and subturns + static func findMessageContainingToolCall( + _ toolCallRequest: ToolCallRequest?, + conversationTurnTracking: ConversationTurnTrackingState, + history: [ChatMessage] + ) async -> ChatMessage? { + guard let request = toolCallRequest else { return nil } + + // If this is a subturn, find the parent turn; otherwise use the request's turnId + let turnIdToFind = conversationTurnTracking.turnParentMap[request.turnId] ?? request.turnId + + return history.first(where: { $0.id == turnIdToFind && $0.role == .assistant }) + } + + /// Searches for a tool call in agent rounds (including nested subagent rounds) and creates an update + /// + /// Note: Parent turns can have multiple sequential subturns, but they don't appear simultaneously. + /// Subturns are merged into the parent's last round's subAgentRounds array by ChatMemory. + static func findAndUpdateToolCall( + toolCallId: String, + newStatus: AgentToolCall.ToolCallStatus, + in agentRounds: [AgentRound] + ) -> AgentRound? { + // First, search in main rounds (for regular tool calls) + for round in agentRounds { + if let toolCalls = round.toolCalls { + for toolCall in toolCalls where toolCall.id == toolCallId { + return AgentRound( + roundId: round.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: toolCallId, + name: toolCall.name, + toolType: toolCall.toolType, + status: newStatus + ), + ] + ) + } + } + } + + // If not found in main rounds, search in subagent rounds (for subturn tool calls) + // Subturns are nested under the parent round's subAgentRounds + for round in agentRounds { + guard let subAgentRounds = round.subAgentRounds else { continue } + + for subRound in subAgentRounds { + guard let toolCalls = subRound.toolCalls else { continue } + + for toolCall in toolCalls where toolCall.id == toolCallId { + // Create an update that will be merged into the parent's subAgentRounds + // ChatMemory.appendMessage will handle the merging logic + let subagentRound = AgentRound( + roundId: subRound.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: toolCallId, + name: toolCall.name, + toolType: toolCall.toolType, + status: newStatus + ), + ] + ) + return AgentRound( + roundId: round.roundId, + reply: "", + toolCalls: [], + subAgentRounds: [subagentRound] + ) + } + } + } + + return nil + } + + /// Creates a message update with the new tool call status + static func createMessageUpdate( + targetMessage: ChatMessage, + updatedRound: AgentRound + ) -> ChatMessage { + return ChatMessage( + id: targetMessage.id, + chatTabID: targetMessage.chatTabID, + clsTurnID: targetMessage.clsTurnID, + role: .assistant, + content: "", + references: [], + steps: [], + editAgentRounds: [updatedRound], + turnStatus: .inProgress + ) + } +} diff --git a/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift new file mode 100644 index 00000000..c7b28d16 --- /dev/null +++ b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift @@ -0,0 +1,11 @@ +import Foundation +import Workspace +import Dependencies + +struct WorkspaceInvocationCoordinator { + @Dependency(\.workspaceInvoker) private var workspaceInvoker + + func invokeFilespaceUpdate(fileURL: URL, content: String) async { + await workspaceInvoker.invokeFilespaceUpdate(fileURL, content) + } +} diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 547ad026..e6ca55e0 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -12,6 +12,7 @@ import OrderedCollections import SwiftUI import GitHelper import SuggestionBasic +import HostAppActivator public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { @@ -30,8 +31,14 @@ public struct DisplayedChatMessage: Equatable { public var errorMessages: [String] = [] public var steps: [ConversationProgressStep] = [] public var editAgentRounds: [AgentRound] = [] + public var parentTurnId: String? = nil public var panelMessages: [CopilotShowMessageParams] = [] public var codeReviewRound: CodeReviewRound? = nil + public var fileEdits: [FileEdit] = [] + public var turnStatus: ChatMessage.TurnStatus? = nil + public let requestType: RequestType + public var modelName: String? = nil + public var billingMultiplier: Float? = nil public init( id: String, @@ -44,8 +51,14 @@ public struct DisplayedChatMessage: Equatable { errorMessages: [String] = [], steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], - codeReviewRound: CodeReviewRound? = nil + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: ChatMessage.TurnStatus? = nil, + requestType: RequestType, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.id = id self.role = role @@ -57,8 +70,14 @@ public struct DisplayedChatMessage: Equatable { self.errorMessages = errorMessages self.steps = steps self.editAgentRounds = editAgentRounds + self.parentTurnId = parentTurnId self.panelMessages = panelMessages self.codeReviewRound = codeReviewRound + self.fileEdits = fileEdits + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier } } @@ -77,6 +96,10 @@ struct ChatContext: Equatable { self.attachedImages = attachedImages } + static func empty() -> ChatContext { + .init(typedMessage: "", attachedReferences: [], attachedImages: []) + } + static func from(_ message: DisplayedChatMessage, projectURL: URL) -> ChatContext { .init( typedMessage: message.text, @@ -147,45 +170,395 @@ struct ChatContextProvider: Equatable { @Reducer struct Chat { public typealias MessageID = String + public enum EditorMode: Hashable { + case input // Default input mode + case editUserMessage(MessageID) + + var isDefault: Bool { self == .input } + + var isEditingUserMessage: Bool { + switch self { + case .input: false + case .editUserMessage: true + } + } + + var editingUserMessageId: String? { + switch self { + case .input: nil + case .editUserMessage(let messageID): messageID + } + } + } + + @ObservableState + struct EditorState: Equatable { + enum Field: String, Hashable { + case textField + case fileSearchBar + } + + var codeReviewState = ConversationCodeReviewFeature.State() + + var mode: EditorMode + var contexts: [EditorMode: ChatContext] + var contextProvider: ChatContextProvider + var focusedField: Field? + var currentEditor: ConversationFileReference? + var handOffClicked: Bool = false + + init( + mode: EditorMode = .input, + contexts: [EditorMode: ChatContext] = [.input: .empty()], + contextProvider: ChatContextProvider = .init(), + focusedField: Field? = nil, + currentEditor: ConversationFileReference? = nil, + handOffClicked: Bool = false + ) { + self.mode = mode + self.contexts = contexts + self.contextProvider = contextProvider + self.focusedField = focusedField + self.currentEditor = currentEditor + self.handOffClicked = handOffClicked + } + + func context(for mode: EditorMode) -> ChatContext { + contexts[mode] ?? .empty() + } + + mutating func setContext(_ context: ChatContext, for mode: EditorMode) { + contexts[mode] = context + } + + mutating func updateCurrentContext(_ update: (inout ChatContext) -> Void) { + var context = self.context(for: mode) + update(&context) + setContext(context, for: mode) + } + + mutating func keepOnlyInputContext() { + let inputContext = context(for: .input) + contexts = [.input: inputContext] + } + + mutating func clearAttachedImages() { + updateCurrentContext { $0.attachedImages.removeAll() } + } + + mutating func addReference(_ reference: ConversationAttachedReference) { + updateCurrentContext { context in + guard !context.attachedReferences.contains(reference) else { return } + context.attachedReferences.append(reference) + } + } + + mutating func removeReference(_ reference: ConversationAttachedReference) { + updateCurrentContext { context in + guard let index = context.attachedReferences.firstIndex(of: reference) else { return } + context.attachedReferences.remove(at: index) + } + } + + mutating func addImage(_ image: ImageReference) { + updateCurrentContext { context in + guard !context.attachedImages.contains(image) else { return } + context.attachedImages.append(image) + } + } + + mutating func removeImage(_ image: ImageReference) { + updateCurrentContext { context in + guard let index = context.attachedImages.firstIndex(of: image) else { return } + context.attachedImages.remove(at: index) + } + } + + mutating func pushContext(_ context: ChatContext) { + contextProvider.pushContext(context) + } + + mutating func resetContextProvider() { + contextProvider.reset() + } + + mutating func popNextContext() -> ChatContext? { + contextProvider.getNextContext() + } + + func previousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { + contextProvider.getPreviousContext(from: history, projectURL: projectURL) + } + } + + @ObservableState + struct ConversationState: Equatable { + var history: [DisplayedChatMessage] + var isReceivingMessage: Bool + var requestType: RequestType? + + init( + history: [DisplayedChatMessage] = [], + isReceivingMessage: Bool = false, + requestType: RequestType? = nil + ) { + self.history = history + self.isReceivingMessage = isReceivingMessage + self.requestType = requestType + } + + func subsequentMessages(after messageId: MessageID) -> [DisplayedChatMessage] { + guard let index = history.firstIndex(where: { $0.id == messageId }) else { + return [] + } + return Array(history[(index + 1)...]) + } + + func editUserMessageEffectedMessages(for mode: EditorMode) -> [DisplayedChatMessage] { + guard case .editUserMessage(let messageId) = mode else { + return [] + } + return subsequentMessages(after: messageId) + } + } + + struct AgentEditingState: Equatable { + var fileEditMap: OrderedDictionary + var diffViewerController: DiffViewWindowController? + + init( + fileEditMap: OrderedDictionary = [:], + diffViewerController: DiffViewWindowController? = nil + ) { + self.fileEditMap = fileEditMap + self.diffViewerController = diffViewerController + } + + static func == (lhs: AgentEditingState, rhs: AgentEditingState) -> Bool { + lhs.fileEditMap == rhs.fileEditMap && lhs.diffViewerController === rhs.diffViewerController + } + } + + struct EnvironmentState: Equatable { + var isAgentMode: Bool + var workspaceURL: URL? + var selectedAgent: ConversationMode + + init( + isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), + workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent + ) { + self.isAgentMode = isAgentMode + self.workspaceURL = workspaceURL + self.selectedAgent = selectedAgent + } + } @ObservableState struct State: Equatable { + typealias Field = EditorState.Field + // Not use anymore. the title of history tab will get from chat tab info // Keep this var as `ChatTabItemView` reference this - var title: String = "New Chat" - var chatContext: ChatContext = .init(typedMessage: "", attachedReferences: [], attachedImages: []) - var contextProvider: ChatContextProvider = .init() - var history: [DisplayedChatMessage] = [] - var isReceivingMessage = false - var requestType: ChatService.RequestType? = nil - var chatMenu = ChatMenu.State() - var focusedField: Field? - var currentEditor: ConversationFileReference? = nil + var title: String + var editor: EditorState + var conversation: ConversationState + var agentEditing: AgentEditingState + var environment: EnvironmentState + var chatMenu: ChatMenu.State + var codeReviewState: ConversationCodeReviewFeature.State + + init( + title: String = "New Chat", + editor: EditorState = .init(), + conversation: ConversationState = .init(), + agentEditing: AgentEditingState = .init(), + environment: EnvironmentState = .init(), + chatMenu: ChatMenu.State = .init(), + codeReviewState: ConversationCodeReviewFeature.State = .init() + ) { + self.title = title + self.editor = editor + self.conversation = conversation + self.agentEditing = agentEditing + self.environment = environment + self.chatMenu = chatMenu + self.codeReviewState = codeReviewState + } + + init( + title: String = "New Chat", + editorMode: EditorMode = .input, + editorModeContexts: [EditorMode: ChatContext] = [.input: .empty()], + focusedField: Field? = nil, + history: [DisplayedChatMessage] = [], + isReceivingMessage: Bool = false, + requestType: RequestType? = nil, + fileEditMap: OrderedDictionary = [:], + diffViewerController: DiffViewWindowController? = nil, + isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), + workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent, + chatMenu: ChatMenu.State = .init(), + codeReviewState: ConversationCodeReviewFeature.State = .init() + ) { + self.init( + title: title, + editor: EditorState( + mode: editorMode, + contexts: editorModeContexts, + focusedField: focusedField + ), + conversation: ConversationState( + history: history, + isReceivingMessage: isReceivingMessage, + requestType: requestType + ), + agentEditing: AgentEditingState( + fileEditMap: fileEditMap, + diffViewerController: diffViewerController + ), + environment: EnvironmentState( + isAgentMode: isAgentMode, + workspaceURL: workspaceURL, + selectedAgent: selectedAgent + ), + chatMenu: chatMenu, + codeReviewState: codeReviewState + ) + } + + var editorMode: EditorMode { + get { editor.mode } + set { + editor.mode = newValue + if editor.contexts[newValue] == nil { + editor.contexts[newValue] = .empty() + } + } + } + + var chatContext: ChatContext { + get { editor.context(for: editor.mode) } + set { editor.setContext(newValue, for: editor.mode) } + } + + var history: [DisplayedChatMessage] { + get { conversation.history } + set { conversation.history = newValue } + } + + var isReceivingMessage: Bool { + get { conversation.isReceivingMessage } + set { conversation.isReceivingMessage = newValue } + } + + var requestType: RequestType? { + get { conversation.requestType } + set { conversation.requestType = newValue } + } + + var handOffClicked: Bool { + get { editor.handOffClicked } + set { editor.handOffClicked = newValue } + } + + var focusedField: Field? { + get { editor.focusedField } + set { editor.focusedField = newValue } + } + + var currentEditor: ConversationFileReference? { + get { editor.currentEditor } + set { editor.currentEditor = newValue } + } + var attachedReferences: [ConversationAttachedReference] { chatContext.attachedReferences } + var attachedImages: [ImageReference] { chatContext.attachedImages } + var typedMessage: String { get { chatContext.typedMessage } - set { - chatContext.typedMessage = newValue - // User typed in. Need to reset contextProvider - contextProvider.reset() + set { + editor.updateCurrentContext { $0.typedMessage = newValue } + editor.resetContextProvider() } } - /// Cache the original content - 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 + + var fileEditMap: OrderedDictionary { + get { agentEditing.fileEditMap } + set { agentEditing.fileEditMap = newValue } + } + + var diffViewerController: DiffViewWindowController? { + get { agentEditing.diffViewerController } + set { agentEditing.diffViewerController = newValue } + } + + var isAgentMode: Bool { + get { environment.isAgentMode } + set { environment.isAgentMode = newValue } + } + + var workspaceURL: URL? { + get { environment.workspaceURL } + set { environment.workspaceURL = newValue } + } + + var selectedAgent: ConversationMode { + get { environment.selectedAgent } + set { environment.selectedAgent = newValue } + } + + /// Not including the one being edited + var editUserMessageEffectedMessages: [DisplayedChatMessage] { + conversation.editUserMessageEffectedMessages(for: editor.mode) } - var codeReviewState = ConversationCodeReviewFeature.State() + // The following messages after check point message will hide on ChatPanel + var pendingCheckpointMessageId: String? = nil + // The chat context before the first restoring + var pendingCheckpointContext: ChatContext? = nil + var messagesAfterCheckpoint: [DisplayedChatMessage] { + guard let pendingCheckpointMessageId, let index = history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) else { + return [] + } + + let nextIndex = index + 1 + guard nextIndex < history.count else { + return [] + } + + // The order matters for restoring / redoing file edits + return Array(history[nextIndex...]) + } + + func getMessages(after afterMessageId: String, through throughMessageId: String?) -> [DisplayedChatMessage] { + guard let afterMessageIdIndex = history.firstIndex(where: { $0.id == afterMessageId }) else { + return [] + } + + let startIndex = afterMessageIdIndex + 1 + + let endIndex: Int + if let throughMessageId = throughMessageId, + let throughMessageIdIndex = history.firstIndex(where: { $0.id == throughMessageId }) { + endIndex = throughMessageIdIndex + 1 + } else { + endIndex = history.count + } + + guard startIndex < endIndex, startIndex < history.count else { + return [] + } + + return Array(history[startIndex..) case agentModeChanged(Bool) + case selectedAgentChanged(ConversationMode) // Code Review case codeReview(ConversationCodeReviewFeature.Action) @@ -254,6 +632,15 @@ struct Chat { // External Action case observeFixErrorNotification case fixEditorErrorIssue(EditorErrorIssue) + + // Check Point + case restoreCheckPoint(String) + case restoreFileEdits + case undoCheckPoint // Revert the restore + case discardCheckPoint + case reloadWorkingset(DisplayedChatMessage) + + case openAutoApproveSettings } let service: ChatService @@ -293,11 +680,23 @@ struct Chat { await send(.focusOnTextField) await send(.refresh) await send(.observeFixErrorNotification) - + + let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() + if let modes = await SharedChatService.shared.loadConversationModes(), + let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { + await send(.selectedAgentChanged(currentMode)) + } + let publisher = NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange) for await _ in publisher.values { let isAgentMode = AppState.shared.isAgentModeEnabled() await send(.agentModeChanged(isAgentMode)) + + let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() + if let modes = await SharedChatService.shared.loadConversationModes(), + let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { + await send(.selectedAgentChanged(currentMode)) + } } } @@ -319,16 +718,31 @@ struct Chat { scope: AppState.shared.modelScope() )?.modelFamily let agentMode = AppState.shared.isAgentModeEnabled() + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() let shouldAttachImages = selectedModel?.supportVision ?? CopilotModelManager.getDefaultChatModel( scope: AppState.shared.modelScope() )?.supportVision ?? false let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] let references = state.attachedReferences - state.chatContext.attachedImages = [] + state.editor.clearAttachedImages() + + let toDeleteMessageIds: [String] = { + var messageIds: [String] = [] + if state.editorMode.isEditingUserMessage { + messageIds.append(contentsOf: state.editUserMessageEffectedMessages.map { $0.id }) + if let editingUserMessageId = state.editorMode.editingUserMessageId { + messageIds.append(editingUserMessageId) + } + } + return messageIds + }() return .run { send in await send(.resetContextProvider) + await send(.discardCheckPoint) + await service.deleteMessages(ids: toDeleteMessageIds) + await send(.setEditorMode(.input)) try await service .send( @@ -340,6 +754,7 @@ struct Chat { model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, + customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) @@ -349,6 +764,17 @@ struct Chat { return .run { _ in service.updateToolCallStatus(toolCallId: toolCallId, status: .accepted) }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .toolCallAcceptedWithApproval(toolCallId, approval): + guard !toolCallId.isEmpty else { return .none } + return .run { send in + if let approval { + await ToolAutoApprovalManager.shared.approve(approval) + } + + await send(.toolCallAccepted(toolCallId)) + }.cancellable(id: CancelID.sendMessage(self.id)) + case let .toolCallCancelled(toolCallId): guard !toolCallId.isEmpty else { return .none } return .run { _ in @@ -372,9 +798,11 @@ struct Chat { )?.modelFamily let references = state.attachedReferences let agentMode = AppState.shared.isAgentModeEnabled() + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() return .run { send in await send(.resetContextProvider) + await send(.discardCheckPoint) try await service .send( @@ -385,13 +813,71 @@ struct Chat { model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, + customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .handOffButtonClicked(handOff): + state.handOffClicked = true + let agent = handOff.agent + let prompt = handOff.prompt + let shouldSend = handOff.send ?? false + + return .run { send in + // Find and switch to the target agent + let modes = await SharedChatService.shared.loadConversationModes() ?? [] + if let targetAgent = modes.first(where: { $0.name.lowercased() == agent.lowercased() }) { + await send(.selectedAgentChanged(targetAgent)) + } + + // If send is true, send the prompt message + if shouldSend && !prompt.isEmpty { + await send(.updateTypedMessage(prompt)) + let id = UUID().uuidString + await send(.sendButtonTapped(id)) + } else if !prompt.isEmpty { + // Just populate the message field + await send(.updateTypedMessage(prompt)) + } + } case .returnButtonTapped: state.typedMessage += "\n" return .none + + case let .updateTypedMessage(message): + state.typedMessage = message + return .none + + case let .setEditorMode(mode): + + switch mode { + case .input: + state.editorMode = mode + // remove all edit contexts except input mode + state.editor.keepOnlyInputContext() + case .editUserMessage(let messageID): + guard let message = state.history.first(where: { $0.id == messageID }), + message.role == .user, + let projectURL = service.getProjectRootURL() + else { + return .none + } + + let chatContext: ChatContext = .from(message, projectURL: projectURL) + state.editor.setContext(chatContext, for: mode) + state.editorMode = mode + let isReceivingMessage = service.isReceivingMessage + + return .run { send in + if isReceivingMessage { + await send(.stopRespondingButtonTapped) + } + } + } + + return .none case .stopRespondingButtonTapped: return .merge( @@ -408,7 +894,7 @@ struct Chat { case let .deleteMessageButtonTapped(id): return .run { _ in - await service.deleteMessage(id: id) + await service.deleteMessages(ids: [id]) } case let .resendMessageButtonTapped(id): @@ -433,9 +919,11 @@ struct Chat { "/bin/bash", arguments: [ "-c", - "xed -l 0 \"\(reference.filePath)\"", + "xed -l 0 \"${TARGET_CHAT_FILE}\"", ], - environment: [:] + environment: [ + "TARGET_CHAT_FILE": reference.filePath + ] ) } catch { print(error) @@ -540,8 +1028,14 @@ struct Chat { errorMessages: message.errorMessages, steps: message.steps, editAgentRounds: message.editAgentRounds, + parentTurnId: message.parentTurnId, panelMessages: message.panelMessages, - codeReviewRound: message.codeReviewRound + codeReviewRound: message.codeReviewRound, + fileEdits: message.fileEdits, + turnStatus: message.turnStatus, + requestType: message.requestType, + modelName: message.modelName, + billingMultiplier: message.billingMultiplier )) return all @@ -620,27 +1114,21 @@ struct Chat { state.currentEditor = fileReference return .none case let .addReference(ref): - guard !state.chatContext.attachedReferences.contains(ref) else { - return .none - } - state.chatContext.attachedReferences.append(ref) + state.editor.addReference(ref) return .none case let .removeReference(ref): - guard let index = state.chatContext.attachedReferences.firstIndex(of: ref) else { - return .none - } - state.chatContext.attachedReferences.remove(at: index) + state.editor.removeReference(ref) return .none // MARK: - Image Context case let .addSelectedImage(imageReference): guard !state.attachedImages.contains(imageReference) else { return .none } - state.chatContext.attachedImages.append(imageReference) + state.editor.addImage(imageReference) return .run { send in await send(.resetContextProvider) } case let .removeSelectedImage(imageReference): - guard let index = state.attachedImages.firstIndex(of: imageReference) else { return .none } - state.chatContext.attachedImages.remove(at: index) + guard let _ = state.attachedImages.firstIndex(of: imageReference) else { return .none } + state.editor.removeImage(imageReference) return .run { send in await send(.resetContextProvider) } // MARK: - Agent Edits @@ -690,13 +1178,24 @@ struct Chat { case let .agentModeChanged(isAgentMode): state.isAgentMode = isAgentMode return .none + + case let .selectedAgentChanged(mode): + state.selectedAgent = mode + state.handOffClicked = false + return .none + + // MARK: - Code Review + case let .codeReview(.request(group)): + return .run { send in + await send(.discardCheckPoint) + } case .codeReview: return .none // MARK: Chat Context case .reloadNextContext: - guard let context = state.contextProvider.getNextContext() else { + guard let context = state.editor.popNextContext() else { return .none } @@ -708,7 +1207,7 @@ struct Chat { case .reloadPreviousContext: guard let projectURL = service.getProjectRootURL(), - let context = state.contextProvider.getPreviousContext( + let context = state.editor.previousContext( from: state.history, projectURL: projectURL) else { @@ -717,14 +1216,14 @@ struct Chat { let currentContext = state.chatContext state.chatContext = context - state.contextProvider.pushContext(currentContext) + state.editor.pushContext(currentContext) return .run { send in await send(.focusOnTextField) } case .resetContextProvider: - state.contextProvider.reset() + state.editor.resetContextProvider() return .none // MARK: - External action @@ -777,6 +1276,8 @@ struct Chat { scope: AppState.shared.modelScope() )?.modelFamily let agentMode = AppState.shared.isAgentModeEnabled() + // TODO: if we need to switch to agent mode or keep the current mode + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() return .run { _ in try await service.send( @@ -787,9 +1288,153 @@ struct Chat { model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, + customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) + + // MARK: - Check Point + + case let .restoreCheckPoint(messageId): + guard let message = state.history.first(where: { $0.id == messageId }) else { + return .none + } + + if state.pendingCheckpointContext == nil { + state.pendingCheckpointContext = state.chatContext + } + state.pendingCheckpointMessageId = messageId + + // Reload the chat context + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + if !messagesAfterCheckpoint.isEmpty, + let userMessage = messagesAfterCheckpoint.first, + userMessage.role == .user, + let projectURL = service.getProjectRootURL() + { + state.chatContext = .from(userMessage, projectURL: projectURL) + } + + let isReceivingMessage = state.isReceivingMessage + return .run { send in + await send(.restoreFileEdits) + await send(.reloadWorkingset(message)) + if isReceivingMessage { + await send(.stopRespondingButtonTapped) + } + } + + case .restoreFileEdits: + // Revert file edits in messages after checkpoint + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + guard !messagesAfterCheckpoint.isEmpty else { + return .none + } + + return .run { _ in + var restoredURLs = Set() + let fileManager = FileManager.default + + // Revert the file edit. From the oldest to newest + for message in messagesAfterCheckpoint { + let fileEdits = message.fileEdits + guard !fileEdits.isEmpty else { + continue + } + + for fileEdit in fileEdits { + guard !restoredURLs.contains(fileEdit.fileURL) else { + continue + } + restoredURLs.insert(fileEdit.fileURL) + + do { + switch fileEdit.toolName { + case .createFile: + try fileManager.removeItem(at: fileEdit.fileURL) + case .insertEditIntoFile: + try fileEdit.originalContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) + default: + break + } + } catch { + Logger.client.error(">>> Failed to restore file Edit: \(error)") + } + } + } + } + + case .undoCheckPoint: + if let context = state.pendingCheckpointContext { + state.chatContext = context + state.pendingCheckpointContext = nil + } + let reversedMessagesAfterCheckpoint = Array(state.messagesAfterCheckpoint.reversed()) + + state.pendingCheckpointMessageId = nil + + // Redo file edits in messages after checkpoint + guard !reversedMessagesAfterCheckpoint.isEmpty else { + return .none + } + + return .run { send in + var redoedURL = Set() + let lastMessage = reversedMessagesAfterCheckpoint.first + + for message in reversedMessagesAfterCheckpoint { + let fileEdits = message.fileEdits + guard !fileEdits.isEmpty else { + continue + } + + for fileEdit in fileEdits { + guard !redoedURL.contains(fileEdit.fileURL) else { + continue + } + redoedURL.insert(fileEdit.fileURL) + + do { + switch fileEdit.toolName { + case .createFile, .insertEditIntoFile: + try fileEdit.modifiedContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) + default: + break + } + } catch { + Logger.client.error(">>> failed to undo fileEdit: \(error)") + } + } + } + + // Recover fileEdits working set + if let lastMessage { + await send(.reloadWorkingset(lastMessage)) + } + } + + case .discardCheckPoint: + let messagesAfterCheckpoint = state.messagesAfterCheckpoint + state.pendingCheckpointMessageId = nil + state.pendingCheckpointContext = nil + return .run { _ in + if !messagesAfterCheckpoint.isEmpty { + await service.deleteMessages(ids: messagesAfterCheckpoint.map { $0.id }) + } + } + + case let .reloadWorkingset(message): + return .run { _ in + service.resetFileEdits() + for fileEdit in message.fileEdits { + service.updateFileEdits(by: fileEdit) + } + } + + case .openAutoApproveSettings: + return .run { _ in + try launchHostAppToolsSettingsAutoApprove() + } } } } diff --git a/Core/Sources/ConversationTab/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift index 8949b882..f5e2573f 100644 --- a/Core/Sources/ConversationTab/ChatExtension.swift +++ b/Core/Sources/ConversationTab/ChatExtension.swift @@ -15,4 +15,12 @@ extension Chat.State { ) return [CurrentEditorSkill(currentFile: fileReference), ProblemsInActiveDocumentSkill()] } + + func getChatContext(of mode: Chat.EditorMode) -> ChatContext { + return editor.context(for: mode) + } + + func getSubsequentMessages(after messageId: String) -> [DisplayedChatMessage] { + conversation.subsequentMessages(after: messageId) + } } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index c5608cf8..f4794206 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -35,30 +35,38 @@ public struct ChatPanel: View { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(.trailing, 16) } else { ChatPanelMessages(chat: chat) .accessibilityElement(children: .combine) .accessibilityLabel("Chat Messages Group") - - if let _ = chat.history.last?.followUp { + + if chat.isAgentMode, let handOffs = chat.selectedAgent.handOffs, !handOffs.isEmpty, + chat.history.contains(where: { $0.role == .assistant && $0.turnStatus != .inProgress }), + !chat.handOffClicked { + ChatHandOffs(chat: chat) + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 16) + .dimWithExitEditMode(chat) + } else if let _ = chat.history.last?.followUp { ChatFollowUp(chat: chat) - .padding(.trailing, 16) - .padding(.vertical, 8) + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 16) + .dimWithExitEditMode(chat) } } if chat.fileEditMap.count > 0 { WorkingSetView(chat: chat) - .padding(.trailing, 16) + .dimWithExitEditMode(chat) + .scaledPadding(.horizontal, 24) } - ChatPanelInputArea(chat: chat) - .padding(.trailing, 16) + ChatPanelInputArea(chat: chat, r: r, editorMode: .input) + .dimWithExitEditMode(chat) + .scaledPadding(.horizontal, 16) } - .padding(.leading, 16) - .padding(.bottom, 16) - .background(.ultraThinMaterial) + .scaledPadding(.vertical, 12) + .background(Color.chatWindowBackgroundColor) .onAppear { chat.send(.appear) } @@ -140,6 +148,7 @@ struct ChatPanelMessages: View { @State var didScrollToBottomOnAppearOnce = false @State var isBottomHidden = true @Environment(\.isEnabled) var isEnabled + @AppStorage(\.fontScale) private var fontScale: Double var body: some View { WithPerceptionTracking { @@ -149,12 +158,13 @@ struct ChatPanelMessages: View { Group { ChatHistory(chat: chat) - .listItemTint(.clear) + .fixedSize(horizontal: false, vertical: true) ExtraSpacingInResponding(chat: chat) Spacer(minLength: 12) .id(bottomID) + .listRowInsets(EdgeInsets()) .onAppear { isBottomHidden = false if !didScrollToBottomOnAppearOnce { @@ -182,8 +192,8 @@ struct ChatPanelMessages: View { } } } - .padding(.leading, -8) .listStyle(.plain) + .scaledPadding(.leading, 8) .listRowBackground(EmptyView()) .modify { view in if #available(macOS 13.0, *) { @@ -207,6 +217,7 @@ struct ChatPanelMessages: View { } .overlay(alignment: .bottomTrailing) { scrollToBottomButton(proxy: proxy) + .scaledPadding(4) } .background { PinToBottomHandler( @@ -253,12 +264,21 @@ struct ChatPanelMessages: View { .store(in: &cancellable) } + private let listRowSpacing: CGFloat = 32 + private let scrollButtonBuffer: CGFloat = 32 + @MainActor func updatePinningState() { // where does the 32 come from? withAnimation(.linear(duration: 0.1)) { - isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20 - || scrollOffset <= 0 + // Ensure listHeight is greater than 0 to avoid invalid calculations or division by zero. + // This guard clause prevents unnecessary updates when the list height is not yet determined. + guard listHeight > 0 else { + isScrollToBottomButtonDisplayed = false + return + } + + isScrollToBottomButtonDisplayed = scrollOffset > listHeight + (listRowSpacing + scrollButtonBuffer) * fontScale } } @@ -271,20 +291,18 @@ struct ChatPanelMessages: View { } }) { Image(systemName: "chevron.down") - .scaledFrame(width: 14, height: 14) - .padding(8) + .scaledFrame(width: 12, height: 12) + .scaledPadding(4) .background { Circle() - .fill(.thickMaterial) - .shadow(color: .black.opacity(0.2), radius: 2) + .fill(Color.chatWindowBackgroundColor) } .overlay { Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) } .foregroundStyle(.secondary) } - .buttonStyle(HoverButtonStyle(padding: 0)) - .padding(4) + .buttonStyle(.plain) .keyboardShortcut(.downArrow, modifiers: [.command]) .opacity(isScrollToBottomButtonDisplayed ? 1 : 0) .help("Scroll Down") @@ -292,11 +310,13 @@ struct ChatPanelMessages: View { struct ExtraSpacingInResponding: View { let chat: StoreOf + + @AppStorage(\.fontScale) private var fontScale: Double var body: some View { WithPerceptionTracking { if chat.isReceivingMessage { - Spacer(minLength: 12) + Spacer(minLength: 12 * fontScale) } } } @@ -323,7 +343,16 @@ struct ChatPanelMessages: View { } } } else { - Task { pinnedToBottom = false } + Task { + // Scoll to bottom when `isReceiving` changes to `false` + if pinnedToBottom { + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } + pinnedToBottom = false + } } } .onChange(of: chat.history.last) { _ in @@ -333,8 +362,10 @@ struct ChatPanelMessages: View { } Task { await Task.yield() - withAnimation(.easeInOut(duration: 0.1)) { - scrollToBottom() + if !chat.editorMode.isEditingUserMessage { + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } } } } @@ -352,21 +383,63 @@ struct ChatPanelMessages: View { struct ChatHistory: View { let chat: StoreOf + + var filteredHistory: [DisplayedChatMessage] { + guard let pendingCheckpointMessageId = chat.pendingCheckpointMessageId else { + return chat.history + } + + if let checkPointMessageIndex = chat.history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) { + return Array(chat.history.prefix(checkPointMessageIndex + 1)) + } + + return chat.history + } + + var editUserMessageEffectedMessageIds: Set { + Set(chat.editUserMessageEffectedMessages.map { $0.id }) + } var body: some View { WithPerceptionTracking { - ForEach(Array(chat.history.enumerated()), id: \.element.id) { index, message in - VStack(spacing: 0) { - WithPerceptionTracking { - ChatHistoryItem(chat: chat, message: message) - .id(message.id) - .padding(.top, 4) - .padding(.bottom, 12) + let currentFilteredHistory = filteredHistory + let pendingCheckpointMessageId = chat.pendingCheckpointMessageId + + VStack(spacing: 16) { + ForEach(Array(currentFilteredHistory.enumerated()), id: \.element.id) { index, message in + VStack(spacing: 8) { + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message) + .id(message.id) + } + + if message.role != .ignored && index < currentFilteredHistory.count - 1 { + if message.role == .assistant && message.parentTurnId == nil { + let nextMessage = currentFilteredHistory[index + 1] + let hasContent = !message.text.isEmpty || !message.editAgentRounds.isEmpty + let nextIsNotSubturn = nextMessage.parentTurnId != message.id + + if hasContent && nextIsNotSubturn { + CheckPoint(chat: chat, messageId: message.id) + .padding(.vertical, 8) + .padding(.trailing, 8) + } + } + } + + // Show up check point for redo + if message.id == pendingCheckpointMessageId { + CheckPoint(chat: chat, messageId: message.id) + .padding(.vertical, 8) + .padding(.trailing, 8) + } } - - // add divider between messages - if message.role != .ignored && index < chat.history.count - 1 { - Divider() } + .dimWithExitEditMode( + chat, + applyTo: message.id, + isDimmed: editUserMessageEffectedMessageIds.contains(message.id), + allowTapToExit: chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId != message.id + ) } } } @@ -386,21 +459,18 @@ struct ChatHistoryItem: View { id: message.id, text: text, imageReferences: message.imageReferences, - chat: chat + chat: chat, + editorCornerRadius: r, + requestType: message.requestType ) + .scaledPadding(.leading, chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId == message.id ? 0 : 20) + .scaledPadding(.trailing, 8) case .assistant: BotMessage( - id: message.id, - text: text, - references: message.references, - followUp: message.followUp, - errorMessages: message.errorMessages, - chat: chat, - steps: message.steps, - editAgentRounds: message.editAgentRounds, - panelMessages: message.panelMessages, - codeReviewRound: message.codeReviewRound + message: message, + chat: chat ) + .scaledPadding(.trailing, 20) case .ignored: EmptyView() } @@ -431,12 +501,55 @@ struct ChatFollowUp: View { } .buttonStyle(.plain) .onHover { isHovered in - if isHovered { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .onDisappear { + NSCursor.pop() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct ChatHandOffs: View { + let chat: StoreOf + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("PROCEED FROM \(chat.selectedAgent.name.uppercased())") + .foregroundStyle(.secondary) + .scaledPadding(.horizontal, 4) + .scaledPadding(.bottom, -4) + + FlowLayout(mode: .vstack, items: chat.selectedAgent.handOffs ?? [], itemSpacing: 4) { item in + Button(action: { + chat.send(.handOffButtonClicked(item)) + }) { + Text(item.label) + } + .buttonStyle(.bordered) + .onHover { isHovered in + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } } + .onDisappear { + NSCursor.pop() + } } } .frame(maxWidth: .infinity, alignment: .leading) @@ -473,643 +586,6 @@ struct ChatCLSError: View { } } -struct ChatPanelInputArea: View { - let chat: StoreOf - @FocusState var focusedField: Chat.State.Field? - - var body: some View { - HStack { - InputAreaTextEditor(chat: chat, focusedField: $focusedField) - } - .background(Color.clear) - } - - @MainActor - var clearButton: some 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) - } - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - } - - enum ShowingType { case template, agent } - - struct InputAreaTextEditor: View { - @Perception.Bindable var chat: StoreOf - var focusedField: FocusState.Binding - @State var cancellable = Set() - @State private var isFilePickerPresented = false - @State private var allFiles: [ConversationAttachedReference]? = nil - @State private var filteredTemplates: [ChatTemplate] = [] - @State private var filteredAgent: [ChatAgent] = [] - @State private var showingTemplates = false - @State private var dropDownShowingType: ShowingType? = nil - @State private var textEditorState: TextEditorState? = nil - - @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool - @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value( - for: \.enableCurrentEditorContext - ) - @ObservedObject private var status: StatusObserver = .shared - @State private var isCCRFFEnabled: Bool - @State private var cancellables = Set() - - @StateObject private var fontScaleManager = FontScaleManager.shared - - var fontScale: Double { - fontScaleManager.currentScale - } - - init( - chat: StoreOf, - focusedField: FocusState.Binding - ) { - self.chat = chat - self.focusedField = focusedField - self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr - } - - var isRequestingConversation: Bool { - if chat.isReceivingMessage, - let requestType = chat.requestType, - requestType == .conversation { - return true - } - return false - } - - var isRequestingCodeReview: Bool { - if chat.isReceivingMessage, - let requestType = chat.requestType, - requestType == .codeReview { - return true - } - - return false - } - - var body: some View { - WithPerceptionTracking { - VStack(spacing: 0) { - chatContextView - - if isFilePickerPresented { - FilePicker( - allFiles: $allFiles, - workspaceURL: chat.workspaceURL, - onSubmit: { ref in - chat.send(.addReference(ref)) - }, - onExit: { - isFilePickerPresented = false - focusedField.wrappedValue = .textField - } - ) - .onAppear() { - allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL) - } - } - - if !chat.state.attachedImages.isEmpty { - ImagesScrollView(chat: chat) - } - - ZStack(alignment: .topLeading) { - if chat.typedMessage.isEmpty { - Group { - chat.isAgentMode ? - Text("Edit files in your workspace in agent mode") : - Text("Ask Copilot or type / for commands") - } - .scaledFont(size: 14) - .foregroundColor(Color(nsColor: .placeholderTextColor)) - .padding(8) - .padding(.horizontal, 4) - } - - HStack(spacing: 0) { - AutoresizingCustomTextEditor( - text: $chat.typedMessage, - font: .systemFont(ofSize: 14 * fontScale), - isEditable: true, - maxHeight: 400, - onSubmit: { - if (dropDownShowingType == nil) { - submitChatMessage() - } - dropDownShowingType = nil - }, - onTextEditorStateChanged: { (state: TextEditorState?) in - DispatchQueue.main.async { - textEditorState = state - } - } - ) - .focused(focusedField, equals: .textField) - .bind($chat.focusedField, to: focusedField) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - .onChange(of: chat.typedMessage) { newValue in - Task { - await onTypedMessageChanged(newValue: newValue) - } - } - /// When chat mode changed, the chat tamplate and agent need to be reloaded - .onChange(of: chat.isAgentMode) { _ in - Task { - await onTypedMessageChanged(newValue: chat.typedMessage) - } - } - } - .frame(maxWidth: .infinity) - } - .padding(.top, 4) - - HStack(spacing: 0) { - ModelPicker() - - Spacer() - - codeReviewButton - .buttonStyle(HoverButtonStyle(padding: 0)) - .disabled(isRequestingConversation) - - ZStack { - sendButton - .opacity(isRequestingConversation ? 0 : 1) - - stopButton - .opacity(isRequestingConversation ? 1 : 0) - } - .buttonStyle(HoverButtonStyle(padding: 0)) - .disabled(isRequestingCodeReview) - } - .padding(8) - .padding(.top, -4) - } - .overlay(alignment: .top) { - dropdownOverlay - } - .onAppear() { - subscribeToActiveDocumentChangeEvent() - // Check quota for CCR - Task { - if status.quotaInfo == nil, - let service = try? GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() { - _ = try? await service.checkQuota() - } - } - } - .task { - subscribeToFeatureFlagsDidChangeEvent() - } - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .background { - Button(action: { - chat.send(.returnButtonTapped) - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - .accessibilityHidden(true) - - Button(action: { - focusedField.wrappedValue = .textField - }) { - EmptyView() - } - .keyboardShortcut("l", modifiers: [.command]) - .accessibilityHidden(true) - - buildReloadContextButtons() - } - - } - } - - private var reloadNextContextButton: some View { - Button(action: { - chat.send(.reloadNextContext) - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.downArrow, modifiers: []) - .accessibilityHidden(true) - } - - private var reloadPreviousContextButton: some View { - Button(action: { - chat.send(.reloadPreviousContext) - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.upArrow, modifiers: []) - .accessibilityHidden(true) - } - - @ViewBuilder - private func buildReloadContextButtons() -> some View { - if let textEditorState = textEditorState { - switch textEditorState { - case .empty, .singleLine: - ZStack { - reloadPreviousContextButton - reloadNextContextButton - } - case .multipleLines(let cursorAt): - switch cursorAt { - case .first: - reloadPreviousContextButton - case .last: - reloadNextContextButton - case .middle: - EmptyView() - } - } - } else { - EmptyView() - } - } - - private var sendButton: some View { - Button(action: { - submitChatMessage() - }) { - Image(systemName: "paperplane.fill") - .scaledFont(.body) - .padding(4) - } - .keyboardShortcut(KeyEquivalent.return, modifiers: []) - .help("Send") - } - - private var stopButton: some View { - Button(action: { - chat.send(.stopRespondingButtonTapped) - }) { - Image(systemName: "stop.circle") - .scaledFont(.body) - .padding(4) - } - } - - private var isFreeUser: Bool { - guard let quotaInfo = status.quotaInfo else { return true } - - return quotaInfo.isFreeUser - } - - private var ccrDisabledTooltip: String { - if !isCCRFFEnabled { - return "GitHub Copilot Code Review is disabled by org policy. Contact your admin." - } - - return "GitHub Copilot Code Review is temporarily unavailable." - } - - var codeReviewIcon: some View { - Image("codeReview") - .resizable() - .scaledToFit() - .scaledFrame(width: 14, height: 14) - .padding(6) - } - - private var codeReviewButton: some View { - Group { - if isFreeUser { - // Show nothing - } else if isCCRFFEnabled { - ZStack { - stopButton - .opacity(isRequestingCodeReview ? 1 : 0) - .help("Stop Code Review") - - Menu { - Button(action: { - chat.send(.codeReview(.request(.index))) - }) { - Text("Review Staged Changes") - } - - Button(action: { - chat.send(.codeReview(.request(.workingTree))) - }) { - Text("Review Unstaged Changes") - } - } label: { - codeReviewIcon - } - .scaledFont(.body) - .opacity(isRequestingCodeReview ? 0 : 1) - .help("Code Review") - } - .buttonStyle(HoverButtonStyle(padding: 0)) - } else { - codeReviewIcon - .foregroundColor(Color(nsColor: .tertiaryLabelColor)) - .help(ccrDisabledTooltip) - } - } - } - - private func subscribeToFeatureFlagsDidChangeEvent() { - FeatureFlagNotifierImpl.shared.featureFlagsDidChange - .sink(receiveValue: { isCCRFFEnabled = $0.ccr }) - .store(in: &cancellables) - } - - private var dropdownOverlay: some View { - Group { - if dropDownShowingType != nil { - if dropDownShowingType == .template { - ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in - chat.typedMessage = "/" + template.id + " " - if template.id == "releaseNotes" { - submitChatMessage() - } - } - } else if dropDownShowingType == .agent { - ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in - chat.typedMessage = "@" + agent.id + " " - } - } - } - } - } - - func onTypedMessageChanged(newValue: String) async { - if newValue.hasPrefix("/") { - filteredTemplates = await chatTemplateCompletion(text: newValue) - dropDownShowingType = filteredTemplates.isEmpty ? nil : .template - } else if newValue.hasPrefix("@") && !chat.isAgentMode { - filteredAgent = await chatAgentCompletion(text: newValue) - dropDownShowingType = filteredAgent.isEmpty ? nil : .agent - } else { - dropDownShowingType = nil - } - } - - enum ChatContextButtonType { case imageAttach, contextAttach} - - private var chatContextView: some View { - let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] - let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap { - $0 - } - let references = chat.state.attachedReferences - let chatContextItems: [Any] = buttonItems.map { - $0 as ChatContextButtonType - } + currentEditorItem + references - return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in - if let buttonType = item as? ChatContextButtonType { - if buttonType == .imageAttach { - VisionMenuView(chat: chat) - } else if buttonType == .contextAttach { - // File picker button - Button(action: { - withAnimation { - isFilePickerPresented.toggle() - if !isFilePickerPresented { - focusedField.wrappedValue = .textField - } - } - }) { - Image(systemName: "paperclip") - .resizable() - .aspectRatio(contentMode: .fill) - .scaledFrame(width: 16, height: 16) - .scaledPadding(4) - .foregroundColor(.primary.opacity(0.85)) - .scaledFont(size: 11, weight: .semibold) - } - .buttonStyle(HoverButtonStyle(padding: 0)) - .help("Add Context") - .cornerRadius(6) - } - } else if let select = item as? ConversationFileReference, select.isCurrentEditor { - makeCurrentEditorView(select) - } else if let select = item as? ConversationAttachedReference { - makeReferenceItemView(select) - } - } - .padding(.horizontal, 8) - .padding(.top, 8) - } - - @ViewBuilder - func makeCurrentEditorView(_ ref: ConversationFileReference) -> some View { - HStack(spacing: 0) { - makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection) - - Toggle("", isOn: $isCurrentEditorContextEnabled) - .toggleStyle(SwitchToggleStyle(tint: .blue)) - .controlSize(.mini) - .padding(.trailing, 4) - .onChange(of: isCurrentEditorContextEnabled) { newValue in - enableCurrentEditorContext = newValue - } - } - .chatContextReferenceStyle(isCurrentEditor: true, r: r) - } - - @ViewBuilder - func makeReferenceItemView(_ ref: ConversationAttachedReference) -> some View { - HStack(spacing: 0) { - makeContextFileNameView(url: ref.url, isCurrentEditor: false, isDirectory: ref.isDirectory) - - Button(action: { chat.send(.removeReference(ref)) }) { - Image(systemName: "xmark") - .resizable() - .scaledFrame(width: 8, height: 8) - .foregroundColor(.primary.opacity(0.85)) - .padding(4) - } - .buttonStyle(HoverButtonStyle()) - } - .chatContextReferenceStyle(isCurrentEditor: false, r: r) - } - - @ViewBuilder - func makeContextFileNameView( - url: URL, - isCurrentEditor: Bool, - isDirectory: Bool = false, - selection: LSPRange? = nil - ) -> some View { - drawFileIcon(url, isDirectory: isDirectory) - .resizable() - .scaledToFit() - .scaledFrame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.85)) - .padding(4) - .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) - - HStack(spacing: 0) { - Text(url.lastPathComponent) - - Group { - if isCurrentEditor, let selection { - let startLine = selection.start.line - let endLine = selection.end.line - if startLine == endLine { - Text(String(format: ":%d", selection.start.line + 1)) - } else { - Text(String(format: ":%d-%d", selection.start.line + 1, selection.end.line + 1)) - } - } - } - .foregroundColor(.secondary) - } - .lineLimit(1) - .truncationMode(.middle) - .foregroundColor( - isCurrentEditor && !isCurrentEditorContextEnabled - ? .secondary - : .primary.opacity(0.85) - ) - .scaledFont(.body) - .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) - .help(url.getPathRelativeToHome()) - } - - func chatTemplateCompletion(text: String) async -> [ChatTemplate] { - guard text.count >= 1 && text.first == "/" else { return [] } - - let prefix = String(text.dropFirst()).lowercased() - let promptTemplates: [ChatTemplate] = await SharedChatService.shared.loadChatTemplates() ?? [] - let releaseNotesTemplate: ChatTemplate = .init( - id: "releaseNotes", - description: "What's New", - shortDescription: "What's New", - scopes: [.chatPanel, .agentPanel] - ) - - let templates = promptTemplates + [releaseNotesTemplate] - let skippedTemplates = [ "feedback", "help" ] - - return templates.filter { - $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) && - $0.id.lowercased().hasPrefix(prefix) && - !skippedTemplates.contains($0.id) - } - } - - func chatAgentCompletion(text: String) async -> [ChatAgent] { - guard text.count >= 1 && text.first == "@" else { return [] } - let prefix = text.dropFirst() - var chatAgents = await SharedChatService.shared.loadChatAgents() ?? [] - - if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) { - let projectAgent = chatAgents[index] - chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl) - } - - /// only enable the @workspace - let includedAgents = ["workspace"] - - return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) } - } - - func subscribeToActiveDocumentChangeEvent() { - var task: Task? - var currentFocusedEditor: SourceEditor? - - Publishers.CombineLatest3( - XcodeInspector.shared.$latestActiveXcode, - XcodeInspector.shared.$activeDocumentURL - .removeDuplicates(), - XcodeInspector.shared.$focusedEditor - .removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { newXcode, newDocURL, newFocusedEditor in - var currentEditor: ConversationFileReference? - - // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil - if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil { - if supportedFileExtensions.contains(realtimeURL.pathExtension) { - currentEditor = ConversationFileReference(url: realtimeURL, isCurrentEditor: true) - } - } else if let docURL = newDocURL, supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { - currentEditor = ConversationFileReference(url: docURL, isCurrentEditor: true) - } - - if var currentEditor = currentEditor { - if let selection = newFocusedEditor?.getContent().selections.first, - selection.start != selection.end { - currentEditor.selection = .init(start: selection.start, end: selection.end) - } - - chat.send(.setCurrentEditor(currentEditor)) - } - - if currentFocusedEditor != newFocusedEditor { - task?.cancel() - task = nil - currentFocusedEditor = newFocusedEditor - - if let editor = currentFocusedEditor { - task = Task { @MainActor in - for await _ in await editor.axNotifications.notifications() - .filter({ $0.kind == .selectedTextChanged }) { - handleSourceEditorSelectionChanged(editor) - } - } - } - } - } - .store(in: &cancellable) - } - - private func handleSourceEditorSelectionChanged(_ sourceEditor: SourceEditor) { - guard let fileURL = sourceEditor.realtimeDocumentURL, - let currentEditorURL = chat.currentEditor?.url, - fileURL == currentEditorURL - else { - return - } - - var currentEditor: ConversationFileReference = .init(url: fileURL, isCurrentEditor: true) - - if let selection = sourceEditor.getContent().selections.first, - selection.start != selection.end { - currentEditor.selection = .init(start: selection.start, end: selection.end) - } - - chat.send(.setCurrentEditor(currentEditor)) - } - - func submitChatMessage() { - chat.send(.sendButtonTapped(UUID().uuidString)) - } - } -} - extension URL { func getPathRelativeToHome() -> String { let filePath = self.path @@ -1131,7 +607,8 @@ struct ChatPanel_Preview: PreviewProvider { id: "1", role: .user, text: "**Hello**", - references: [] + references: [], + requestType: .conversation ), .init( id: "2", @@ -1149,25 +626,29 @@ struct ChatPanel_Preview: PreviewProvider { kind: .class, referenceType: .file ), - ] + ], + requestType: .conversation ), .init( id: "7", role: .ignored, text: "Ignored", - references: [] + references: [], + requestType: .conversation ), .init( id: "5", role: .assistant, text: "Yooo", - references: [] + references: [], + requestType: .conversation ), .init( id: "4", role: .user, text: "Yeeeehh", - references: [] + references: [], + requestType: .conversation ), .init( id: "3", @@ -1187,7 +668,8 @@ struct ChatPanel_Preview: PreviewProvider { ``` """#, references: [], - followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type") + followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type"), + requestType: .conversation ), ] @@ -1232,8 +714,8 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { ChatPanel( chat: .init( initialState: .init( - chatContext: .init( - typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum."), + editorModeContexts: [Chat.EditorMode.input: ChatContext( + typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.")], history: ChatPanel_Preview.history, isReceivingMessage: false ), diff --git a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift index 553f5976..3cecf903 100644 --- a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift +++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift @@ -86,13 +86,13 @@ struct AsyncCodeBlockView: View { Group { if let highlighted = storage.highlighted { Text(highlighted) - .frame(maxWidth: .infinity, alignment: .leading) } else { Text(content).font(.init(font)) - .frame(maxWidth: .infinity, alignment: .leading) } } - .frame(maxWidth: .infinity) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) .onAppear { storage.highlight(debounce: false, for: self) } diff --git a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift index e4da4784..4a52af45 100644 --- a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift +++ b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift @@ -2,6 +2,7 @@ import SwiftUI import ChatService import ComposableArchitecture import WebKit +import ChatAPIService enum Style { /// default diff view frame. Same as the `ChatPanel` diff --git a/Core/Sources/ConversationTab/DiffViews/DiffView.swift b/Core/Sources/ConversationTab/DiffViews/DiffView.swift index c857528e..ee66ec8b 100644 --- a/Core/Sources/ConversationTab/DiffViews/DiffView.swift +++ b/Core/Sources/ConversationTab/DiffViews/DiffView.swift @@ -5,6 +5,7 @@ import Logger import ConversationServiceProvider import ChatService import ChatTab +import ChatAPIService extension FileEdit { var originalContentByStatus: String { diff --git a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift index cc42af5d..36c952a5 100644 --- a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift +++ b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift @@ -3,6 +3,7 @@ import ChatService import SwiftUI import WebKit import Logger +import ChatAPIService struct DiffWebView: NSViewRepresentable { @Perception.Bindable var chat: StoreOf diff --git a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift index c85ca212..d55acc6d 100644 --- a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift +++ b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift @@ -70,9 +70,11 @@ public struct ConversationCodeReviewFeature { "/bin/bash", arguments: [ "-c", - "xed -l \(lineNumber+1) \"\(fileURL.path)\"" + "xed -l \(lineNumber+1) \"${TARGET_REVIEW_FILE}\"" ], - environment: [:] + environment: [ + "TARGET_REVIEW_FILE": fileURL.path + ] ) } catch { print(error) diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index e6eebc2b..284c52ec 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -176,7 +176,6 @@ public struct FilePicker: View { RoundedRectangle(cornerRadius: 8) .stroke(Color(nsColor: .separatorColor), lineWidth: 1) ) - .padding(.horizontal, 12) } } @@ -204,9 +203,8 @@ struct FileRowView: View { var body: some View { WithPerceptionTracking { - HStack { + HStack(alignment: .center) { drawFileIcon(ref.url, isDirectory: ref.isDirectory) - .resizable() .scaledToFit() .scaledFrame(width: 16, height: 16) .hoverSecondaryForeground(isHovered: selectedId == id) diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift similarity index 69% rename from Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift index 228ad965..ec1abafb 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift @@ -8,7 +8,10 @@ import HostAppActivator import SharedUIComponents import ConversationServiceProvider -struct ModelPicker: View { +struct ModeAndModelPicker: View { + let projectRootURL: URL? + @Binding var selectedAgent: ConversationMode + @State private var selectedModel: LLMModel? @State private var isHovered = false @State private var isPressed = false @@ -24,6 +27,7 @@ struct ModelPicker: View { @State var isMCPFFEnabled: Bool @State var isBYOKFFEnabled: Bool + @State var isEditorPreviewEnabled: Bool @State private var cancellables = Set() @StateObject private var fontScaleManager = FontScaleManager.shared @@ -32,23 +36,17 @@ struct ModelPicker: View { fontScaleManager.currentScale } - let minimumPadding: Int = 48 - let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] - - var spaceWidth: CGFloat { - "\u{200A}".size(withAttributes: attributes).width - } - - var minimumPaddingWidth: CGFloat { - spaceWidth * CGFloat(minimumPadding) - } + let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes - init() { + init(projectRootURL: URL?, selectedAgent: Binding) { + self.projectRootURL = projectRootURL + self._selectedAgent = selectedAgent let initialModel = AppState.shared.getSelectedModel() ?? CopilotModelManager.getDefaultChatModel() self._selectedModel = State(initialValue: initialModel) self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok + self.isEditorPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures updateAgentPicker() } @@ -56,6 +54,7 @@ struct ModelPicker: View { FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in isMCPFFEnabled = featureFlags.mcp isBYOKFFEnabled = featureFlags.byok + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures }) .store(in: &cancellables) } @@ -116,19 +115,13 @@ struct ModelPicker: View { var maxWidth: CGFloat = 0 for model in models { - var multiplierText = "" - if model.billing != nil { - multiplierText = formatMultiplierText(for: model.billing) - } else if let providerName = model.providerName, !providerName.isEmpty { - // For BYOK models, show the provider name - multiplierText = providerName - } - newCache[model.modelName.appending(model.providerName ?? "")] = multiplierText - + let multiplierText = ModelMenuItemFormatter.getMultiplierText(for: model) + newCache[model.id.appending(model.providerName ?? "")] = multiplierText + let displayName = "โœ“ \(model.displayName ?? model.modelName)" let displayNameWidth = displayName.size(withAttributes: attributes).width let multiplierWidth = multiplierText.isEmpty ? 0 : multiplierText.size(withAttributes: attributes).width - let totalWidth = displayNameWidth + minimumPaddingWidth + multiplierWidth + let totalWidth = displayNameWidth + ModelMenuItemFormatter.minimumPaddingWidth + multiplierWidth maxWidth = max(maxWidth, totalWidth) } @@ -150,6 +143,22 @@ struct ModelPicker: 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 model == currentModel @@ -176,11 +185,23 @@ struct ModelPicker: View { self.chatMode = AppState.shared.getSelectedChatMode() } - func switchModelsForScope(_ scope: PromptTemplateScope) { + func switchModelsForScope(_ scope: PromptTemplateScope, model: String?) { let newModeModels = CopilotModelManager.getAvailableChatLLMs( scope: scope ) + BYOKModelManager.getAvailableChatLLMs(scope: scope) + // If a model string is provided, try to parse and find it + if let modelString = model { + if let parsedModel = parseModelString(modelString, from: newModeModels) { + // Model exists in the scope, set it + AppState.shared.setSelectedModel(parsedModel) + self.updateCurrentModel() + updateModelCacheIfNeeded(for: scope) + return + } + // If model doesn't exist in scope, fall through to default behavior + } + if let currentModel = AppState.shared.getSelectedModel() { if !newModeModels.isEmpty && !newModeModels.contains(where: { $0 == currentModel }) { let defaultModel = CopilotModelManager.getDefaultChatModel(scope: scope) @@ -196,15 +217,60 @@ struct ModelPicker: View { updateModelCacheIfNeeded(for: scope) } + // Parse model string in format "{Model DisplayName} ({providerName or copilot})" + // If no parentheses, defaults to Copilot model + private func parseModelString(_ modelString: String, from availableModels: [LLMModel]) -> LLMModel? { + var displayName: String + var isCopilotModel: Bool + var provider: String = "" + + // Extract display name and provider from the format: "DisplayName (provider)" + if let openParenIndex = modelString.lastIndex(of: "("), + let closeParenIndex = modelString.lastIndex(of: ")"), + openParenIndex < closeParenIndex { + + let displayNameEndIndex = modelString.index(before: openParenIndex) + displayName = String(modelString[.. AttributedString { - let displayName = isSelected ? "โœ“ \(modelName)" : " \(modelName)" - - var fullString = displayName - var attributedString = AttributedString(fullString) - - if !cachedMultiplierText.isEmpty { - let displayNameWidth = displayName.size(withAttributes: attributes).width - let multiplierTextWidth = cachedMultiplierText.size(withAttributes: attributes).width - let neededPaddingWidth = currentCache.cachedMaxWidth - displayNameWidth - multiplierTextWidth - let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth) - - let numberOfSpaces = Int(round(finalPaddingWidth / spaceWidth)) - let padding = String(repeating: "\u{200A}", count: max(minimumPadding, numberOfSpaces)) - fullString = "\(displayName)\(padding)\(cachedMultiplierText)" - - attributedString = AttributedString(fullString) - - if let range = attributedString.range( - of: cachedMultiplierText, - options: .backwards - ) { - attributedString[range].foregroundColor = .secondary - } - } - - return attributedString + return ModelMenuItemFormatter.createModelMenuItemAttributedString( + modelName: modelName, + isSelected: isSelected, + multiplierText: cachedMultiplierText, + targetWidth: currentCache.cachedMaxWidth + ) } } struct ModelPicker_Previews: PreviewProvider { + @State static var agent: ConversationMode = .defaultAgent + static var previews: some View { - ModelPicker() + ModeAndModelPicker(projectRootURL: nil, selectedAgent: $agent) } } diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift new file mode 100644 index 00000000..683f8091 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift @@ -0,0 +1,372 @@ +import AppKit +import ConversationServiceProvider +import Persist +import SharedUIComponents +import SwiftUI + +// MARK: - Custom NSButton that accepts clicks anywhere within its bounds +class ClickThroughButton: NSButton { + override func hitTest(_ point: NSPoint) -> NSView? { + // If the point is within our bounds, return self (the button) + // This ensures clicks on subviews are handled by the button + if self.bounds.contains(point) { + return self + } + return super.hitTest(point) + } +} + +// MARK: - Agent Mode Button + +struct AgentModeButton: NSViewRepresentable { + @StateObject private var fontScaleManager = FontScaleManager.shared + + private var fontScale: Double { + fontScaleManager.currentScale + } + + let title: String + let isSelected: Bool + let activeBackground: Color + let activeTextColor: Color + let inactiveTextColor: Color + let chatMode: String + let builtInAgentModes: [ConversationMode] + let customAgents: [ConversationMode] + let selectedAgent: ConversationMode + let selectedIconName: String? + let isCustomAgentEnabled: Bool + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + func makeNSView(context: Context) -> NSView { + let containerView = NSView() + containerView.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 + + // Create icon for agent mode + let iconImageView = NSImageView() + iconImageView.translatesAutoresizingMaskIntoConstraints = false + iconImageView.imageScaling = .scaleProportionallyDown + + // Create chevron icon + let chevronView = NSImageView() + let chevronImage = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil) + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 9 * fontScale, weight: .bold) + chevronView.image = chevronImage?.withSymbolConfiguration(symbolConfig) + chevronView.translatesAutoresizingMaskIntoConstraints = false + chevronView.isHidden = !isCustomAgentEnabled + + // Create title label + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + titleLabel.isEditable = false + titleLabel.isBordered = false + titleLabel.backgroundColor = .clear + titleLabel.drawsBackground = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.alignment = .center + titleLabel.usesSingleLineMode = true + titleLabel.lineBreakMode = .byClipping + + // Create horizontal stack with icon, title, and chevron + let stackView = NSStackView(views: [iconImageView, titleLabel, chevronView]) + stackView.orientation = .horizontal + stackView.spacing = 0 + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .centerY + stackView.setHuggingPriority(.required, for: .horizontal) + stackView.setContentCompressionResistancePriority(.required, for: .horizontal) + + // Set custom spacing between title and chevron + stackView.setCustomSpacing(3 * fontScale, after: titleLabel) + + button.addSubview(stackView) + containerView.addSubview(button) + + let stackLeadingConstraint = stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 6 * fontScale) + let stackTrailingConstraint = stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -6 * fontScale) + let stackTopConstraint = stackView.topAnchor.constraint(equalTo: button.topAnchor, constant: 2 * fontScale) + let stackBottomConstraint = stackView.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -2 * fontScale) + let iconWidthConstraint = iconImageView.widthAnchor.constraint(equalToConstant: 16 * fontScale) + let iconHeightConstraint = iconImageView.heightAnchor.constraint(equalToConstant: 16 * fontScale) + let chevronWidthConstraint = chevronView.widthAnchor.constraint(equalToConstant: 9 * fontScale) + let chevronHeightConstraint = chevronView.heightAnchor.constraint(equalToConstant: 9 * fontScale) + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + button.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + button.topAnchor.constraint(equalTo: containerView.topAnchor), + button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + stackLeadingConstraint, + stackTrailingConstraint, + stackTopConstraint, + stackBottomConstraint, + + iconWidthConstraint, + iconHeightConstraint, + + chevronWidthConstraint, + chevronHeightConstraint, + ]) + + context.coordinator.button = button + context.coordinator.titleLabel = titleLabel + context.coordinator.iconImageView = iconImageView + context.coordinator.chevronView = chevronView + context.coordinator.stackView = stackView + context.coordinator.stackLeadingConstraint = stackLeadingConstraint + context.coordinator.stackTrailingConstraint = stackTrailingConstraint + context.coordinator.stackTopConstraint = stackTopConstraint + context.coordinator.stackBottomConstraint = stackBottomConstraint + context.coordinator.iconWidthConstraint = iconWidthConstraint + context.coordinator.iconHeightConstraint = iconHeightConstraint + context.coordinator.chevronWidthConstraint = chevronWidthConstraint + context.coordinator.chevronHeightConstraint = chevronHeightConstraint + + return containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let button = context.coordinator.button, + let titleLabel = context.coordinator.titleLabel, + let iconImageView = context.coordinator.iconImageView, + let chevronView = context.coordinator.chevronView, + let stackView = context.coordinator.stackView else { return } + + titleLabel.stringValue = title + titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + context.coordinator.chatMode = chatMode + context.coordinator.builtInAgentModes = builtInAgentModes + context.coordinator.customAgents = customAgents + context.coordinator.selectedAgent = selectedAgent + context.coordinator.isSelected = isSelected + context.coordinator.isCustomAgentEnabled = isCustomAgentEnabled + context.coordinator.fontScale = fontScale + + // Update constraints for scaling + context.coordinator.stackLeadingConstraint?.constant = 6 * fontScale + context.coordinator.stackTrailingConstraint?.constant = -6 * fontScale + context.coordinator.stackTopConstraint?.constant = 2 * fontScale + context.coordinator.stackBottomConstraint?.constant = -2 * fontScale + context.coordinator.iconWidthConstraint?.constant = 16 * fontScale + context.coordinator.iconHeightConstraint?.constant = 16 * fontScale + context.coordinator.chevronWidthConstraint?.constant = 9 * fontScale + context.coordinator.chevronHeightConstraint?.constant = 9 * fontScale + stackView.spacing = 0 + + // Update custom spacing between title and chevron + stackView.setCustomSpacing(3 * fontScale, after: titleLabel) + + // Update chevron visibility based on feature flag and policy + chevronView.isHidden = !isCustomAgentEnabled + + // Update icon based on selected agent mode + if let iconName = selectedIconName { + iconImageView.isHidden = false + iconImageView.image = createIconImage(named: iconName, pointSize: 16 * fontScale) + } else { + // No icon for custom agents + iconImageView.isHidden = true + iconImageView.image = nil + } + + // Update chevron icon with scaled size + chevronView.image = createSFSymbolImage(named: "chevron.down", pointSize: 9 * fontScale, weight: .bold) + + // Update button appearance based on selection + if isSelected { + button.layer?.backgroundColor = NSColor(activeBackground).cgColor + titleLabel.textColor = NSColor(activeTextColor) + iconImageView.contentTintColor = NSColor(activeTextColor) + chevronView.contentTintColor = NSColor(activeTextColor) + + // Remove existing shadows before adding new ones + button.layer?.shadowOpacity = 0 + + // Add shadows + button.shadow = { + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.05) + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.shadowBlurRadius = 0.375 + return shadow + }() + + // For the second shadow, we can add a sublayer or just use one. + // For simplicity, we will just use one for now. A second shadow can be added with a sublayer if needed. + + // Add overlay + button.layer?.borderColor = NSColor.black.withAlphaComponent(0.02).cgColor + button.layer?.borderWidth = 0.5 + + } else { + button.layer?.backgroundColor = NSColor.clear.cgColor + titleLabel.textColor = NSColor(inactiveTextColor) + iconImageView.contentTintColor = NSColor(inactiveTextColor) + chevronView.contentTintColor = NSColor(inactiveTextColor) + button.shadow = nil + button.layer?.borderColor = NSColor.clear.cgColor + button.layer?.borderWidth = 0 + } + button.wantsLayer = true + button.layer?.cornerRadius = 10 * fontScale + button.layer?.cornerCurve = .continuous + } + + func makeCoordinator() -> Coordinator { + Coordinator( + chatMode: chatMode, + builtInAgentModes: builtInAgentModes, + customAgents: customAgents, + selectedAgent: selectedAgent, + isSelected: isSelected, + isCustomAgentEnabled: isCustomAgentEnabled, + fontScale: fontScale, + onSelectAgent: onSelectAgent, + onEditAgent: onEditAgent, + onDeleteAgent: onDeleteAgent, + onCreateAgent: onCreateAgent + ) + } + + // MARK: - Helper Methods for Image Creation + + /// Creates an icon image - either a custom asset or SF Symbol + private func createIconImage(named iconName: String, pointSize: CGFloat) -> NSImage? { + if iconName == AgentModeIcon.agent { + return createResizedCustomImage(named: iconName, targetSize: pointSize) + } else { + return createSFSymbolImage(named: iconName, pointSize: pointSize, weight: .bold) + } + } + + /// Creates a resized custom image (non-SF Symbol) with template rendering + private func createResizedCustomImage(named imageName: String, targetSize: CGFloat) -> NSImage? { + guard let image = NSImage(named: imageName) else { return nil } + + let size = NSSize(width: targetSize, height: targetSize) + let resizedImage = NSImage(size: size) + resizedImage.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + image.draw( + in: NSRect(origin: .zero, size: size), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + resizedImage.unlockFocus() + resizedImage.isTemplate = true + return resizedImage + } + + /// Creates an SF Symbol image with the specified configuration + private func createSFSymbolImage(named symbolName: String, pointSize: CGFloat, weight: NSFont.Weight) -> NSImage? { + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight) + return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(config) + } + + class Coordinator: NSObject { + var chatMode: String + var builtInAgentModes: [ConversationMode] + var customAgents: [ConversationMode] + var selectedAgent: ConversationMode + var isSelected: Bool + var isCustomAgentEnabled: Bool + var fontScale: Double + var button: NSButton? + var titleLabel: NSTextField? + var iconImageView: NSImageView? + var chevronView: NSImageView? + var stackView: NSStackView? + var stackLeadingConstraint: NSLayoutConstraint? + var stackTrailingConstraint: NSLayoutConstraint? + var stackTopConstraint: NSLayoutConstraint? + var stackBottomConstraint: NSLayoutConstraint? + var iconWidthConstraint: NSLayoutConstraint? + var iconHeightConstraint: NSLayoutConstraint? + var chevronWidthConstraint: NSLayoutConstraint? + var chevronHeightConstraint: NSLayoutConstraint? + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + init( + chatMode: String, + builtInAgentModes: [ConversationMode], + customAgents: [ConversationMode], + selectedAgent: ConversationMode, + isSelected: Bool, + isCustomAgentEnabled: Bool, + fontScale: Double, + onSelectAgent: @escaping (ConversationMode) -> Void, + onEditAgent: @escaping (ConversationMode) -> Void, + onDeleteAgent: @escaping (ConversationMode) -> Void, + onCreateAgent: @escaping () -> Void + ) { + self.chatMode = chatMode + self.builtInAgentModes = builtInAgentModes + self.customAgents = customAgents + self.selectedAgent = selectedAgent + self.isSelected = isSelected + self.isCustomAgentEnabled = isCustomAgentEnabled + self.fontScale = fontScale + self.onSelectAgent = onSelectAgent + self.onEditAgent = onEditAgent + self.onDeleteAgent = onDeleteAgent + self.onCreateAgent = onCreateAgent + } + + @objc func buttonClicked(_ sender: NSButton) { + // If in Ask mode, switch to agent mode + if chatMode == ChatMode.Ask.rawValue { + // Restore the previously selected agent from AppState + let savedSubMode = AppState.shared.getSelectedAgentSubMode() + + // Try to find the saved agent + let agent = builtInAgentModes.first(where: { $0.id == savedSubMode }) + ?? customAgents.first(where: { $0.id == savedSubMode }) + ?? builtInAgentModes.first + + if let agent = agent { + onSelectAgent(agent) + } + } else { + // If in Agent mode and custom agent is enabled, show the menu + // If custom agent is disabled, do nothing + if isCustomAgentEnabled { + showMenu(sender) + } + } + } + + @objc func showMenu(_ sender: NSButton) { + let menuBuilder = AgentModeMenu( + builtInAgentModes: builtInAgentModes, + customAgents: customAgents, + selectedAgent: selectedAgent, + fontScale: fontScale, + onSelectAgent: onSelectAgent, + onEditAgent: onEditAgent, + onDeleteAgent: onDeleteAgent, + onCreateAgent: onCreateAgent + ) + menuBuilder.showMenu(relativeTo: sender) + } + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift new file mode 100644 index 00000000..322bac6d --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift @@ -0,0 +1,522 @@ +import AppKit +import ConversationServiceProvider +import SwiftUI + +// MARK: - Agent Menu Item View + +class AgentModeButtonMenuItem: NSView { + // Layout constants + private let fontScale: Double + + private lazy var scaledConstants = ScaledLayoutConstants(fontScale: fontScale) + + private struct ScaledLayoutConstants { + let fontScale: Double + + var menuHeight: CGFloat { 22 * fontScale } + var checkmarkLeftEdge: CGFloat { 9 * fontScale } + var checkmarkSize: CGFloat { 13 * fontScale } + var iconSize: CGFloat { 16 * fontScale } + var iconTextSpacing: CGFloat { 5 * fontScale } + var checkmarkIconSpacing: CGFloat { 5 * fontScale } + var hoverEdgeInset: CGFloat { 5 * fontScale } + var buttonSpacing: CGFloat { -4 * fontScale } + var deleteButtonRightEdge: CGFloat { 12 * fontScale } + var buttonSize: CGFloat { 24 * fontScale } + var buttonIconSize: CGFloat { 10 * fontScale } + var buttonBackgroundSize: CGFloat { 17 * fontScale } + var buttonBackgroundEdgeInset: CGFloat { 3 * fontScale } + var minWidth: CGFloat { 180 * fontScale } + var maxWidth: CGFloat { 320 * fontScale } + var fontSize: CGFloat { 13 * fontScale } + var fontWeight: NSFont.Weight { .regular } + + // MARK: - Computed Properties for Repeated Calculations + + /// Starting X position for checkmark and icons without selection + var checkmarkStartX: CGFloat { checkmarkLeftEdge } + + /// Starting X position for icons when menu has selection + var iconStartXWithSelection: CGFloat { + checkmarkLeftEdge + checkmarkSize + checkmarkIconSpacing + } + + /// Icon X position based on selection state + func iconX(isSelected: Bool, menuHasSelection: Bool) -> CGFloat { + isSelected || menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge + } + + /// Helper to vertically center an element within the menu height + func centeredY(for elementSize: CGFloat) -> CGFloat { + (menuHeight - elementSize) / 2 + } + + /// Starting X position for label text based on icon presence + func labelStartX(hasIcon: Bool, iconName: String?, isSelected: Bool, menuHasSelection: Bool) -> CGFloat { + if hasIcon { + let iconX: CGFloat + let iconWidth: CGFloat + if iconName == AgentModeIcon.plus { + iconX = checkmarkLeftEdge + iconWidth = checkmarkSize + } else { + iconX = isSelected ? iconStartXWithSelection : (menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge) + iconWidth = iconSize + } + return iconX + iconWidth + iconTextSpacing + } else { + return menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge + } + } + } + + private let name: String + private let iconName: String? + private let isSelected: Bool + private let menuHasSelection: Bool + private let onSelect: () -> Void + private let onEdit: (() -> Void)? + private let onDelete: (() -> Void)? + + private var isHovered = false + private var isEditButtonHovered = false + private var isDeleteButtonHovered = false + private var trackingArea: NSTrackingArea? + + private var hasEditDeleteButtons: Bool { + onEdit != nil && onDelete != nil + } + + private let nameLabel = NSTextField(labelWithString: "") + private let iconImageView = NSImageView() + private let checkmarkImageView = NSImageView() + private let editButton = NSButton() + private let deleteButton = NSButton() + private let editButtonBackground = NSView() + private let deleteButtonBackground = NSView() + + init( + name: String, + iconName: String?, + isSelected: Bool, + menuHasSelection: Bool, + fontScale: Double = 1.0, + fixedWidth: CGFloat? = nil, + onSelect: @escaping () -> Void, + onEdit: (() -> Void)? = nil, + onDelete: (() -> Void)? = nil + ) { + self.name = name + self.iconName = iconName + self.isSelected = isSelected + self.menuHasSelection = menuHasSelection + self.fontScale = fontScale + self.onSelect = onSelect + self.onEdit = onEdit + self.onDelete = onDelete + + // Use fixed width if provided, otherwise calculate dynamically + let calculatedWidth = fixedWidth ?? Self.calculateMenuItemWidth( + name: name, + hasIcon: iconName != nil, + isSelected: isSelected, + menuHasSelection: menuHasSelection, + hasEditDelete: onEdit != nil && onDelete != nil, + fontScale: fontScale + ) + + let constants = ScaledLayoutConstants(fontScale: fontScale) + super.init(frame: NSRect(x: 0, y: 0, width: calculatedWidth, height: constants.menuHeight)) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func calculateMenuItemWidth( + name: String, + hasIcon: Bool, + isSelected: Bool, + menuHasSelection: Bool, + hasEditDelete: Bool, + fontScale: Double = 1.0 + ) -> CGFloat { + // Create scaled constants + let constants = ScaledLayoutConstants(fontScale: fontScale) + + // Calculate text width + let font = NSFont.systemFont(ofSize: constants.fontSize, weight: constants.fontWeight) + let textAttributes = [NSAttributedString.Key.font: font] + let textSize = (name as NSString).size(withAttributes: textAttributes) + + // Calculate label X position using computed property + let iconName = hasIcon ? (name == "Create an agent" ? AgentModeIcon.plus : nil) : nil + let labelX = constants.labelStartX(hasIcon: hasIcon, iconName: iconName, isSelected: isSelected, menuHasSelection: menuHasSelection) + + // Calculate required width + var width = labelX + textSize.width + 10 * fontScale // 10pt padding after text + + if hasEditDelete { + // Add space for edit and delete buttons + width = max(width, labelX + textSize.width + 20 * fontScale) // Ensure some space before buttons + width += (constants.buttonSize * 2) + constants.buttonSpacing + constants.deleteButtonRightEdge + } else { + width += 10 * fontScale // Extra padding for items without buttons + } + + // Clamp to min/max width + return min(max(width, constants.minWidth), constants.maxWidth) + } + + private func setupView() { + wantsLayer = true + layer?.masksToBounds = true + + setupCheckmark() + setupIcon() + setupNameLabel() + + let showEditDeleteButtons = onEdit != nil && onDelete != nil + if showEditDeleteButtons { + setupEditDeleteButtons() + } + + setupTrackingArea() + } + + // MARK: - View Setup Helpers + + private func setupCheckmark() { + let checkmarkConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium) + if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil)? + .withSymbolConfiguration(checkmarkConfig) { + checkmarkImageView.image = image + } + checkmarkImageView.contentTintColor = .labelColor + let checkmarkY = scaledConstants.centeredY(for: scaledConstants.checkmarkSize) + checkmarkImageView.frame = NSRect( + x: scaledConstants.checkmarkStartX, + y: checkmarkY, + width: scaledConstants.checkmarkSize, + height: scaledConstants.checkmarkSize + ) + checkmarkImageView.isHidden = !isSelected + addSubview(checkmarkImageView) + } + + private func setupIcon() { + guard let iconName = iconName else { return } + + if iconName == AgentModeIcon.agent { + setupCustomAgentIcon() + } else if iconName == AgentModeIcon.plus { + setupPlusIcon() + } else { + setupSFSymbolIcon(iconName) + } + + iconImageView.contentTintColor = .labelColor + iconImageView.isHidden = false + + // Calculate and set icon position + let (iconX, iconSize, iconY) = calculateIconPosition(for: iconName) + iconImageView.frame = NSRect(x: iconX, y: iconY, width: iconSize, height: iconSize) + addSubview(iconImageView) + } + + private func setupCustomAgentIcon() { + guard let image = NSImage(named: AgentModeIcon.agent) else { return } + + let targetSize = NSSize(width: scaledConstants.iconSize, height: scaledConstants.iconSize) + let resizedImage = NSImage(size: targetSize) + resizedImage.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + image.draw( + in: NSRect(origin: .zero, size: targetSize), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + resizedImage.unlockFocus() + resizedImage.isTemplate = true + iconImageView.image = resizedImage + } + + private func setupPlusIcon() { + let plusConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium) + if let image = NSImage(systemSymbolName: AgentModeIcon.plus, accessibilityDescription: nil) { + iconImageView.image = image.withSymbolConfiguration(plusConfig) + } + } + + private func setupSFSymbolIcon(_ iconName: String) { + let symbolConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.iconSize, weight: .medium) + if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { + iconImageView.image = image.withSymbolConfiguration(symbolConfig) + } + } + + private func calculateIconPosition(for iconName: String) -> (x: CGFloat, size: CGFloat, y: CGFloat) { + if iconName == AgentModeIcon.plus { + let size = scaledConstants.checkmarkSize + return ( + scaledConstants.checkmarkStartX, + size, + scaledConstants.centeredY(for: size) + ) + } else { + let size = scaledConstants.iconSize + return ( + scaledConstants.iconX(isSelected: isSelected, menuHasSelection: menuHasSelection), + size, + scaledConstants.centeredY(for: size) + ) + } + } + + private func setupNameLabel() { + let labelX = scaledConstants.labelStartX( + hasIcon: iconName != nil, + iconName: iconName, + isSelected: isSelected, + menuHasSelection: menuHasSelection + ) + + nameLabel.stringValue = name + nameLabel.font = NSFont.systemFont(ofSize: scaledConstants.fontSize, weight: scaledConstants.fontWeight) + nameLabel.textColor = .labelColor + nameLabel.frame = NSRect(x: labelX, y: 3 * fontScale, width: 160 * fontScale, height: 16 * fontScale) + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.backgroundColor = .clear + nameLabel.drawsBackground = false + addSubview(nameLabel) + } + + private func setupEditDeleteButtons() { + let viewWidth = frame.width + let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium) + + // Calculate button positions from the right edge + let deleteButtonX = viewWidth - scaledConstants.deleteButtonRightEdge - scaledConstants.buttonSize + let editButtonX = deleteButtonX - scaledConstants.buttonSpacing - scaledConstants.buttonSize + let backgroundY = (frame.height - scaledConstants.buttonBackgroundSize) / 2 + + // Setup edit button and background + setupEditButton(at: editButtonX, backgroundY: backgroundY, config: buttonIconConfig) + + // Setup delete button and background + setupDeleteButton(at: deleteButtonX, backgroundY: backgroundY, config: buttonIconConfig) + } + + private func setupButtonWithBackground( + button: NSButton, + background: NSView, + at x: CGFloat, + backgroundY: CGFloat, + iconName: String, + accessibilityDescription: String, + action: Selector, + config: NSImage.SymbolConfiguration + ) { + // Setup background + let backgroundX = x + scaledConstants.buttonBackgroundEdgeInset + background.wantsLayer = true + background.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.15).cgColor + background.layer?.cornerRadius = scaledConstants.buttonBackgroundSize / 2 + background.frame = NSRect( + x: backgroundX, + y: backgroundY, + width: scaledConstants.buttonBackgroundSize, + height: scaledConstants.buttonBackgroundSize + ) + background.isHidden = true + addSubview(background) + + // Setup button + button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: accessibilityDescription)?.withSymbolConfiguration(config) + button.bezelStyle = .roundRect + button.isBordered = false + button.frame = NSRect( + x: x, + y: scaledConstants.centeredY(for: scaledConstants.buttonSize), + width: scaledConstants.buttonSize, + height: scaledConstants.buttonSize + ) + button.target = self + button.action = action + button.isHidden = true + button.alphaValue = 1.0 + addSubview(button) + } + + private func setupEditButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) { + setupButtonWithBackground( + button: editButton, + background: editButtonBackground, + at: x, + backgroundY: backgroundY, + iconName: "pencil", + accessibilityDescription: "Edit", + action: #selector(editTapped), + config: config + ) + } + + private func setupDeleteButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) { + setupButtonWithBackground( + button: deleteButton, + background: deleteButtonBackground, + at: x, + backgroundY: backgroundY, + iconName: "trash", + accessibilityDescription: "Delete", + action: #selector(deleteTapped), + config: config + ) + } + + private func setupTrackingArea() { + // Use .zero rect with .inVisibleRect to automatically track the visible bounds + // This avoids accessing bounds during layout cycles + trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .mouseMoved, .activeInActiveApp, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea!) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + updateButtonVisibility() + updateColors() + needsDisplay = true + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + isEditButtonHovered = false + isDeleteButtonHovered = false + updateButtonVisibility() + editButtonBackground.isHidden = true + deleteButtonBackground.isHidden = true + updateColors() + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + let location = convert(event.locationInWindow, from: nil) + + if hasEditDeleteButtons { + if editButton.frame.contains(location) || deleteButton.frame.contains(location) { + return + } + } + + onSelect() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea = trackingArea { + removeTrackingArea(trackingArea) + } + setupTrackingArea() + } + + private func updateButtonVisibility() { + if hasEditDeleteButtons { + editButton.isHidden = !isHovered + deleteButton.isHidden = !isHovered + } + } + + private func updateColors() { + if isHovered { + nameLabel.textColor = .white + iconImageView.contentTintColor = .white + checkmarkImageView.contentTintColor = .white + if hasEditDeleteButtons { + editButton.contentTintColor = .white + deleteButton.contentTintColor = .white + } + } else { + nameLabel.textColor = .labelColor + iconImageView.contentTintColor = .labelColor + checkmarkImageView.contentTintColor = .labelColor + if hasEditDeleteButtons { + editButton.contentTintColor = nil + deleteButton.contentTintColor = nil + } + } + } + + @objc private func editTapped() { + onEdit?() + } + + @objc private func deleteTapped() { + onDelete?() + } + + override func draw(_ dirtyRect: NSRect) { + 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() + } + } + + override func mouseMoved(with event: NSEvent) { + guard hasEditDeleteButtons else { return } + + let location = convert(event.locationInWindow, from: nil) + + if editButton.frame.contains(location) && !editButton.isHidden { + updateButtonHoverState(editHovered: true, deleteHovered: false, trashFilled: false) + } else if deleteButton.frame.contains(location) && !deleteButton.isHidden { + updateButtonHoverState(editHovered: false, deleteHovered: true, trashFilled: true) + } else { + updateButtonHoverState(editHovered: false, deleteHovered: false, trashFilled: false) + } + + if isHovered { + editButton.contentTintColor = .white + deleteButton.contentTintColor = .white + } + } + + private func updateButtonHoverState(editHovered: Bool, deleteHovered: Bool, trashFilled: Bool) { + isEditButtonHovered = editHovered + isDeleteButtonHovered = deleteHovered + editButtonBackground.isHidden = !editHovered + deleteButtonBackground.isHidden = !deleteHovered + + let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium) + let trashIcon = trashFilled ? "trash.fill" : "trash" + deleteButton.image = NSImage(systemSymbolName: trashIcon, accessibilityDescription: "Delete")?.withSymbolConfiguration(buttonIconConfig) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift new file mode 100644 index 00000000..3461a0f4 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - Agent Mode Icon Constants + +enum AgentModeIcon { + /// Icon for Plan mode (SF Symbol: checklist) + static let plan = "checklist" + + /// Icon for Agent mode (Custom asset: Agent) + static let agent = "Agent" + + /// Icon for create/add actions (SF Symbol: plus) + static let plus = "plus" + + /// Returns the appropriate icon name for a given agent mode name + /// - Parameter modeName: The name of the agent mode + /// - Returns: The icon name to use, or nil for custom agents + static func icon(for modeName: String) -> String { + return modeName.lowercased() == "plan" ? plan : agent + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift new file mode 100644 index 00000000..81e76aef --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift @@ -0,0 +1,165 @@ +import AppKit +import ConversationServiceProvider + +// MARK: - Agent Mode Menu Builder + +struct AgentModeMenu { + let builtInAgentModes: [ConversationMode] + let customAgents: [ConversationMode] + let selectedAgent: ConversationMode + let fontScale: Double + let onSelectAgent: (ConversationMode) -> Void + let onEditAgent: (ConversationMode) -> Void + let onDeleteAgent: (ConversationMode) -> Void + let onCreateAgent: () -> Void + + func createMenu() -> NSMenu { + let menu = NSMenu() + + let menuHasSelection = true // Always show checkmarks for clarity + + // Calculate the maximum width needed across all items + let maxWidth = calculateMaxMenuItemWidth(menuHasSelection: menuHasSelection) + + // Add built-in agent modes + addBuiltInModes(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + + // Add custom agents if any + if !customAgents.isEmpty { + menu.addItem(.separator()) + addCustomAgents(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + } + + // Add create option + menu.addItem(.separator()) + addCreateOption(to: menu, menuHasSelection: menuHasSelection, width: maxWidth) + + return menu + } + + private func calculateMaxMenuItemWidth(menuHasSelection: Bool) -> CGFloat { + var maxWidth: CGFloat = 0 + + // Check built-in modes + for mode in builtInAgentModes { + let width = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: mode.name, + hasIcon: true, + isSelected: selectedAgent.id == mode.id, + menuHasSelection: menuHasSelection, + hasEditDelete: false, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + // Check custom agents + for agent in customAgents { + let width = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: agent.name, + hasIcon: false, + isSelected: selectedAgent.id == agent.id, + menuHasSelection: menuHasSelection, + hasEditDelete: true, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + // Check create option + let createWidth = AgentModeButtonMenuItem.calculateMenuItemWidth( + name: "Create an agent", + hasIcon: true, + isSelected: false, + menuHasSelection: menuHasSelection, + hasEditDelete: false, + fontScale: fontScale + ) + maxWidth = max(maxWidth, createWidth) + + return maxWidth + } + + private func addBuiltInModes(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + for mode in builtInAgentModes { + let agentItem = NSMenuItem() + // Determine icon: use checklist for Plan, Agent icon for others + let iconName = AgentModeIcon.icon(for: mode.name) + let agentView = AgentModeButtonMenuItem( + name: mode.name, + iconName: iconName, + isSelected: selectedAgent.id == mode.id, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onSelectAgent] in + onSelectAgent(mode) + menu.cancelTracking() + } + ) + agentView.toolTip = mode.description + agentItem.view = agentView + menu.addItem(agentItem) + } + } + + private func addCustomAgents(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + for agent in customAgents { + let agentItem = NSMenuItem() + agentItem.representedObject = agent + + // Create custom view for the menu item + let customView = AgentModeButtonMenuItem( + name: agent.name, + iconName: nil, + isSelected: selectedAgent.id == agent.id, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onSelectAgent] in + onSelectAgent(agent) + menu.cancelTracking() + }, + onEdit: { [onEditAgent] in + onEditAgent(agent) + menu.cancelTracking() + }, + onDelete: { [onDeleteAgent] in + onDeleteAgent(agent) + menu.cancelTracking() + } + ) + + customView.toolTip = agent.description + agentItem.view = customView + menu.addItem(agentItem) + } + } + + private func addCreateOption(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) { + let createItem = NSMenuItem() + let createView = AgentModeButtonMenuItem( + name: "Create an agent", + iconName: AgentModeIcon.plus, + isSelected: false, + menuHasSelection: menuHasSelection, + fontScale: fontScale, + fixedWidth: width, + onSelect: { [onCreateAgent] in + onCreateAgent() + menu.cancelTracking() + } + ) + createItem.view = createView + menu.addItem(createItem) + } + + func showMenu(relativeTo button: NSButton) { + let menu = createMenu() + + // Show menu aligned to the button's edge, positioned below the button + let buttonFrame = button.frame + let menuOrigin = NSPoint(x: buttonFrame.minX, y: buttonFrame.maxY) + menu.popUp(positioning: menu.items.first, at: menuOrigin, in: button.superview) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift new file mode 100644 index 00000000..97268560 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift @@ -0,0 +1,304 @@ +import AppKit +import AppKitExtension +import ChatService +import Combine +import ConversationServiceProvider +import GitHubCopilotService +import Persist +import SharedUIComponents +import SwiftUI +import SystemUtils +import Workspace +import XcodeInspector + +public extension Notification.Name { + static let gitHubCopilotChatModeDidChange = Notification + .Name("com.github.CopilotForXcode.ChatModeDidChange") +} + +public struct ChatModePicker: View { + @Binding var chatMode: String + @Binding var selectedAgent: ConversationMode + + 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] = [] + @State private var customAgents: [ConversationMode] = [] + @State private var isCreateSheetPresented = false + @State private var agentToDelete: ConversationMode? + @State private var showDeleteConfirmation = false + var onScopeChange: (PromptTemplateScope, String?) -> Void + + public init( + projectRootURL: URL?, + chatMode: Binding, + selectedAgent: Binding, + onScopeChange: @escaping (PromptTemplateScope, String?) -> Void = { _, _ in } + ) { + _chatMode = chatMode + _selectedAgent = selectedAgent + self.projectRootURL = projectRootURL + self.onScopeChange = onScopeChange + isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode + isEditorPreviewFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + isCustomAgentPolicyEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled + } + + private func setAskMode() { + chatMode = ChatMode.Ask.rawValue + AppState.shared.setSelectedChatMode(ChatMode.Ask.rawValue) + onScopeChange(.chatPanel, nil) + NotificationCenter.default.post( + name: .gitHubCopilotChatModeDidChange, + object: nil + ) + } + + private func setAgentMode(_ agent: ConversationMode) { + chatMode = ChatMode.Agent.rawValue + selectedAgent = agent + AppState.shared.setSelectedChatMode(ChatMode.Agent.rawValue) + AppState.shared.setSelectedAgentSubMode(agent.id) + + // Load agents if switching from Ask mode + Task { + await loadCustomAgentsAsync() + } + onScopeChange(.agentPanel, agent.model) + NotificationCenter.default.post( + name: .gitHubCopilotChatModeDidChange, + object: nil + ) + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in + isAgentModeFFEnabled = featureFlags.agentMode + isEditorPreviewFFEnabled = featureFlags.editorPreviewFeatures + }) + .store(in: &cancellables) + } + + private func subscribeToPolicyDidChangeEvent() { + CopilotPolicyNotifierImpl.shared.policyDidChange.sink(receiveValue: { policy in + isCustomAgentPolicyEnabled = policy.customAgentEnabled + }) + .store(in: &cancellables) + } + + private func loadCustomAgents() { + Task { + await loadCustomAgentsAsync() + + // Only restore if we're in Agent mode + if chatMode == ChatMode.Agent.rawValue { + loadSelectedAgentSubMode() + } + } + } + + private func loadCustomAgentsAsync() async { + guard let modes = await SharedChatService.shared.loadConversationModes() else { + // Fallback: create default built-in modes when server returns nil + builtInAgents = [.defaultAgent] + customAgents = [] + return + } + + // Filter built-in modes (exclude Edit) + builtInAgents = modes.filter { $0.isBuiltIn && $0.kind == .Agent } + + // Filter for custom agent modes (non-built-in) + customAgents = modes.filter { !$0.isBuiltIn && $0.kind == .Agent } + } + + private func deleteCustomAgent(_ agent: ConversationMode) { + agentToDelete = agent + showDeleteConfirmation = true + } + + private func performDelete() { + guard let agent = agentToDelete, + let uriString = agent.uri, + let fileURL = URL(string: uriString) else { + return + } + + do { + try FileManager.default.removeItem(at: fileURL) + loadCustomAgents() + } catch { + // Error handling + } + agentToDelete = nil + } + + private func openAgentFileInXcode(_ agent: ConversationMode) { + guard let uriString = agent.uri, let fileURL = URL(string: uriString) else { + return + } + + NSWorkspace.openFileInXcode(fileURL: fileURL) + } + + private func createNewAgent() { + isCreateSheetPresented = true + } + + private var displayName: String { + return selectedAgent.name + } + + private var displayIconName: String? { + // Custom agents don't have icons + if !selectedAgent.isBuiltIn { + return nil + } + // Use checklist icon for Plan, Agent icon for others + return AgentModeIcon.icon(for: selectedAgent.name) + } + + public var body: some View { + VStack { + if isAgentModeFFEnabled { + HStack(spacing: -1) { + ModeButton( + title: "Ask", + isSelected: chatMode == ChatMode.Ask.rawValue, + activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, + activeTextColor: Color.primary, + inactiveTextColor: Color.primary.opacity(0.5), + action: { + setAskMode() + } + ) + + AgentModeButton( + title: displayName, + isSelected: chatMode == ChatMode.Agent.rawValue, + activeBackground: Color.accentColor, + activeTextColor: Color.white, + inactiveTextColor: Color.primary.opacity(0.5), + chatMode: chatMode, + builtInAgentModes: builtInAgents, + customAgents: customAgents, + selectedAgent: selectedAgent, + selectedIconName: displayIconName, + isCustomAgentEnabled: isEditorPreviewFFEnabled && isCustomAgentPolicyEnabled, + onSelectAgent: { setAgentMode($0) }, + onEditAgent: { openAgentFileInXcode($0) }, + onDeleteAgent: { deleteCustomAgent($0) }, + onCreateAgent: { createNewAgent() } + ) + } + .scaledPadding(1) + .scaledFrame(height: 22, alignment: .topLeading) + .background(.primary.opacity(0.1)) + .cornerRadius(16) + .padding(4) + .help("Set Agent") + } else { + EmptyView() + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + subscribeToPolicyDidChangeEvent() + await loadCustomAgentsAsync() + loadSelectedAgentSubMode() + if !isAgentModeFFEnabled { + setAskMode() + } + } + .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in + if !newAgentModeFFEnabled { + 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 { + let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + setAgentMode(defaultAgent) + } + } + // Minimal refresh: when app becomes active (e.g. user returns from editing an agent file in Xcode) + // Reload custom agents to pick up external changes without adding complex file monitoring. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + loadCustomAgents() + } + .onChange(of: selectedAgent) { newAgent in + // When selectedAgent changes externally (e.g., from handoff), + // call setAgentMode to trigger all side effects + // Guard: only trigger if we're not already in the correct state to avoid redundant work + guard chatMode != ChatMode.Agent.rawValue || + AppState.shared.getSelectedAgentSubMode() != newAgent.id else { + return + } + setAgentMode(newAgent) + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + promptType: .agent, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { projectRootURL }, + onSuccess: { _ in + loadCustomAgents() + }, + onError: { _ in + // Handle error silently or log it + } + ) + } + .confirmationDialog( + // `agentToDelete` should always be non-nil, adding fallback for compilation safety + "Are you sure you want to delete '\(agentToDelete?.name ?? "Agent")'?", + isPresented: $showDeleteConfirmation + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { performDelete() } + } + } + + private func loadSelectedAgentSubMode() { + let subMode = AppState.shared.getSelectedAgentSubMode() + + // 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) { + selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + AppState.shared.setSelectedAgentSubMode("Agent") + return + } + selectedAgent = agent + return + } + + // Default to Agent mode if nothing matches + selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent + } + + private func findAgent(byId id: String) -> ConversationMode? { + // Check built-in agents first + if let builtIn = builtInAgents.first(where: { $0.id == id }) { + return builtIn + } + // Check custom agents + if let custom = customAgents.first(where: { $0.id == id }) { + return custom + } + return nil + } +} diff --git a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift similarity index 89% rename from Core/Sources/ConversationTab/ModelPicker/ModeButton.swift rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift index 53106ba2..7964d448 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift @@ -12,13 +12,13 @@ public struct ModeButton: View { public var body: some View { Button(action: action) { Text(title) - .scaledFont(.body) + .scaledFont(size: 12) .scaledPadding(.horizontal, 6) - .scaledPadding(.vertical, 0) + .scaledPadding(.vertical, 2) .frame(maxHeight: .infinity, alignment: .center) .background(isSelected ? activeBackground : Color.clear) .foregroundColor(isSelected ? activeTextColor : inactiveTextColor) - .cornerRadius(5) + .cornerRadius(16) .shadow(color: .black.opacity(0.05), radius: 0.375, x: 0, y: 1) .shadow(color: .black.opacity(0.15), radius: 0.125, x: 0, y: 0.25) .overlay( diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift similarity index 83% rename from Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift index 92af4af9..53eeeb6e 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift @@ -6,6 +6,7 @@ import ConversationServiceProvider public let SELECTED_LLM_KEY = "selectedLLM" public let SELECTED_CHATMODE_KEY = "selectedChatMode" +public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode" public extension Notification.Name { static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange") @@ -25,7 +26,8 @@ public extension AppState { } guard let modelName = savedModel["modelName"]?.stringValue, - let modelFamily = savedModel["modelFamily"]?.stringValue else { + let modelFamily = savedModel["modelFamily"]?.stringValue, + let id = savedModel["id"]?.stringValue else { return nil } @@ -47,6 +49,7 @@ public extension AppState { displayName: displayName, modelName: modelName, modelFamily: modelFamily, + id: id, billing: billing, providerName: providerName, supportVision: supportVision @@ -55,7 +58,9 @@ public extension AppState { func setSelectedModel(_ model: LLMModel) { update(key: SELECTED_LLM_KEY, value: model) - NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil) + } } func modelScope() -> PromptTemplateScope { @@ -79,6 +84,19 @@ public extension AppState { func isAgentModeEnabled() -> Bool { return getSelectedChatMode() == "Agent" } + + func getSelectedAgentSubMode() -> String { + if let savedSubMode = get(key: SELECTED_AGENT_SUBMODE_KEY), + let subMode = savedSubMode.stringValue { + return subMode + } + // Default to "Agent" + return "Agent" + } + + func setSelectedAgentSubMode(_ subMode: String) { + update(key: SELECTED_AGENT_SUBMODE_KEY, value: subMode) + } private func convertChatMode(_ mode: String) -> String { switch mode { @@ -133,7 +151,8 @@ public class CopilotModelManagerObservable: ObservableObject { AppState.shared.setSelectedModel( .init( modelName: fallbackModel.modelName, - modelFamily: fallbackModel.id, + modelFamily: fallbackModel.modelFamily, + id: fallbackModel.id, billing: fallbackModel.billing, supportVision: fallbackModel.capabilities.supports.vision ) @@ -154,6 +173,7 @@ public extension CopilotModelManager { return LLMModel( modelName: $0.modelName, modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, + id: $0.id, billing: $0.billing, supportVision: $0.capabilities.supports.vision ) @@ -163,12 +183,13 @@ 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 }) + let defaultModel = LLMsInScope.first(where: { $0.isChatDefault && !$0.isAutoModel }) // If a default model is found, return it if let defaultModel = defaultModel { return LLMModel( modelName: defaultModel.modelName, modelFamily: defaultModel.modelFamily, + id: defaultModel.id, billing: defaultModel.billing, supportVision: defaultModel.capabilities.supports.vision ) @@ -180,16 +201,18 @@ public extension CopilotModelManager { return LLMModel( modelName: gpt4_1.modelName, modelFamily: gpt4_1.modelFamily, + id: gpt4_1.id, billing: gpt4_1.billing, supportVision: gpt4_1.capabilities.supports.vision ) } // If no default model is found, fallback to the first available model - if let firstModel = LLMsInScope.first { + if let firstModel = LLMsInScope.first(where: { !$0.isAutoModel }) { return LLMModel( modelName: firstModel.modelName, modelFamily: firstModel.modelFamily, + id: firstModel.id, billing: firstModel.billing, supportVision: firstModel.capabilities.supports.vision ) @@ -213,6 +236,7 @@ public extension BYOKModelManager { displayName: $0.modelCapabilities?.name, modelName: $0.modelId, modelFamily: $0.modelId, + id: $0.modelId, billing: nil, providerName: $0.providerName.rawValue, supportVision: $0.modelCapabilities?.vision ?? false @@ -222,17 +246,19 @@ public extension BYOKModelManager { } public struct LLMModel: Codable, Hashable, Equatable { - let displayName: String? - let modelName: String - let modelFamily: String - let billing: CopilotModelBilling? - let providerName: String? - let supportVision: Bool + public let displayName: String? + public let modelName: String + public let modelFamily: String + public let id: String + public let billing: CopilotModelBilling? + public let providerName: String? + public let supportVision: Bool public init( displayName: String? = nil, modelName: String, modelFamily: String, + id: String, billing: CopilotModelBilling?, providerName: String? = nil, supportVision: Bool @@ -240,14 +266,22 @@ public struct LLMModel: Codable, Hashable, Equatable { self.displayName = displayName self.modelName = modelName self.modelFamily = modelFamily + self.id = id self.billing = billing self.providerName = providerName self.supportVision = supportVision } } -public struct ScopeCache { - var modelMultiplierCache: [String: String] = [:] - var cachedMaxWidth: CGFloat = 0 - var lastModelsHash: Int = 0 +public extension LLMModel { + /// Apply to `Copilot Models` + var isPremiumModel: Bool { billing?.isPremium == true } + /// Apply to `Copilot Models` + var isStandardModel: Bool { !isPremiumModel || billing == nil } + /// Apply to `Copilot Models` + var isAutoModel: Bool { isStandardModel && modelName == "Auto" } +} + +extension CopilotModel { + var isAutoModel: Bool { modelName == "Auto" } } diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift new file mode 100644 index 00000000..7b32efc8 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift @@ -0,0 +1,87 @@ +import AppKit +import Foundation + +public struct ScopeCache { + var modelMultiplierCache: [String: String] = [:] + var cachedMaxWidth: CGFloat = 0 + var lastModelsHash: Int = 0 +} + +// MARK: - Model Menu Item Formatting +public struct ModelMenuItemFormatter { + public static let minimumPadding: Int = 48 + + public static let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + + public static var spaceWidth: CGFloat { + "\u{200A}".size(withAttributes: attributes).width + } + + public static var minimumPaddingWidth: CGFloat { + spaceWidth * CGFloat(minimumPadding) + } + + /// Creates an attributed string for model menu items with proper spacing and formatting + public static func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + multiplierText: String, + targetWidth: CGFloat? = nil + ) -> AttributedString { + let displayName = isSelected ? "โœ“ \(modelName)" : " \(modelName)" + + var fullString = displayName + var attributedString = AttributedString(fullString) + + if !multiplierText.isEmpty { + let displayNameWidth = displayName.size(withAttributes: attributes).width + let multiplierTextWidth = multiplierText.size(withAttributes: attributes).width + + // Calculate padding needed + let neededPaddingWidth: CGFloat + + if let targetWidth = targetWidth { + neededPaddingWidth = targetWidth - displayNameWidth - multiplierTextWidth + } else { + neededPaddingWidth = minimumPaddingWidth + } + + let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth) + let numberOfSpaces = Int(round(finalPaddingWidth / spaceWidth)) + let padding = String(repeating: "\u{200A}", count: max(minimumPadding, numberOfSpaces)) + fullString = "\(displayName)\(padding)\(multiplierText)" + + attributedString = AttributedString(fullString) + + if let range = attributedString.range( + of: multiplierText, + options: .backwards + ) { + attributedString[range].foregroundColor = .secondary + } + } + + return attributedString + } + + /// Gets the multiplier text for a model (e.g., "2x", "Included", provider name, or "Variable") + public static func getMultiplierText(for model: LLMModel) -> String { + if model.isAutoModel { + return "Variable" + } else if let billing = model.billing { + let multiplier = billing.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" + } + } else if let providerName = model.providerName, !providerName.isEmpty { + return providerName + } else { + return "" + } + } +} diff --git a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift deleted file mode 100644 index 44d8c8cc..00000000 --- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift +++ /dev/null @@ -1,96 +0,0 @@ -import SwiftUI -import Persist -import ConversationServiceProvider -import GitHubCopilotService -import Combine -import SharedUIComponents - -public extension Notification.Name { - static let gitHubCopilotChatModeDidChange = Notification - .Name("com.github.CopilotForXcode.ChatModeDidChange") -} - -public enum ChatMode: String { - case Ask = "Ask" - case Agent = "Agent" -} - -public struct ChatModePicker: View { - @Binding var chatMode: String - @Environment(\.colorScheme) var colorScheme - @State var isAgentModeFFEnabled: Bool - @State private var cancellables = Set() - var onScopeChange: (PromptTemplateScope) -> Void - - public init(chatMode: Binding, onScopeChange: @escaping (PromptTemplateScope) -> Void = { _ in }) { - self._chatMode = chatMode - self.onScopeChange = onScopeChange - self.isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode - } - - private func setChatMode(mode: ChatMode) { - chatMode = mode.rawValue - AppState.shared.setSelectedChatMode(mode.rawValue) - onScopeChange(mode == .Ask ? .chatPanel : .agentPanel) - NotificationCenter.default.post( - name: .gitHubCopilotChatModeDidChange, - object: nil - ) - } - - private func subscribeToFeatureFlagsDidChangeEvent() { - FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in - isAgentModeFFEnabled = featureFlags.agentMode - }) - .store(in: &cancellables) - } - - public var body: some View { - VStack { - if isAgentModeFFEnabled { - HStack(spacing: -1) { - ModeButton( - title: "Ask", - isSelected: chatMode == "Ask", - activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white, - activeTextColor: Color.primary, - inactiveTextColor: Color.primary.opacity(0.5), - action: { - setChatMode(mode: .Ask) - } - ) - - ModeButton( - title: "Agent", - isSelected: chatMode == "Agent", - activeBackground: Color.blue, - activeTextColor: Color.white, - inactiveTextColor: Color.primary.opacity(0.5), - action: { - setChatMode(mode: .Agent) - } - ) - } - .scaledPadding(1) - .scaledFrame(height: 20, alignment: .topLeading) - .background(.primary.opacity(0.1)) - .cornerRadius(5) - .padding(4) - .help("Set Mode") - } else { - EmptyView() - } - } - .task { - subscribeToFeatureFlagsDidChangeEvent() - if !isAgentModeFFEnabled { - setChatMode(mode: .Ask) - } - } - .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in - if !newAgentModeFFEnabled { - setChatMode(mode: .Ask) - } - } - } -} diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift index f2309324..eca980d5 100644 --- a/Core/Sources/ConversationTab/Styles.swift +++ b/Core/Sources/ConversationTab/Styles.swift @@ -36,6 +36,7 @@ extension NSAppearance { extension View { var messageBubbleCornerRadius: Double { 8 } var hoverableImageCornerRadius: Double { 4 } + var inputAreaTextEditorCornerRadius: Double { 12 } func codeBlockLabelStyle() -> some View { relativeLineSpacing(.em(0.225)) @@ -200,7 +201,7 @@ extension View { struct CodeReviewCardBackground: View { var body: some View { - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: 12) .stroke(.black.opacity(0.17), lineWidth: 1) .background(Color.gray.opacity(0.05)) } @@ -208,7 +209,7 @@ struct CodeReviewCardBackground: View { struct CodeReviewHeaderBackground: View { var body: some View { - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: 12) .stroke(.black.opacity(0.17), lineWidth: 1) .background(Color.gray.opacity(0.1)) } diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index 5d57b03d..7ac6ef3c 100644 --- a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift +++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift @@ -1,9 +1,11 @@ -import SwiftUI -import XcodeInspector -import ConversationServiceProvider +import ChatService import ComposableArchitecture -import Terminal +import ConversationServiceProvider +import GitHubCopilotService import SharedUIComponents +import SwiftUI +import Terminal +import XcodeInspector struct RunInTerminalToolView: View { let tool: AgentToolCall @@ -23,7 +25,10 @@ struct RunInTerminalToolView: View { init(tool: AgentToolCall, chat: StoreOf) { self.tool = tool self.chat = chat - if let input = tool.invokeParams?.input as? [String: AnyCodable] { + + let input = (tool.invokeParams?.input as? [String: AnyCodable]) ?? tool.input + + if let input { self.command = input["command"]?.value as? String self.explanation = input["explanation"]?.value as? String self.isBackground = input["isBackground"]?.value as? Bool @@ -82,7 +87,7 @@ struct RunInTerminalToolView: View { toolView } - .padding(8) + .scaledPadding(8) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) @@ -121,10 +126,12 @@ struct RunInTerminalToolView: View { .scaledFrame(width: 16, height: 16) Text(command!) + .lineLimit(nil) .textSelection(.enabled) .scaledFont(size: chatFontSize, design: .monospaced) - .padding(8) + .scaledPadding(8) .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) .foregroundStyle(codeForegroundColor) .background(codeBackgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -141,27 +148,214 @@ struct RunInTerminalToolView: View { terminalSession: terminalSession, onTerminalInput: terminalSession.handleTerminalInput ) - .frame(minHeight: 200, maxHeight: 400) + .scaledFrame(minHeight: 200, maxHeight: 400) } else if tool.status == .waitForConfirmation { ThemedMarkdownText(text: explanation ?? "", chat: chat) .frame(maxWidth: .infinity, alignment: .leading) HStack { - Button("Cancel") { + Button(action: { chat.send(.toolCallCancelled(tool.id)) + }) { + Text("Skip") + .scaledFont(.body) } - .scaledFont(.body) - Button("Continue") { - chat.send(.toolCallAccepted(tool.id)) + if #available(macOS 13.0, *), + FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && + CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled, + let command, !command.isEmpty { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: terminalMenuItems(command: command), + style: .prominent + ) + } else { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Allow") + .scaledFont(.body) + } + .buttonStyle(BorderedProminentButtonStyle()) } - .scaledFont(.body) - .buttonStyle(BorderedProminentButtonStyle()) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) + .scaledPadding(.top, 4) + } + } + } + } + + @available(macOS 13.0, *) + private func terminalMenuItems(command: String) -> [SplitButtonMenuItem] { + var items: [SplitButtonMenuItem] = [] + + let subCommands = ToolAutoApprovalManager.extractSubCommandsWithTreeSitter(command) + let commandNames = extractCommandNamesForMenu(subCommands) + let commandNamesLabel = formatCommandNameListForMenu(commandNames) + + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedSubCommands = subCommands + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + let shouldShowExactCommandLineItems = !( + trimmedSubCommands.count == 1 && + trimmedSubCommands[0] == trimmedCommand && + commandNames.contains(trimmedCommand) + ) + + let conversationId = tool.invokeParams?.conversationId ?? "" + let hasConversationId = !conversationId.isEmpty + + // Session-scoped + if hasConversationId, !commandNames.isEmpty { + items.append( + SplitButtonMenuItem(title: sessionAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .session(conversationId), + commands: commandNames + ) + ) + ) + } + ) + } + + // Global + if !commandNames.isEmpty { + items.append( + SplitButtonMenuItem(title: alwaysAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .global, + commands: commandNames + ) + ) + ) + } + ) + } + + items.append(.divider()) + + if shouldShowExactCommandLineItems { + // Session-scoped exact command line + if hasConversationId { + items.append( + SplitButtonMenuItem(title: "Allow Exact Command Line in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .session(conversationId), + commands: [command] + ) + ) + ) + } + ) + } + + // Global exact command line + items.append( + SplitButtonMenuItem(title: "Always Allow Exact Command Line") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .global, + commands: [command] + ) + ) + ) } + ) + + items.append(.divider()) + } + + // Session-scoped allow all + if hasConversationId { + items.append( + SplitButtonMenuItem(title: "Allow All Commands in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .terminal( + scope: .session(conversationId), + commands: [] + ) + ) + ) + } + ) + } + + items.append(.divider()) + items.append( + SplitButtonMenuItem(title: "Configure Auto Approve...") { + chat.send(.openAutoApproveSettings) } + ) + + return items + } + + private func formatSubCommandListForMenu(_ subCommands: [String]) -> String { + let trimmed = subCommands.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return "(none)" } + return trimmed.joined(separator: ", ") + } + + private func extractCommandNamesForMenu(_ subCommands: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + + for subCommand in subCommands { + guard let name = ToolAutoApprovalManager.extractTerminalCommandName(fromSubCommand: subCommand) else { + continue + } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard !seen.contains(trimmed) else { continue } + seen.insert(trimmed) + result.append(trimmed) + } + + return result + } + + private func formatCommandNameListForMenu(_ commandNames: [String]) -> String { + let trimmed = commandNames.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return "(none)" } + + func suffixEllipsis(_ name: String) -> String { "`\(name) ...`" } + + return trimmed.map(suffixEllipsis).joined(separator: ", ") + } + + private func sessionAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String { + if commandCount == 1 { + return "Allow \(commandNamesLabel) in this Session" + } + return "Allow Commands \(commandNamesLabel) in this Session" + } + + private func alwaysAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String { + if commandCount == 1 { + return "Always Allow \(commandNamesLabel)" } + return "Always Allow Commands \(commandNamesLabel)" } } diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift index 912c9687..181f3cbd 100644 --- a/Core/Sources/ConversationTab/ViewExtension.swift +++ b/Core/Sources/ConversationTab/ViewExtension.swift @@ -1,4 +1,5 @@ import SwiftUI +import ComposableArchitecture let ITEM_SELECTED_COLOR = Color("ItemSelectedColor") @@ -82,4 +83,58 @@ extension View { public func hoverSecondaryForeground(isHovered: Bool) -> some View { self.hoverForeground(isHovered: isHovered, defaultColor: .secondary) } + + // MARK: - Editor Mode + + /// Dims the view when in edit mode and provides tap/keyboard exit functionality + /// - Parameters: + /// - chat: The chat store + /// - messageId: Optional message ID to determine if this specific message should be dimmed + /// - isDimmed: Whether this view should be dimmed (defaults to true when editing affects this view) + /// - allowTapToExit: Whether tapping on this view should exit edit mode (defaults to true) + func dimWithExitEditMode( + _ chat: StoreOf, + applyTo messageId: String? = nil, + isDimmed: Bool? = nil, + allowTapToExit: Bool = true + ) -> some View { + let editUserMessageEffectedMessageIds = chat.editUserMessageEffectedMessages.map { $0.id } + let shouldDim = isDimmed ?? { + guard chat.editorMode.isEditingUserMessage else { return false } + guard let messageId else { return true } + return editUserMessageEffectedMessageIds.contains(messageId) + }() + + let isInEditMode = chat.editorMode.isEditingUserMessage + let shouldAllowTapExit = allowTapToExit && isInEditMode + + return self + .opacity(shouldDim && isInEditMode ? 0.5 : 1) + .overlay( + Group { + if shouldAllowTapExit { + Color.clear + .contentShape(Rectangle()) // Ensure the entire area is tappable + .onTapGesture { + if shouldAllowTapExit { + chat.send(.setEditorMode(.input)) + } + } + } + } + ) + .background( + // Global escape key handler - only add once per view hierarchy + Group { + if isInEditMode { + Button("") { + chat.send(.setEditorMode(.input)) + } + .keyboardShortcut(.escape, modifiers: []) + .opacity(0) + .accessibilityHidden(true) + } + } + ) + } } diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 98a6173a..c690a208 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -7,61 +7,33 @@ import SwiftUI import ConversationServiceProvider import ChatTab import ChatAPIService +import HostAppActivator struct BotMessage: View { var r: Double { messageBubbleCornerRadius } - let id: String - let text: String - let references: [ConversationReference] - let followUp: ConversationFollowUp? - let errorMessages: [String] + let message: DisplayedChatMessage let chat: StoreOf - let steps: [ConversationProgressStep] - let editAgentRounds: [AgentRound] - let panelMessages: [CopilotShowMessageParams] - let codeReviewRound: CodeReviewRound? + var id: String { + message.id + } + var text: String { message.text } + var references: [ConversationReference] { message.references } + var followUp: ConversationFollowUp? { message.followUp } + var errorMessages: [String] { message.errorMessages } + var steps: [ConversationProgressStep] { message.steps } + var editAgentRounds: [AgentRound] { message.editAgentRounds } + var panelMessages: [CopilotShowMessageParams] { message.panelMessages } + var codeReviewRound: CodeReviewRound? { message.codeReviewRound } @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize - @State var isReferencesPresented = false - - struct ResponseToolBar: View { - let id: String - let chat: StoreOf - let text: String - - var body: some View { - HStack(spacing: 4) { - - UpvoteButton { rating in - chat.send(.upvote(id, rating)) - } - - DownvoteButton { rating in - chat.send(.downvote(id, rating)) - } - - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - chat.send(.copyCode(id)) - } - - Spacer() // Pushes the buttons to the left - } - } - } + @State var isHovering = false struct ReferenceButton: View { - var r: Double { messageBubbleCornerRadius } let references: [ConversationReference] let chat: StoreOf - @Binding var isReferencesPresented: Bool - - @State var isReferencesHovered = false - @AppStorage(\.chatFontSize) var chatFontSize func MakeReferenceTitle(references: [ConversationReference]) -> String { @@ -75,154 +47,188 @@ struct BotMessage: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { - Button(action: { - isReferencesPresented.toggle() - }, label: { - HStack(spacing: 4) { - Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right") - .resizable() - .scaledToFit() - .scaledFrame(width: 14, height: 14) - - Text(MakeReferenceTitle(references: references)) - .scaledFont(size: chatFontSize) - } - .background { - RoundedRectangle(cornerRadius: r - 4) - .fill(isReferencesHovered ? Color.gray.opacity(0.1) : Color.clear) - } - .foregroundStyle(.secondary) - }) - .buttonStyle(HoverButtonStyle()) - .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand") + let files = references.map { $0.filePath } + let fileHelpTexts = Dictionary(uniqueKeysWithValues: references.compactMap { reference in + guard reference.url != nil else { return nil } + return (reference.filePath, reference.getPathRelativeToHome()) + }) + let progressMessage = Text(MakeReferenceTitle(references: references)) + .foregroundStyle(.secondary) + + HStack(spacing: 0) { + ExpandableFileListView( + progressMessage: progressMessage, + files: files, + chatFontSize: chatFontSize, + helpText: "View referenced files", + onFileClick: { filePath in + if let reference = references.first(where: { $0.filePath == filePath }) { + chat.send(.referenceClicked(reference)) + } + }, + fileHelpTexts: fileHelpTexts + ) - if isReferencesPresented { - ReferenceList(references: references, chat: chat) - .background( - RoundedRectangle(cornerRadius: 5) - .stroke(Color.gray, lineWidth: 0.2) - ) - } + Spacer() } } } - - private var agentWorkingStatus: some View { - HStack(spacing: 4) { - ProgressView() - .controlSize(.small) - .frame(width: 20, height: 16) - .scaleEffect(0.7) - - Text("Working...") - .scaledFont(size: chatFontSize) - .foregroundColor(.secondary) - } - } var body: some View { - HStack { - VStack(alignment: .leading, spacing: 8) { - CopilotMessageHeader() - - if !references.isEmpty { - WithPerceptionTracking { - ReferenceButton( - references: references, - chat: chat, - isReferencesPresented: $isReferencesPresented - ) + WithPerceptionTracking { + HStack { + VStack(alignment: .leading, spacing: 8) { + if !references.isEmpty { + WithPerceptionTracking { + ReferenceButton( + references: references, + chat: chat + ) + } } - } - - // progress step - if steps.count > 0 { - ProgressStep(steps: steps) - } - - if !panelMessages.isEmpty { - WithPerceptionTracking { - ForEach(panelMessages.indices, id: \.self) { index in - FunctionMessage(text: panelMessages[index].message, chat: chat) + + // progress step + if steps.count > 0 { + ProgressStep(steps: steps) + + } + + if !panelMessages.isEmpty { + WithPerceptionTracking { + ForEach(panelMessages.indices, id: \.self) { index in + FunctionMessage(text: panelMessages[index].message, chat: chat) + } } } - } - - if editAgentRounds.count > 0 { - ProgressAgentRound(rounds: editAgentRounds, chat: chat) - } - - if !text.isEmpty { - ThemedMarkdownText(text: text, chat: chat) - } - - if let codeReviewRound = codeReviewRound { - CodeReviewMainView( - store: chat, round: codeReviewRound - ) - } + + if editAgentRounds.count > 0 { + ProgressAgentRound(rounds: editAgentRounds, chat: chat) + } + + if !text.isEmpty { + Group{ + ThemedMarkdownText(text: text, chat: chat) + } + .scaledPadding(.leading, 2) + .scaledPadding(.vertical, 4) + } + + if let codeReviewRound = codeReviewRound { + CodeReviewMainView( + store: chat, round: codeReviewRound + ) + .frame(maxWidth: .infinity) + } + + if !errorMessages.isEmpty { + buildErrorMessageView() + } - if !errorMessages.isEmpty { - VStack(spacing: 4) { - ForEach(errorMessages.indices, id: \.self) { index in - if let attributedString = try? AttributedString(markdown: errorMessages[index]) { - NotificationBanner(style: .warning) { - Text(attributedString) + HStack { + if shouldShowTurnStatus() { + TurnStatusView(message: message) + .modify { view in + if message.turnStatus == .inProgress { + view + .scaledPadding(.leading, 6) + } else { + view + } } - } } + + Spacer() + + ResponseToolBar( + id: id, + chat: chat, + text: text, + message: message + ) + .conditionalFontWeight(.medium) + .opacity(shouldShowToolBar() ? 1 : 0) + .scaledPadding(.trailing, -20) } } - - if shouldShowWorkingStatus() { - agentWorkingStatus + .padding(.leading, message.parentTurnId != nil ? 4 : 0) + .shadow(color: .black.opacity(0.05), radius: 6) + .contextMenu { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .scaledFont(.body) + + Button("Set as Extra System Prompt") { + chat.send(.setAsExtraPromptButtonTapped(id)) + } + .scaledFont(.body) + + Divider() + + Button("Delete") { + chat.send(.deleteMessageButtonTapped(id)) + } + .scaledFont(.body) } - - if shouldShowToolBar() { - ResponseToolBar(id: id, chat: chat, text: text) + .onHover { + isHovering = $0 } } - .shadow(color: .black.opacity(0.05), radius: 6) - .contextMenu { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - .scaledFont(.body) - - Button("Set as Extra System Prompt") { - chat.send(.setAsExtraPromptButtonTapped(id)) - } - .scaledFont(.body) - - Divider() - - Button("Delete") { - chat.send(.deleteMessageButtonTapped(id)) + } + } + + @ViewBuilder + private func buildErrorMessageView() -> some View { + VStack(spacing: 4) { + ForEach(errorMessages.indices, id: \.self) { index in + if let attributedString = try? AttributedString(markdown: errorMessages[index]) { + NotificationBanner(style: .warning) { + VStack(alignment: .leading, spacing: 4) { + Text(attributedString) + + if isSettingsActionableError(errorMessages[index]) { + Button(action: { + Task { + try? launchHostAppAdvancedSettings() + } + }) { + Text("Open Settings") + } + .buttonStyle(.link) + } + } + } } - .scaledFont(.body) } } + .scaledPadding(.vertical, 4) } - private func shouldShowWorkingStatus() -> Bool { - let hasRunningStep: Bool = steps.contains(where: { $0.status == .running }) - let hasRunningRound: Bool = editAgentRounds.contains(where: { round in - return round.toolCalls?.contains(where: { $0.status == .running }) ?? false - }) - - if hasRunningStep || hasRunningRound { + private func isSettingsActionableError(_ message: String) -> Bool { + message == HardCodedToolRoundExceedErrorMessage || + message == SSLCertificateErrorMessage + } + + private func shouldShowTurnStatus() -> Bool { + guard isLatestAssistantMessage() else { return false } - // Only show working status for the current bot message being received - return chat.isReceivingMessage && isLatestAssistantMessage() + if steps.isEmpty && editAgentRounds.isEmpty { + return true + } + + if !steps.isEmpty { + return !message.text.isEmpty + } + + return true } private func shouldShowToolBar() -> Bool { // Always show toolbar for historical messages - if !isLatestAssistantMessage() { return true } + if !isLatestAssistantMessage() { return isHovering } // For current message, only show toolbar when message is complete return !chat.isReceivingMessage @@ -234,74 +240,71 @@ struct BotMessage: View { } } -struct ReferenceList: View { - let references: [ConversationReference] - let chat: StoreOf - - private let maxVisibleItems: Int = 6 - @State private var itemHeight: CGFloat = 16 +private struct TurnStatusView: View { + + let message: DisplayedChatMessage @AppStorage(\.chatFontSize) var chatFontSize - struct ReferenceView: View { - let references: [ConversationReference] - let chat: StoreOf - @AppStorage(\.chatFontSize) var chatFontSize - @Binding var itemHeight: CGFloat - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(0.. some View { + HStack(spacing: 4) { + Image(systemName: icon) + .scaledFont(size: chatFontSize) + .foregroundColor(iconColor) + .conditionalFontWeight(.medium) + + Text(text) + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + } } } @@ -334,72 +337,34 @@ struct BotMessage_Previews: PreviewProvider { static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") BotMessage( - id: "1", - text: """ - **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? - ```swift - func foo() {} - ``` - """, - references: .init(repeating: .init( - uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", - status: .included, - kind: .class, - referenceType: .file - ), count: 2), - followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), - errorMessages: ["Sorry, an error occurred while generating a response."], + message: .init( + id: "1", + role: .assistant, + text: """ + **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? + ```swift + func foo() {} + ``` + """, + references: .init( + repeating: .init( + uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", + status: .included, + kind: .class, + referenceType: .file), + count: 2 + ), + followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), + errorMessages: ["Sorry, an error occurred while generating a response."], + steps: steps, + editAgentRounds: agentRounds, + panelMessages: [], + codeReviewRound: nil, + requestType: .conversation + ), chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), - steps: steps, - editAgentRounds: agentRounds, - panelMessages: [], - codeReviewRound: nil ) .padding() .fixedSize(horizontal: true, vertical: true) } } - -struct ReferenceList_Previews: PreviewProvider { - static var previews: some View { - let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") - ReferenceList(references: [ - .init( - uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", - status: .included, - kind: .class, - referenceType: .file - ), - .init( - uri: "/Core/Sources/ConversationTab/Views", - status: .included, - kind: .struct, - referenceType: .file - ), - .init( - uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", - status: .included, - kind: .function, - referenceType: .file - ), - .init( - uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", - status: .included, - kind: .case, - referenceType: .file - ), - .init( - uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", - status: .included, - kind: .extension, - referenceType: .file - ), - .init( - uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", - status: .included, - kind: .webpage, - referenceType: .file - ), - ], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) - } -} diff --git a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift new file mode 100644 index 00000000..108f2f64 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift @@ -0,0 +1,65 @@ +import SwiftUI +import ComposableArchitecture +import SharedUIComponents + +struct ResponseToolBar: View { + let id: String + let chat: StoreOf + let text: String + let message: DisplayedChatMessage + @AppStorage(\.chatFontSize) var chatFontSize + + var billingMultiplier: String? { + guard let multiplier = message.billingMultiplier else { + return nil + } + let rounded = (multiplier * 100).rounded() / 100 + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + formatter.numberStyle = .decimal + let formattedMultiplier = formatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)" + return "\(formattedMultiplier)x" + } + + var modelNameAndMultiplierText: String? { + guard let modelName = message.modelName else { + return nil + } + + var text = modelName + + if let billingMultiplier = billingMultiplier { + text += " โ€ข \(billingMultiplier)" + } + + return text + } + + var body: some View { + HStack(spacing: 8) { + + if let modelNameAndMultiplierText = modelNameAndMultiplierText { + Text(modelNameAndMultiplierText) + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + .foregroundColor(.secondary) + .help(modelNameAndMultiplierText) + } + + UpvoteButton { rating in + chat.send(.upvote(id, rating)) + } + + DownvoteButton { rating in + chat.send(.downvote(id, rating)) + } + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + chat.send(.copyCode(id)) + } + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift new file mode 100644 index 00000000..251f4022 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift @@ -0,0 +1,41 @@ +import SwiftUI +import ComposableArchitecture + +struct ChatPanelInputArea: View { + let chat: StoreOf + let r: Double + let editorMode: Chat.EditorMode + @FocusState var focusedField: Chat.State.Field? + + var body: some View { + HStack { + InputAreaTextEditor(chat: chat, r: r, focusedField: $focusedField, editorMode: editorMode) + } + .background(Color.clear) + } + + @MainActor + var clearButton: some 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) + } + } + .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/InputAreaTextEditor.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift new file mode 100644 index 00000000..88373b42 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift @@ -0,0 +1,667 @@ +import ChatService +import ComposableArchitecture +import Combine +import ConversationServiceProvider +import SwiftUIFlowLayout +import GitHubCopilotService +import GitHubCopilotViewModel +import LanguageServerProtocol +import Preferences +import SharedUIComponents +import Status +import SwiftUI +import Workspace +import XcodeInspector + +enum ShowingType { case template, agent } + +struct InputAreaTextEditor: View { + @Perception.Bindable var chat: StoreOf + let r: Double + var focusedField: FocusState.Binding + let editorMode: Chat.EditorMode + @State var cancellable = Set() + @State private var isFilePickerPresented = false + @State private var allFiles: [ConversationAttachedReference]? = nil + @State private var filteredTemplates: [ChatTemplate] = [] + @State private var filteredAgent: [ChatAgent] = [] + @State private var showingTemplates = false + @State private var dropDownShowingType: ShowingType? = nil + @State private var textEditorState: TextEditorState? = nil + + @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool + @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value( + for: \.enableCurrentEditorContext + ) + @ObservedObject private var status: StatusObserver = .shared + @State private var isCCRFFEnabled: Bool + @State private var isCCRHovering: Bool = false + @State private var cancellables = Set() + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + init( + chat: StoreOf, + r: Double, + focusedField: FocusState.Binding, + editorMode: Chat.EditorMode + ) { + self.chat = chat + self.r = r + self.focusedField = focusedField + self.editorMode = editorMode + self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr + } + + var isEditorActive: Bool { + editorMode == chat.editorMode + } + + var isRequestingConversation: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .conversation { + return true + } + return false + } + + var isRequestingCodeReview: Bool { + if chat.isReceivingMessage, + let requestType = chat.requestType, + requestType == .codeReview { + return true + } + + return false + } + + var projectRootURL: URL? { + WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: chat.workspaceURL, + documentURL: chat.state.currentEditor?.url + ) + } + + var body: some View { + WithPerceptionTracking { + let typedMessage = chat.state.getChatContext(of: editorMode).typedMessage + VStack(spacing: 0) { + chatContextView + + if isFilePickerPresented { + FilePicker( + allFiles: $allFiles, + workspaceURL: chat.workspaceURL, + onSubmit: { ref in + chat.send(.addReference(ref)) + }, + onExit: { + isFilePickerPresented = false + focusedField.wrappedValue = .textField + } + ) + .onAppear() { + allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL) + } + } + + if !chat.state.attachedImages.isEmpty { + ImagesScrollView(chat: chat, editorMode: editorMode) + } + + ZStack(alignment: .topLeading) { + if typedMessage.isEmpty { + Group { + chat.isAgentMode ? + Text("Edit files in your workspace in agent mode") : + Text("Ask Copilot or type / for commands") + } + .scaledFont(size: 14) + .foregroundColor(Color(nsColor: .placeholderTextColor)) + .padding(8) + .padding(.horizontal, 4) + } + + HStack(spacing: 0) { + AutoresizingCustomTextEditor( + text: Binding( + get: { typedMessage }, + set: { newValue in chat.send(.updateTypedMessage(newValue)) } + ), + font: .systemFont(ofSize: 14 * fontScale), + isEditable: true, + maxHeight: 400, + onSubmit: { + if (dropDownShowingType == nil) { + submitChatMessage() + } + dropDownShowingType = nil + }, + onTextEditorStateChanged: { (state: TextEditorState?) in + DispatchQueue.main.async { + textEditorState = state + } + } + ) + .focused(focusedField, equals: isEditorActive ? .textField : nil) + .bind($chat.focusedField, to: focusedField) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: typedMessage) { newValue in + Task { + await onTypedMessageChanged(newValue: newValue) + } + } + /// When chat mode changed, the chat tamplate and agent need to be reloaded + .onChange(of: chat.isAgentMode) { _ in + guard isEditorActive else { return } + Task { + await onTypedMessageChanged(newValue: typedMessage) + } + } + } + .frame(maxWidth: .infinity) + } + .padding(.top, 4) + + HStack(spacing: 0) { + ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent) + + Spacer() + + if chat.editorMode.isDefault { + codeReviewButton + .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .clear)) + .opacity(isRequestingConversation ? 0 : 1) + } + + ZStack { + sendButton + .opacity(isRequestingConversation || isRequestingCodeReview ? 0 : 1) + .foregroundColor( + typedMessage.isEmpty ? Color(nsColor: .tertiaryLabelColor) : Color( + "IconStrokeColor" + ) + ) + .disabled(typedMessage.isEmpty) + + stopButton + .opacity(isRequestingConversation || isRequestingCodeReview ? 1 : 0) + .foregroundColor(Color("IconStrokeColor")) + } + .buttonStyle( + HoverButtonStyle( + padding: 0, + hoverColor: Color(nsColor: .quaternaryLabelColor), + backgroundColor: Color(nsColor: .quinaryLabel), + cornerRadius: .infinity + ) + ) + } + .padding(8) + .padding(.top, -4) + } + .overlay(alignment: .top) { + dropdownOverlay + } + .onAppear() { + guard editorMode.isDefault else { return } + subscribeToActiveDocumentChangeEvent() + // Check quota for CCR + Task { + if status.quotaInfo == nil, + let service = try? GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() { + _ = try? await service.checkQuota() + } + } + } + .task { + subscribeToFeatureFlagsDidChangeEvent() + } + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(.quaternary, lineWidth: 1) + } + .background { + if isEditorActive { + Button(action: { + chat.send(.returnButtonTapped) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + .accessibilityHidden(true) + + Button(action: { + focusedField.wrappedValue = .textField + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + .accessibilityHidden(true) + + buildReloadContextButtons() + } + } + + } + } + + private var reloadNextContextButton: some View { + Button(action: { + chat.send(.reloadNextContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.downArrow, modifiers: []) + .accessibilityHidden(true) + } + + private var reloadPreviousContextButton: some View { + Button(action: { + chat.send(.reloadPreviousContext) + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.upArrow, modifiers: []) + .accessibilityHidden(true) + } + + @ViewBuilder + private func buildReloadContextButtons() -> some View { + if let textEditorState = textEditorState { + switch textEditorState { + case .empty, .singleLine: + ZStack { + reloadPreviousContextButton + reloadNextContextButton + } + case .multipleLines(let cursorAt): + switch cursorAt { + case .first: + reloadPreviousContextButton + case .last: + reloadNextContextButton + case .middle: + EmptyView() + } + } + } else { + EmptyView() + } + } + + private var sendButton: some View { + Button(action: { + submitChatMessage() + }) { + Image(systemName: "paperplane") + .scaledFont(size: 12, weight: .medium) + .padding(.leading, 5) + .padding(.trailing, 6) + .padding(.top, 6.5) + .padding(.bottom, 5.5) + } + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + .help("Send") + } + + private var stopButton: some View { + Button(action: { + chat.send(.stopRespondingButtonTapped) + }) { + Image(systemName: "stop.fill") + .scaledFont(size: 12, weight: .medium) + .padding(8) + } + .keyboardShortcut(KeyEquivalent.escape, modifiers: []) + .help("Stop") + } + + private var isFreeUser: Bool { + guard let quotaInfo = status.quotaInfo else { return true } + + return quotaInfo.isFreeUser + } + + private var ccrDisabledTooltip: String { + if !isCCRFFEnabled { + return "GitHub Copilot Code Review is disabled by org policy. Contact your admin." + } + + return "GitHub Copilot Code Review is temporarily unavailable." + } + + var codeReviewIcon: some View { + Image("codeReview") + .resizable() + .scaledToFit() + .scaledFrame(width: 14, height: 14) + .padding(6) + } + + private var codeReviewButton: some View { + Group { + if isFreeUser { + // Show nothing + } else if isCCRFFEnabled { + Menu { + Button(action: { + chat.send(.codeReview(.request(.index))) + }) { + Text("Review Staged Changes") + } + + Button(action: { + chat.send(.codeReview(.request(.workingTree))) + }) { + Text("Review Unstaged Changes") + } + } label: { + codeReviewIcon + .foregroundColor(isCCRHovering ? .primary : Color("IconStrokeColor")) + } + .scaledFont(.body) + .onHover { hovering in + isCCRHovering = hovering + } + .opacity(isRequestingCodeReview ? 0 : 1) + .help("Code Review") + } else { + codeReviewIcon + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + .help(ccrDisabledTooltip) + } + } + } + + private func subscribeToFeatureFlagsDidChangeEvent() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { isCCRFFEnabled = $0.ccr }) + .store(in: &cancellables) + } + + private var dropdownOverlay: some View { + Group { + if dropDownShowingType != nil { + if dropDownShowingType == .template { + ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in + chat.send(.updateTypedMessage("/" + template.id + " ")) + if template.id == "releaseNotes" { + submitChatMessage() + } + } + } else if dropDownShowingType == .agent { + ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in + chat.send(.updateTypedMessage("@" + agent.id + " ")) + } + } + } + } + } + + func onTypedMessageChanged(newValue: String) async { + guard chat.editorMode.isDefault else { return } + if newValue.hasPrefix("/") { + filteredTemplates = await chatTemplateCompletion(text: newValue) + dropDownShowingType = filteredTemplates.isEmpty ? nil : .template + } else if newValue.hasPrefix("@") && !chat.isAgentMode { + filteredAgent = await chatAgentCompletion(text: newValue) + dropDownShowingType = filteredAgent.isEmpty ? nil : .agent + } else { + dropDownShowingType = nil + } + } + + enum ChatContextButtonType { case imageAttach, contextAttach} + + private var chatContextView: some View { + let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach] + // Always use the latest current editor from state + let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap { + $0 + } + let references = chat.state.getChatContext(of: editorMode).attachedReferences + let chatContextItems: [Any] = buttonItems.map { + $0 as ChatContextButtonType + } + currentEditorItem + references + return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in + if let buttonType = item as? ChatContextButtonType { + if buttonType == .imageAttach { + VisionMenuView(chat: chat) + } else if buttonType == .contextAttach { + // File picker button + Button(action: { + withAnimation { + isFilePickerPresented.toggle() + if !isFilePickerPresented { + focusedField.wrappedValue = .textField + } + } + }) { + Image(systemName: "paperclip") + .resizable() + .aspectRatio(contentMode: .fill) + .scaledFrame(width: 16, height: 16) + .scaledPadding(4) + .foregroundColor(.primary.opacity(0.85)) + .scaledFont(size: 11, weight: .semibold) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .help("Add Context") + .cornerRadius(6) + } + } else if let select = item as? ConversationFileReference, select.isCurrentEditor { + makeCurrentEditorView(select) + } else if let select = item as? ConversationAttachedReference { + makeReferenceItemView(select) + } + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + + @ViewBuilder + func makeCurrentEditorView(_ ref: ConversationFileReference) -> some View { + let toggleTrailingPadding: CGFloat = { + if #available(macOS 26.0, *) { + return 8 + } else { + return 4 + } + }() + + HStack(alignment: .center, spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection) + + Toggle("", isOn: $isCurrentEditorContextEnabled) + .toggleStyle(SwitchToggleStyle(tint: .blue)) + .controlSize(.mini) + .frame(width: 34) + .padding(.trailing, toggleTrailingPadding) + .onChange(of: isCurrentEditorContextEnabled) { newValue in + enableCurrentEditorContext = newValue + } + } + .chatContextReferenceStyle(isCurrentEditor: true, r: r) + } + + @ViewBuilder + func makeReferenceItemView(_ ref: ConversationAttachedReference) -> some View { + HStack(spacing: 0) { + makeContextFileNameView(url: ref.url, isCurrentEditor: false, isDirectory: ref.isDirectory) + + Button(action: { chat.send(.removeReference(ref)) }) { + Image(systemName: "xmark") + .resizable() + .scaledFrame(width: 8, height: 8) + .foregroundColor(.primary.opacity(0.85)) + .padding(4) + } + .buttonStyle(HoverButtonStyle()) + } + .chatContextReferenceStyle(isCurrentEditor: false, r: r) + } + + @ViewBuilder + func makeContextFileNameView( + url: URL, + isCurrentEditor: Bool, + isDirectory: Bool = false, + selection: LSPRange? = nil + ) -> some View { + drawFileIcon(url, isDirectory: isDirectory) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.85)) + .padding(4) + .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) + + HStack(spacing: 0) { + Text(url.lastPathComponent) + + Group { + if isCurrentEditor, let selection { + let startLine = selection.start.line + let endLine = selection.end.line + if startLine == endLine { + Text(String(format: ":%d", selection.start.line + 1)) + } else { + Text(String(format: ":%d-%d", selection.start.line + 1, selection.end.line + 1)) + } + } + } + .foregroundColor(.secondary) + } + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor( + isCurrentEditor && !isCurrentEditorContextEnabled + ? .secondary + : .primary.opacity(0.85) + ) + .scaledFont(.body) + .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0) + .help(url.getPathRelativeToHome()) + } + + func chatTemplateCompletion(text: String) async -> [ChatTemplate] { + guard text.count >= 1 && text.first == "/" else { return [] } + + let prefix = String(text.dropFirst()).lowercased() + let promptTemplates: [ChatTemplate] = await SharedChatService.shared.loadChatTemplates() ?? [] + let releaseNotesTemplate: ChatTemplate = .init( + id: "releaseNotes", + description: "What's New", + shortDescription: "What's New", + scopes: [.chatPanel, .agentPanel] + ) + + let templates = promptTemplates + [releaseNotesTemplate] + let skippedTemplates = [ "feedback", "help" ] + + return templates.filter { + $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) && + $0.id.lowercased().hasPrefix(prefix) && + !skippedTemplates.contains($0.id) + } + } + + func chatAgentCompletion(text: String) async -> [ChatAgent] { + guard text.count >= 1 && text.first == "@" else { return [] } + let prefix = text.dropFirst() + var chatAgents = await SharedChatService.shared.loadChatAgents() ?? [] + + if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) { + let projectAgent = chatAgents[index] + chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl) + } + + /// only enable the @workspace + let includedAgents = ["workspace"] + + return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) } + } + + func subscribeToActiveDocumentChangeEvent() { + var task: Task? + var currentFocusedEditor: SourceEditor? + + Publishers.CombineLatest3( + XcodeInspector.shared.$latestActiveXcode, + XcodeInspector.shared.$activeDocumentURL + .removeDuplicates(), + XcodeInspector.shared.$focusedEditor + .removeDuplicates() + ) + .receive(on: DispatchQueue.main) + .sink { newXcode, newDocURL, newFocusedEditor in + var currentEditor: ConversationFileReference? + + // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil + if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil { + if supportedFileExtensions.contains(realtimeURL.pathExtension) { + currentEditor = ConversationFileReference(url: realtimeURL, isCurrentEditor: true) + } + } else if let docURL = newDocURL, supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") { + currentEditor = ConversationFileReference(url: docURL, isCurrentEditor: true) + } + + if var currentEditor = currentEditor { + if let selection = newFocusedEditor?.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) + } + + if currentFocusedEditor != newFocusedEditor { + task?.cancel() + task = nil + currentFocusedEditor = newFocusedEditor + + if let editor = currentFocusedEditor { + task = Task { @MainActor in + for await _ in await editor.axNotifications.notifications() + .filter({ $0.kind == .selectedTextChanged }) { + handleSourceEditorSelectionChanged(editor) + } + } + } + } + } + .store(in: &cancellable) + } + + private func handleSourceEditorSelectionChanged(_ sourceEditor: SourceEditor) { + guard let fileURL = sourceEditor.realtimeDocumentURL, + let currentEditorURL = chat.currentEditor?.url, + fileURL == currentEditorURL + else { + return + } + + var currentEditor: ConversationFileReference = .init(url: fileURL, isCurrentEditor: true) + + if let selection = sourceEditor.getContent().selections.first, + selection.start != selection.end { + currentEditor.selection = .init(start: selection.start, end: selection.end) + } + + chat.send(.setCurrentEditor(currentEditor)) + } + + func submitChatMessage() { + chat.send(.sendButtonTapped(UUID().uuidString)) + } +} diff --git a/Core/Sources/ConversationTab/Views/CheckPoint.swift b/Core/Sources/ConversationTab/Views/CheckPoint.swift new file mode 100644 index 00000000..5c6e4a86 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/CheckPoint.swift @@ -0,0 +1,238 @@ +import SwiftUI +import ComposableArchitecture +import SharedUIComponents +import AppKit + +struct CheckPoint: View { + let chat: StoreOf + let messageId: String + + @State private var isHovering: Bool = false + @State private var window: NSWindow? + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.suppressRestoreCheckpointConfirmation) var suppressRestoreCheckpointConfirmation + @Environment(\.colorScheme) var colorScheme + + private var isPendingCheckpoint: Bool { + chat.pendingCheckpointMessageId == messageId + } + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 4) { + checkpointIcon + + checkpointLine + .overlay(alignment: .leading) { + checkpointContent + } + } + .scaledFrame(height: chatFontSize) + .onHover { isHovering = $0 } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .background(WindowAccessor { window in + // Store window reference for later use + self.window = window + }) + } + } + + var checkpointIcon: some View { + Image(systemName: "bookmark") + .resizable() + .scaledToFit() + .scaledFrame(width: chatFontSize, height: chatFontSize) + .foregroundStyle(.secondary) + } + + var checkpointLine: some View { + DashedLine() + .stroke(style: StrokeStyle(dash: [3])) + .foregroundStyle(.gray) + .scaledFrame(height: 1) + } + + @ViewBuilder + var checkpointContent: some View { + HStack(spacing: 12) { + if isPendingCheckpoint { + HStack(spacing: 12) { + undoButton + + Text("Checkpoint Restored") + .scaledFont(size: chatFontSize) + .foregroundStyle(.secondary) + .scaledPadding(.horizontal, 2) + .background(Color.chatWindowBackgroundColor) + } + } else if isHovering { + restoreButton + .transition(.opacity.combined(with: .move(edge: .leading))) + } + + Spacer() + } + } + + var hasSubsequentFileEdit: Bool { + for message in chat.state.getMessages(after: messageId, through: chat.pendingCheckpointMessageId) { + if !message.fileEdits.isEmpty { + return true + } + } + + return false + } + + var restoreButton: some View { + ActionButton( + title: "Restore Checkpoint", + helpText: "Restore workspace and chat to this point", + action: { + if !suppressRestoreCheckpointConfirmation && hasSubsequentFileEdit { + showRestoreAlert() + } else { + handleRestore() + } + } + ) + } + + func handleRestore() { + Task { @MainActor in + await chat.send(.restoreCheckPoint(messageId)).finish() + } + } + + var undoButton: some View { + ActionButton( + title: "Undo", + helpText: "Reapply discarded workspace changes and chat", + action: { + Task { @MainActor in + await chat.send(.undoCheckPoint).finish() + } + } + ) + } + + var accessibilityLabel: String { + if isPendingCheckpoint { + "Checkpoint restored. Tap to redo changes." + } else { + "Checkpoint. Tap to restore to this point." + } + } + + func showRestoreAlert() { + let alert = NSAlert() + alert.messageText = "Restore Checkpoint" + alert.informativeText = "This will remove all subsequent requests and edits. Do you want to proceed?" + + alert.addButton(withTitle: "Restore") + alert.addButton(withTitle: "Cancel") + + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Don't ask again" + + alert.alertStyle = .warning + + let targetWindow = window ?? NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first { + $0.isVisible + } + + if let targetWindow = targetWindow { + alert.beginSheetModal(for: targetWindow) { response in + self.handleAlertResponse(response, alert: alert) + } + } else { + let response = alert.runModal() + handleAlertResponse(response, alert: alert) + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, alert: NSAlert) { + if response == .alertFirstButtonReturn { + handleRestore() + } + + suppressRestoreCheckpointConfirmation = alert.suppressionButton?.state == .on + } +} + +private struct ActionButton: View { + let title: String + let helpText: String + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @AppStorage(\.chatFontSize) private var chatFontSize + + private var adaptiveTextColor: Color { + colorScheme == .light ? .black.opacity(0.75) : .white.opacity(0.75) + } + + var body: some View { + Button(action: action) { + Text(title) + .scaledFont(.footnote) + .scaledPadding(4) + .foregroundStyle(adaptiveTextColor) + } + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.gray, lineWidth: 0.5) + ) + ) + .buttonStyle(HoverButtonStyle(padding: 0)) + .scaledPadding(.leading, 8) + .help(helpText) + .accessibilityLabel(title) + .accessibilityHint(helpText) + } +} + +private struct DashedLine: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + return path + } +} + +struct WindowAccessor: NSViewRepresentable { + var callback: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + return WindowTrackingView(callback: callback) + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let windowTrackingView = nsView as? WindowTrackingView { + windowTrackingView.callback = callback + } + } +} + +private class WindowTrackingView: NSView { + var callback: (NSWindow?) -> Void + + init(callback: @escaping (NSWindow?) -> Void) { + self.callback = callback + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + callback(window) + } +} diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift index 8c0132f9..27256b87 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift @@ -38,56 +38,7 @@ struct CodeReviewMainView: View { .scaledFont(.system(size: chatFontSize)) } - var statusIcon: some View { - Group { - switch round.status { - case .running: - ProgressView() - .controlSize(.small) - .frame(width: 16, height: 16) - .scaledScaleEffect(0.7) - case .completed: - Image(systemName: "checkmark") - .foregroundColor(.green) - .scaledFont(.body) - case .error: - Image(systemName: "xmark.circle") - .foregroundColor(.red) - .scaledFont(.body) - case .cancelled: - Image(systemName: "slash.circle") - .foregroundColor(.gray) - .scaledFont(.body) - case .waitForConfirmation: - EmptyView() - case .accepted: - EmptyView() - } - } - } - - var statusView: some View { - Group { - switch round.status { - case .waitForConfirmation, .accepted: - EmptyView() - default: - HStack(spacing: 4) { - statusIcon - .scaledFrame(width: 16, height: 16) - - Text("Running Code Review...") - .scaledFont(.system(size: chatFontSize)) - .foregroundColor(.secondary) - - Spacer() - } - } - } - } - var shouldShowHelloMessage: Bool { round.statusHistory.contains(.waitForConfirmation) } - var shouldShowRunningStatus: Bool { round.statusHistory.contains(.running) } var body: some View { WithPerceptionTracking { @@ -105,10 +56,6 @@ struct CodeReviewMainView: View { ) } - if shouldShowRunningStatus { - statusView - } - if hasFileComments { ReviewResultsSection(store: store, round: round) } diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift index 16921189..bc044fa7 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift @@ -46,7 +46,7 @@ private struct FileSelectionHeader: View { var body: some View { HStack(alignment: .top, spacing: 6) { - Image("Sparkle") + Image("codeReview") .resizable() .scaledToFit() .scaledFrame(width: 16, height: 16) @@ -113,6 +113,7 @@ private struct FileSelectionList: View { // Select All checkbox for all files selectedAllCheckbox .disabled(reviewStatus != .waitForConfirmation) + .scaledFrame(maxHeight: 16) FileToggleList( fileUris: visibleFileUris, @@ -254,11 +255,10 @@ private struct FileSelectionRow: View { } var body: some View { - HStack { + HStack(alignment: .center) { Toggle(isOn: $isSelected) { HStack(spacing: 8) { drawFileIcon(fileURL) - .resizable() .scaledToFit() .scaledFrame(width: 16, height: 16) diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift index 67dcf282..1a239021 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift @@ -147,9 +147,8 @@ private struct ReviewResultRowContent: View { @AppStorage(\.chatFontSize) private var chatFontSize var body: some View { - HStack(spacing: 4) { + HStack(alignment: .center, spacing: 4) { drawFileIcon(fileURL) - .resizable() .scaledToFit() .scaledFrame(width: 16, height: 16) diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift index cb6548a8..76fcbf6d 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift @@ -7,14 +7,18 @@ struct ReviewSummarySection: View { @AppStorage(\.chatFontSize) var chatFontSize var body: some View { - if round.status == .error, let errorMessage = round.error { - Text(errorMessage) - .scaledFont(size: chatFontSize) - } else if round.status == .completed, let request = round.request, let response = round.response { - CompletedSummary(request: request, response: response) - } else { - Text("Oops, failed to review changes.") - .font(.system(size: chatFontSize)) + HStack { + if round.status == .error, let errorMessage = round.error { + Text(errorMessage) + .scaledFont(size: chatFontSize) + } else if round.status == .completed, let request = round.request, let response = round.response { + CompletedSummary(request: request, response: response) + } else { + Text("Oops, failed to review changes.") + .font(.system(size: chatFontSize)) + } + + Spacer() } } } diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift deleted file mode 100644 index 7114a5ee..00000000 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift +++ /dev/null @@ -1,227 +0,0 @@ -import SwiftUI -import ConversationServiceProvider -import ComposableArchitecture -import Combine -import ChatTab -import ChatService -import SharedUIComponents - -struct ProgressAgentRound: View { - let rounds: [AgentRound] - let chat: StoreOf - - var body: some View { - WithPerceptionTracking { - VStack(alignment: .leading, spacing: 4) { - ForEach(rounds, id: \.roundId) { round in - VStack(alignment: .leading, spacing: 4) { - ThemedMarkdownText(text: round.reply, chat: chat) - if let toolCalls = round.toolCalls, !toolCalls.isEmpty { - ProgressToolCalls(tools: toolCalls, chat: chat) - .padding(.vertical, 8) - } - } - } - } - .foregroundStyle(.secondary) - } - } -} - -struct ProgressToolCalls: View { - let tools: [AgentToolCall] - let chat: StoreOf - - var body: some View { - WithPerceptionTracking { - VStack(alignment: .leading, spacing: 4) { - ForEach(tools) { tool in - if tool.name == ToolName.runInTerminal.rawValue && tool.invokeParams != nil { - RunInTerminalToolView(tool: tool, chat: chat) - } else if tool.invokeParams != nil && tool.status == .waitForConfirmation { - ToolConfirmationView(tool: tool, chat: chat) - } else { - ToolStatusItemView(tool: tool) - } - } - } - } - } -} - -struct ToolConfirmationView: View { - let tool: AgentToolCall - let chat: StoreOf - - @AppStorage(\.chatFontSize) var chatFontSize - - var body: some View { - WithPerceptionTracking { - VStack(alignment: .leading, spacing: 8) { - GenericToolTitleView(toolStatus: "Run", toolName: tool.name, fontWeight: .semibold) - - ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - Button("Cancel") { - chat.send(.toolCallCancelled(tool.id)) - } - .scaledFont(.body) - - Button("Continue") { - chat.send(.toolCallAccepted(tool.id)) - } - .buttonStyle(BorderedProminentButtonStyle()) - .scaledFont(.body) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - } - .padding(8) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - } - } -} - -struct GenericToolTitleView: View { - var toolStatus: String - var toolName: String - var fontWeight: Font.Weight = .regular - - @AppStorage(\.chatFontSize) var chatFontSize - - var body: some View { - HStack(spacing: 4) { - Text(toolStatus) - .textSelection(.enabled) - .scaledFont(size: chatFontSize, weight: fontWeight) - .foregroundStyle(.primary) - .background(Color.clear) - Text(toolName) - .textSelection(.enabled) - .scaledFont(size: chatFontSize, weight: fontWeight) - .foregroundStyle(.primary) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background(Color("ToolTitleHighlightBgColor")) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .inset(by: 0.5) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -struct ToolStatusItemView: View { - - let tool: AgentToolCall - - @AppStorage(\.chatFontSize) var chatFontSize - - var statusIcon: some View { - Group { - switch tool.status { - case .running: - ProgressView() - .controlSize(.small) - .scaledScaleEffect(0.7) - case .completed: - Image(systemName: "checkmark") - .foregroundColor(.green.opacity(0.5)) - .scaledFont(.body) - case .error: - Image(systemName: "xmark.circle") - .foregroundColor(.red.opacity(0.5)) - .scaledFont(.body) - case .cancelled: - Image(systemName: "slash.circle") - .foregroundColor(.gray.opacity(0.5)) - .scaledFont(.body) - case .waitForConfirmation: - EmptyView() - case .accepted: - EmptyView() - } - } - } - - var progressTitleText: some View { - let message: String = { - var msg = tool.progressMessage ?? "" - if tool.name == ToolName.createFile.rawValue { - if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String { - let fileURL = URL(fileURLWithPath: filePath) - msg += ": [\(fileURL.lastPathComponent)](\(fileURL.absoluteString))" - } - } - return msg - }() - - return Group { - if message.isEmpty { - GenericToolTitleView(toolStatus: "Running", toolName: tool.name) - } else { - if let attributedString = try? AttributedString(markdown: message) { - Text(attributedString) - .environment(\.openURL, OpenURLAction { url in - if url.scheme == "file" || url.isFileURL { - NSWorkspace.shared.open(url) - return .handled - } else { - return .systemAction - } - }) - } else { - Text(message) - } - } - } - } - - var body: some View { - WithPerceptionTracking { - HStack(spacing: 4) { - statusIcon - .scaledFrame(width: 16, height: 16) - - progressTitleText - .scaledFont(size: chatFontSize) - .lineLimit(1) - - Spacer() - } - } - } -} - -struct ProgressAgentRound_Preview: PreviewProvider { - static let agentRounds: [AgentRound] = [ - .init(roundId: 1, reply: "this is agent step", toolCalls: [ - .init( - id: "toolcall_001", - name: "Tool Call 1", - progressMessage: "Read Tool Call 1", - status: .completed, - error: nil), - .init( - id: "toolcall_002", - name: "Tool Call 2", - progressMessage: "Running Tool Call 2", - status: .running) - ]) - ] - - static var previews: some View { - let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") - ProgressAgentRound(rounds: agentRounds, chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) - .frame(width: 300, height: 300) - } -} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift new file mode 100644 index 00000000..95f0e91a --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift @@ -0,0 +1,411 @@ +import ChatService +import ChatTab +import Combine +import ComposableArchitecture +import ConversationServiceProvider +import GitHubCopilotService +import SharedUIComponents +import SwiftUI + +struct ProgressAgentRound: View { + let rounds: [AgentRound] + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + ForEach(rounds, id: \.roundId) { round in + VStack(alignment: .leading, spacing: 8) { + ThemedMarkdownText(text: round.reply, chat: chat) + if let toolCalls = round.toolCalls, !toolCalls.isEmpty { + ProgressToolCalls(tools: toolCalls, chat: chat) + } + if let subAgentRounds = round.subAgentRounds, !subAgentRounds.isEmpty { + SubAgentRounds(rounds: subAgentRounds, chat: chat) + } + } + } + } + .foregroundStyle(.secondary) + } + } +} + +struct SubAgentRounds: View { + let rounds: [AgentRound] + let chat: StoreOf + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + ForEach(rounds, id: \.roundId) { round in + VStack(alignment: .leading, spacing: 8) { + ThemedMarkdownText(text: round.reply, chat: chat) + if let toolCalls = round.toolCalls, !toolCalls.isEmpty { + ProgressToolCalls(tools: toolCalls, chat: chat) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .scaledPadding(.horizontal, 16) + .scaledPadding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 8).fill(Color("SubagentTurnBackground"))) + } + } +} + +struct ProgressToolCalls: View { + let tools: [AgentToolCall] + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 4) { + ForEach(tools) { tool in + if tool.name == ToolName.runInTerminal.rawValue && (tool.invokeParams != nil || tool.input != nil) { + RunInTerminalToolView(tool: tool, chat: chat) + } else if tool.invokeParams != nil && tool.status == .waitForConfirmation { + ToolConfirmationView(tool: tool, chat: chat) + } else if tool.isToolcallingLoopContinueTool { + // ignore rendering for internal tool calling loop continue tool + } else { + ToolStatusItemView(tool: tool) + } + } + } + } + } +} + +struct ToolConfirmationView: View { + let tool: AgentToolCall + let chat: StoreOf + + @AppStorage(\.chatFontSize) var chatFontSize + + private var toolName: String { tool.name } + private var titleText: String { tool.title ?? "" } + private var mcpServerName: String? { ToolAutoApprovalManager.extractMCPServerName(from: titleText) } + private var conversationId: String { tool.invokeParams?.conversationId ?? "" } + private var invokeMessage: String { tool.invokeParams?.message ?? "" } + private var isSensitiveFileOperation: Bool { ToolAutoApprovalManager.isSensitiveFileOperation(message: invokeMessage) } + private var sensitiveFileInfo: ToolAutoApprovalManager.SensitiveFileConfirmationInfo { + ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: invokeMessage) + } + + private var shouldShowMCPSplitButton: Bool { mcpServerName != nil && !conversationId.isEmpty } + private var shouldShowSensitiveFileSplitButton: Bool { + mcpServerName == nil && isSensitiveFileOperation && !conversationId.isEmpty + } + + @ViewBuilder + private var confirmationActionView: some View { + if #available(macOS 13.0, *), + FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && + CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled { + if tool.isToolcallingLoopContinueTool { + continueButton + } else if shouldShowSensitiveFileSplitButton { + sensitiveFileSplitButton + } else if shouldShowMCPSplitButton, let serverName = mcpServerName { + mcpSplitButton(serverName: serverName) + } else { + allowButton + } + } else { + legacyAllowOrContinueButton + } + } + + private var continueButton: some View { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Continue") + .scaledFont(.body) + } + .buttonStyle(.borderedProminent) + } + + private var allowButton: some View { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text("Allow") + .scaledFont(.body) + } + .buttonStyle(.borderedProminent) + } + + private var legacyAllowOrContinueButton: some View { + Button(action: { + chat.send(.toolCallAccepted(tool.id)) + }) { + Text(tool.isToolcallingLoopContinueTool ? "Continue" : "Allow") + .scaledFont(.body) + } + .buttonStyle(.borderedProminent) + } + + @available(macOS 13.0, *) + private var sensitiveFileMenuItems: [SplitButtonMenuItem] { + var items: [SplitButtonMenuItem] = [] + + items.append( + SplitButtonMenuItem(title: "Allow in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .sensitiveFile( + scope: .session(conversationId), + toolName: toolName, + description: sensitiveFileInfo.description, + pattern: sensitiveFileInfo.pattern + ) + ) + ) + } + ) + + let defaultPatterns = ["**/.github/instructions/*", "**/github-copilot/**/*", "outside-workspace"] + + if let pattern = sensitiveFileInfo.pattern, !pattern.isEmpty, !defaultPatterns.contains(pattern) { + items.append( + SplitButtonMenuItem(title: "Always Allow") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .sensitiveFile( + scope: .global, + toolName: toolName, + description: sensitiveFileInfo.description, + pattern: pattern + ) + ) + ) + } + ) + } + + items.append(.divider()) + items.append( + SplitButtonMenuItem(title: "Configure Auto Approve...") { + chat.send(.openAutoApproveSettings) + } + ) + + return items + } + + @available(macOS 13.0, *) + private var sensitiveFileSplitButton: some View { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: sensitiveFileMenuItems, + style: .prominent + ) + } + + @available(macOS 13.0, *) + private func mcpMenuItems(serverName: String) -> [SplitButtonMenuItem] { + var items: [SplitButtonMenuItem] = [] + + items.append( + SplitButtonMenuItem(title: "Allow \(toolName) in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpTool( + scope: .session(conversationId), + serverName: serverName, + toolName: toolName + ) + ) + ) + } + ) + + items.append( + SplitButtonMenuItem(title: "Always Allow \(toolName)") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpTool( + scope: .global, + serverName: serverName, + toolName: toolName + ) + ) + ) + } + ) + + items.append(.divider()) + + items.append( + SplitButtonMenuItem(title: "Allow tools from \(serverName) in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpServer( + scope: .session(conversationId), + serverName: serverName + ) + ) + ) + } + ) + + items.append( + SplitButtonMenuItem(title: "Always Allow tools from \(serverName)") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpServer( + scope: .global, + serverName: serverName + ) + ) + ) + } + ) + + items.append(.divider()) + + items.append( + SplitButtonMenuItem(title: "Configure Auto Approve...") { + chat.send(.openAutoApproveSettings) + } + ) + + return items + } + + @available(macOS 13.0, *) + private func mcpSplitButton(serverName: String) -> some View { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: mcpMenuItems(serverName: serverName), + style: .prominent + ) + } + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 8) { + if let title = tool.title { + ToolConfirmationTitleView(title: title, fontWeight: .semibold) + } else { + GenericToolTitleView(toolStatus: "Run", toolName: tool.name, fontWeight: .semibold) + } + + ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Button(action: { + chat.send(.toolCallCancelled(tool.id)) + }) { + Text(tool.isToolcallingLoopContinueTool ? "Cancel" : "Skip") + .scaledFont(.body) + } + + confirmationActionView + } + .frame(maxWidth: .infinity, alignment: .leading) + .scaledPadding(.top, 4) + } + .scaledPadding(8) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + } + } +} + +struct ToolConfirmationTitleView: View { + var title: String + var fontWeight: Font.Weight = .regular + + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack(spacing: 4) { + Text(title) + .textSelection(.enabled) + .scaledFont(size: chatFontSize, weight: fontWeight) + .foregroundStyle(.primary) + .background(Color.clear) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct GenericToolTitleView: View { + var toolStatus: String + var toolName: String + var fontWeight: Font.Weight = .regular + + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + HStack(spacing: 4) { + Text(toolStatus) + .textSelection(.enabled) + .scaledFont(size: chatFontSize - 1, weight: fontWeight) + .foregroundStyle(.primary) + .background(Color.clear) + Text(toolName) + .textSelection(.enabled) + .scaledFont(size: chatFontSize - 1, weight: fontWeight) + .foregroundStyle(.primary) + .scaledPadding(.vertical, 2) + .scaledPadding(.horizontal, 4) + .background(Color("ToolTitleHighlightBgColor")) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .inset(by: 0.5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct ProgressAgentRound_Preview: PreviewProvider { + static let agentRounds: [AgentRound] = [ + .init(roundId: 1, reply: "this is agent step", toolCalls: [ + .init( + id: "toolcall_001", + name: "Tool Call 1", + progressMessage: "Read Tool Call 1", + status: .completed, + error: nil), + .init( + id: "toolcall_002", + name: "Tool Call 2", + progressMessage: "Running Tool Call 2", + status: .running), + ]), + ] + + static var previews: some View { + let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") + ProgressAgentRound(rounds: agentRounds, chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) + .frame(width: 300, height: 300) + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift new file mode 100644 index 00000000..f5a95a2d --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift @@ -0,0 +1,219 @@ +import SwiftUI +import SharedUIComponents +import AppKit +import Terminal + +struct FileSearchResult: Hashable { + var file: String + var startLine: Int? = nil + var endLine: Int? = nil + var content: String? = nil +} + +struct ExpandableFileListView: View { + var progressMessage: ProgressMessage + var files: [FileSearchResult] + var chatFontSize: Double + var helpText: String + var onFileClick: ((String) -> Void)? = nil + var fileHelpTexts: [String: String]? = nil + + @State private var isExpanded: Bool = false + + init( + progressMessage: ProgressMessage, + files: [FileSearchResult], + chatFontSize: Double, + helpText: String, + onFileClick: ((String) -> Void)? = nil, + fileHelpTexts: [String: String]? = nil + ) { + self.progressMessage = progressMessage + self.files = files + self.chatFontSize = chatFontSize + self.helpText = helpText + self.onFileClick = onFileClick + self.fileHelpTexts = fileHelpTexts + } + + init( + progressMessage: ProgressMessage, + files: [String], + chatFontSize: Double, + helpText: String, + onFileClick: ((String) -> Void)? = nil, + fileHelpTexts: [String: String]? = nil + ) { + self.init( + progressMessage: progressMessage, + files: files.map { FileSearchResult(file: $0) }, + chatFontSize: chatFontSize, + helpText: helpText, + onFileClick: onFileClick, + fileHelpTexts: fileHelpTexts + ) + } + + private let maxVisibleRows = 5 + private let chevronWidth: CGFloat = 16 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header with chevron on the left + Button(action: { + isExpanded.toggle() + }) { + HStack(spacing: 4) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(4) + .scaledFrame(width: chevronWidth, height: chevronWidth) + .scaledFont(size: 10, weight: .medium) + .foregroundColor(.secondary) + + progressMessage + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(helpText) + + if isExpanded { + HStack(alignment: .top, spacing: 0) { + // Vertical line aligned with chevron center + Rectangle() + .fill(Color.secondary.opacity(0.3)) + .scaledFrame(width: 1) + .scaledPadding(.leading, chevronWidth / 2 - 0.5) + + // File list + VStack(alignment: .leading, spacing: 0) { + if files.count <= maxVisibleRows { + ForEach(files, id: \.self) { fileItem in + fileRow(for: fileItem) + } + } else { + ThinScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(files, id: \.self) { fileItem in + fileRow(for: fileItem) + } + } + } + .frame(height: CGFloat(maxVisibleRows) * 23) + } + } + .scaledPadding(.leading, chevronWidth / 2) + } + .scaledPadding(.top, 4) + } + } + } + + @ViewBuilder + private func fileRow(for fileItem: FileSearchResult) -> some View { + let filePath = fileItem.file + let isDirectory = filePath.hasSuffix("/") + let cleanPath = isDirectory ? String(filePath.dropLast()) : filePath + let url = URL(string: cleanPath).flatMap { $0.scheme == "file" ? $0 : nil } ?? URL(fileURLWithPath: cleanPath) + let displayName: String = { + var name = isDirectory ? url.lastPathComponent + "/" : url.lastPathComponent + if let line = fileItem.startLine, !isDirectory { + name += ": \(line)" + if let endLine = fileItem.endLine { + name += "-\(endLine)" + } + } + return name + }() + + Button(action: { + if let onFileClick = onFileClick { + onFileClick(filePath) + } else { + if let line = fileItem.startLine, !isDirectory { + Task { + let terminal = Terminal() + do { + _ = try await terminal.runCommand( + "/usr/bin/xed", + arguments: [ + "-l", + String(line), + url.path + ], + environment: [ + "TARGET_FILE": url.path + ] + ) + } catch { + print("Failed to open file with xed: \(error)") + NSWorkspace.shared.open(url) + } + } + } else { + NSWorkspace.shared.open(url) + } + } + }) { + HStack(alignment: .center, spacing: 6) { + drawFileIcon(url, isDirectory: isDirectory) + .scaledToFit() + .scaledFrame(width: 13, height: 13) + .foregroundColor(.secondary) + + Text(displayName) + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + } + .contentShape(Rectangle()) + } + .help(fileHelpTexts?[filePath] ?? url.path) + .buttonStyle(HoverButtonStyle()) + } +} + +// NSScrollView wrapper for thin, overlay-style scrollbars +struct ThinScrollView: NSViewRepresentable { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + + let hostingView = NSHostingView(rootView: content) + scrollView.documentView = hostingView + + // Ensure the hosting view can expand vertically + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + if let hostingView = scrollView.documentView as? NSHostingView { + hostingView.rootView = content + hostingView.invalidateIntrinsicContentSize() + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift new file mode 100644 index 00000000..9238a932 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift @@ -0,0 +1,580 @@ +import SwiftUI +import ConversationServiceProvider +import SharedUIComponents +import ComposableArchitecture +import MarkdownUI + +struct ToolStatusItemView: View { + + let tool: AgentToolCall + + @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.fontScale) var fontScale + + @State private var isHoveringFileLink = false + + var statusIcon: some View { + Group { + switch tool.status { + case .running: + ProgressView() + .controlSize(.small) + .scaledScaleEffect(0.7) + case .completed: + Image(systemName: "checkmark") + .foregroundColor(.secondary) + case .error: + Image(systemName: "xmark") + .foregroundColor(.red.opacity(0.5)) + case .cancelled: + Image(systemName: "slash.circle") + .foregroundColor(.gray.opacity(0.5)) + case .waitForConfirmation: + EmptyView() + case .accepted: + EmptyView() + } + } + .scaledFont(size: chatFontSize - 1, weight: .medium) + } + + @ViewBuilder + var progressTitleText: some View { + if tool.name == ServerToolName.findFiles.rawValue { + searchProgressView( + pattern: "Searched for files matching query: (.*)", + prefix: "Searched for files matching ", + singularSuffix: "match", + pluralSuffix: "matches" + ) + } else if tool.name == ServerToolName.findTextInFiles.rawValue { + searchProgressView( + pattern: "Searched for text in files matching query: (.*)", + prefix: "Searched for text in files matching ", + singularSuffix: "result", + pluralSuffix: "results" + ) + } else if tool.name == ServerToolName.readFile.rawValue || tool.name == CopilotToolName.readFile.rawValue { + readFileProgressView + } else if tool.name == ToolName.createFile.rawValue { + createFileProgressView + } else if tool.name == ServerToolName.replaceString.rawValue { + replaceStringProgressView + } else if tool.name == ToolName.insertEditIntoFile.rawValue { + insertEditIntoFileProgressView + } else if tool.name == ServerToolName.codebase.rawValue { + codebaseSearchProgressView + } else { + otherToolsProgressView + } + } + + @ViewBuilder + func searchProgressView(pattern: String, prefix: String, singularSuffix: String, pluralSuffix: String) -> some View { + let message = tool.progressMessage ?? "" + let matchCountText: String = { + if let parsed = parsedFileListResult { + let suffix = parsed.count == 1 ? singularSuffix : pluralSuffix + return "\(parsed.count) \(suffix)" + } + return "" + }() + + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)), + let range = Range(match.range(at: 1), in: message) { + + let query = String(message[range]) + let suffix = matchCountText.isEmpty ? "" : ": \(matchCountText)" + + HStack(spacing: 0) { + Text(prefix) + Text(query) + .scaledFont(size: chatFontSize - 1, weight: .regular, design: .monospaced) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(SecondarySystemFillColor) + .foregroundColor(.secondary) + .cornerRadius(4) + .padding(.horizontal, 2) + Text(suffix) + } + } else { + let displayMessage: String = { + if message.isEmpty { + return matchCountText + } else { + return message + (matchCountText.isEmpty ? "" : ": \(matchCountText)") + } + }() + + markdownView(text: displayMessage) + } + } + + @ViewBuilder + var readFileProgressView: some View { + let pattern = #"^Read file \[(?.+?)\]\((?.+?)\)(?:, lines (?\d+) to (?\d+))?"# + fileOperationProgressView(prefix: "Read", pattern: pattern) { match in + let message = tool.progressMessage ?? "" + if let startRange = Range(match.range(withName: "start"), in: message), + let endRange = Range(match.range(withName: "end"), in: message) { + let start = String(message[startRange]) + let end = String(message[endRange]) + Text(": \(start)-\(end)") + .foregroundColor(.secondary) + .scaledFont(size: chatFontSize - 1) + } + } + } + + @ViewBuilder + var createFileProgressView: some View { + let pattern = #"^Created \[(?.+?)\]\((?.+?)\)"# + fileOperationProgressView(suffix: "created successfully.", pattern: pattern) + } + @ViewBuilder + var replaceStringProgressView: some View { + let pattern = #"^Edited \[(?.+?)\]\((?.+?)\) with replace_string_in_file tool"# + fileOperationProgressView(prefix: "Edited", suffix: "with replace_string_in_file tool.", pattern: pattern) + } + + @ViewBuilder + var insertEditIntoFileProgressView: some View { + let pattern = #"^Edited \[(?.+?)\]\((?.+?)\) with insert_edit_into_file tool"# + fileOperationProgressView(prefix: "Edited", suffix: "with insert_edit_into_file tool.", pattern: pattern) + } + + @ViewBuilder + var codebaseSearchProgressView: some View { + let pattern = #"^Searched (?.+) for "(?.+)", (?no|\d+) results?$"# + if let regex = try? NSRegularExpression(pattern: pattern), + let message = tool.progressMessage, + let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)), + let targetRange = Range(match.range(withName: "target"), in: message), + let queryRange = Range(match.range(withName: "query"), in: message), + let countRange = Range(match.range(withName: "count"), in: message) { + + let target = String(message[targetRange]) + let query = String(message[queryRange]) + let countStr = String(message[countRange]) + let count = countStr == "no" ? "0" : countStr + let suffix = count == "1" ? "result" : "results" + + HStack(spacing: 0) { + Text("Searched \(target) for ") + Text(query) + .scaledFont(size: chatFontSize - 1, weight: .regular, design: .monospaced) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(SecondarySystemFillColor) + .foregroundColor(.secondary) + .cornerRadius(4) + .padding(.horizontal, 2) + Text(": \(count) \(suffix)") + } + } else { + markdownView(text: tool.progressMessage ?? "") + } + } + + @ViewBuilder + func fileOperationProgressView( + prefix: String? = nil, + suffix: String? = nil, + pattern: String, + @ViewBuilder extraContent: (NSTextCheckingResult) -> Content = { _ in EmptyView() } + ) -> some View { + let message = tool.progressMessage ?? "" + + if tool.name == ToolName.createFile.rawValue, tool.status == .error { + if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String { + let url = URL(fileURLWithPath: filePath) + let name = url.lastPathComponent + HStack(spacing: 4) { + drawFileIcon(url) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + Text(name).scaledFont(size: chatFontSize - 1) + Text("File creation failed") + } + } else { + markdownView(text: message) + } + } else if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)), + let nameRange = Range(match.range(withName: "name"), in: message), + let pathRange = Range(match.range(withName: "path"), in: message) { + + let name = String(message[nameRange]) + let pathString = String(message[pathRange]) + let url = URL(string: pathString).flatMap { $0.scheme == "file" ? $0 : nil } ?? URL(fileURLWithPath: pathString) + + HStack(spacing: 4) { + if let prefix { + Text(prefix) + } + + drawFileIcon(url) + .scaledToFit() + .scaledFrame(width: 16, height: 16) + + Button(action: { + NSWorkspace.shared.open(url) + }) { + Text(name) + .scaledFont(size: chatFontSize - 1) + .foregroundColor(isHoveringFileLink ? .primary : .secondary) + } + .buttonStyle(.plain) + .onHover { hovering in + isHoveringFileLink = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + if let suffix { + Text(suffix) + } + + extraContent(match) + .padding(.leading, -4) + } + } else { + markdownView(text: message) + } + } + + @ViewBuilder + var otherToolsProgressView: some View { + let message: String = { + var msg = tool.progressMessage ?? "" + if tool.name == ToolName.createFile.rawValue { + if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String { + let fileURL = URL(fileURLWithPath: filePath) + msg += ": [\(fileURL.lastPathComponent)](\(fileURL.absoluteString))" + } + } + return msg + }() + + if message.isEmpty { + GenericToolTitleView(toolStatus: "Running", toolName: tool.name) + } else { + markdownView(text: message) + } + } + + func markdownView(text: String) -> some View { + ThemedMarkdownText( + text: text, + context: .init(supportInsert: false), + foregroundColor: .secondary + ) + .environment(\.openURL, OpenURLAction { url in + if url.scheme == "file" || url.isFileURL { + NSWorkspace.shared.open(url) + return .handled + } else { + return .systemAction + } + }) + } + + var progressErrorText: some View { + ThemedMarkdownText( + text: tool.error ?? "", + context: .init(supportInsert: false), + foregroundColor: .secondary + ) + } + + @ViewBuilder + func toolCallDetailSection(title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .scaledFont(size: chatFontSize - 1, weight: .medium) + .foregroundColor(.secondary) + markdownView(text: text) + .toolCallDetailStyle(fontScale: fontScale) + } + } + + var mcpDetailView: some View { + VStack(alignment: .leading, spacing: 8) { + if let inputMessage = tool.inputMessage, !inputMessage.isEmpty { + toolCallDetailSection(title: "Input", text: inputMessage) + } + if let errorMessage = tool.error, !errorMessage.isEmpty { + toolCallDetailSection(title: "Output", text: errorMessage) + } + if let result = tool.result, !result.isEmpty { + toolCallDetailSection(title: "Output", text: toolResultText ?? "") + } + } + } + + var progress: some View { + HStack(spacing: 4) { + statusIcon + .scaledFrame(width: 16, height: 16) + + progressTitleText + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + + Spacer() + } + .help(tool.progressMessage ?? "") + } + + var toolResultText: String? { + tool.result?.compactMap({ item -> String? in + if case .text(let s) = item { return s } + return nil + }).joined(separator: "\n") + } + + func extractCreateFileContent(from text: String) -> String { + let pattern = #"(?s)\n?(.*?)\n?"# + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), + let range = Range(match.range(at: 1), in: text) { + return String(text[range]) + } + return text + } + + func extractInsertEditContent(from text: String) -> String { + let pattern = #"(?s)\n?(.*?)\n?"# + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), + let range = Range(match.range(at: 1), in: text) { + return String(text[range]) + } + return text + } + + var parsedFileListResult: (count: Int, files: [FileSearchResult])? { + guard let resultText = toolResultText, + !resultText.isEmpty else { + return nil + } + + // Parse find_files result + if tool.name == ServerToolName.findFiles.rawValue { + if resultText.hasPrefix("No files found") { + return (0, []) + } + + let pattern = "Found (\\d+) files? matching query:" + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: resultText, range: NSRange(resultText.startIndex..., in: resultText)), + let range = Range(match.range(at: 1), in: resultText), + let count = Int(resultText[range]) { + + if let newlineIndex = resultText.firstIndex(of: "\n") { + let filesPart = resultText[resultText.index(after: newlineIndex)...] + let files = filesPart.split(separator: "\n").map { FileSearchResult(file: String($0)) } + return (count, files) + } + } + } + + // Parse grep_search result + if tool.name == ServerToolName.findTextInFiles.rawValue { + if resultText.contains("no results") { + return (0, []) + } + + let countPattern = "Searched text for: .*, (\\d+) results?" + var count = 0 + if let regex = try? NSRegularExpression(pattern: countPattern), + let match = regex.firstMatch(in: resultText, range: NSRange(resultText.startIndex..., in: resultText)), + let range = Range(match.range(at: 1), in: resultText), + let parsedCount = Int(resultText[range]) { + count = parsedCount + } + + var files: [FileSearchResult] = [] + let lines = resultText.split(separator: "\n") + // Skip the first line which is the summary + if lines.count > 1 { + for line in lines.dropFirst() { + let parts = line.split(separator: ":", maxSplits: 2) + if parts.count >= 2 { + let path = String(parts[0]) + if let lineNumber = Int(parts[1]) { + let content = parts.count > 2 ? String(parts[2]) : nil + files.append(FileSearchResult(file: path, startLine: lineNumber, content: content)) + } else { + files.append(FileSearchResult(file: path)) + } + } + } + } + + return (count, files) + } + + // Parse list_dir result + if tool.name == ServerToolName.listDir.rawValue { + let files = resultText.split(separator: "\n").map { FileSearchResult(file: String($0)) } + return (files.count, files) + } + + return nil + } + + var parsedCodebaseSearchResult: (count: Int, files: [FileSearchResult])? { + guard let details = tool.resultDetails, !details.isEmpty else { return nil } + + var files: [FileSearchResult] = [] + for item in details { + if case .fileLocation(let location) = item { + files + .append( + FileSearchResult( + file: location.uri, + startLine: location.range.start.line, + endLine: location.range.end.line + ) + ) + } + } + + return (files.count, files) + } + + var body: some View { + WithPerceptionTracking { + if tool.name == ToolName.createFile.rawValue, let resultText = toolResultText, !resultText.isEmpty { + ToolStatusDetailsView( + title: progress, + content: markdownView(text: extractCreateFileContent(from: resultText)) + ) + } else if tool.name == ServerToolName.replaceString.rawValue, let resultText = toolResultText, !resultText.isEmpty { + ToolStatusDetailsView( + title: progress, + content: markdownView(text: resultText) + ) + } else if tool.name == ToolName.insertEditIntoFile.rawValue, let resultText = toolResultText, !resultText.isEmpty { + ToolStatusDetailsView( + title: progress, + content: markdownView(text: extractInsertEditContent(from: resultText)) + ) + } else if tool.toolType == .mcp { + ToolStatusDetailsView( + title: progress, + content: mcpDetailView + ) + } else if tool.status == .error { + ToolStatusDetailsView( + title: progress, + content: progressErrorText + ) + } else if let result = parsedFileListResult, + !result.files.isEmpty { + ExpandableFileListView( + progressMessage: progressTitleText, + files: result.files, + chatFontSize: chatFontSize, + helpText: tool.progressMessage ?? "" + ) + .scaledPadding(.horizontal, 6) + } else if let result = parsedCodebaseSearchResult, + !result.files.isEmpty { + ExpandableFileListView( + progressMessage: progressTitleText, + files: result.files, + chatFontSize: chatFontSize, + helpText: tool.progressMessage ?? "" + ) + .scaledPadding(.horizontal, 6) + } else { + progress.scaledPadding(.horizontal, 6) + } + } + } +} + + +private struct ToolStatusDetailsView: View { + var title: Title + var content: Content + + @State private var isExpanded = false + @AppStorage(\.fontScale) var fontScale + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + + Button(action: { + isExpanded.toggle() + }) { + HStack(spacing: 8) { + title + + Spacer() + + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(4) + .scaledFrame(width: 16, height: 16) + .scaledFont(size: 10, weight: .medium) + } + .contentShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .scaledPadding(.horizontal, 6) + .toolStatusStyle(withBackground: !isExpanded, fontScale: fontScale) + + if isExpanded { + Divider() + .background(Color.agentToolStatusDividerColor) + + content + .scaledPadding(.horizontal, 8) + } + } + .toolStatusStyle(withBackground: isExpanded, fontScale: fontScale) + } +} + +private extension View { + func toolStatusStyle(withBackground: Bool, fontScale: CGFloat) -> some View { + /// Leverage the `modify` extension to avoid refreshing of chat panel `List` view + self.modify { view in + if withBackground { + view + .scaledPadding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale) + ) + } else { + view + } + } + } + + func toolCallDetailStyle(fontScale: CGFloat) -> some View { + /// Leverage the `modify` extension to avoid refreshing of chat panel `List` view + self.modify { view in + view + .foregroundColor(.secondary) + .scaledPadding(4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(SecondarySystemFillColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale) + ) + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift index b6c0f524..739b126a 100644 --- a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift @@ -34,26 +34,23 @@ struct StatusItemView: View { .scaledScaleEffect(0.7) case .completed: Image(systemName: "checkmark") - .foregroundColor(.green) - .scaledFont(.body) + .foregroundColor(Color.successLightGreen) case .failed: Image(systemName: "xmark.circle") .foregroundColor(.red) - .scaledFont(.body) case .cancelled: Image(systemName: "slash.circle") .foregroundColor(.gray) - .scaledFont(.body) } } + .scaledFont(size: chatFontSize - 1, weight: .medium) } - var statusTitle: some View { - var title = step.title + var statusTitleText: String { if step.id == ProjectContextSkill.ProgressID && step.status == .failed { - title = step.error?.message ?? step.title + return step.error?.message ?? step.title } - return Text(title) + return step.title } var body: some View { @@ -62,12 +59,13 @@ struct StatusItemView: View { statusIcon .scaledFrame(width: 16, height: 16) - statusTitle - .scaledFont(size: chatFontSize) + Text(statusTitleText) + .scaledFont(size: chatFontSize - 1) .lineLimit(1) Spacer() } + .help(statusTitleText) } } } diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index 4a883f97..0523a44e 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -19,6 +19,10 @@ struct FunctionMessage: View { private var isOrgUser: Bool { text.contains("reach out to your organization's Copilot admin") } + + private var isBYOKUser: Bool { + text.contains("You've reached your quota limit for your BYOK model") + } private var switchToFallbackModelText: String { if let fallbackModelName = CopilotModelManager.getFallbackLLM( @@ -31,25 +35,33 @@ struct FunctionMessage: View { } private var errorContent: Text { - switch (isFreePlanUser, isOrgUser) { - case (true, _): + switch (isFreePlanUser, isOrgUser, isBYOKUser) { + case (true, _, _): return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.") - case (_, true): + case (_, true, _): let parts = [ "You have exceeded your free request allowance.", switchToFallbackModelText, "To enable additional paid premium requests, contact your organization admin." ].filter { !$0.isEmpty } return Text(attributedString(from: parts)) + + case (_, _, true): + let sentences = splitBYOKQuotaMessage(text) + + guard sentences.count == 2 else { fallthrough } - default: let parts = [ - "You have exceeded your premium request allowance.", + sentences[0], switchToFallbackModelText, - "[Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models." + sentences[1] ].filter { !$0.isEmpty } return Text(attributedString(from: parts)) + + default: + let parts = [text, switchToFallbackModelText].filter { !$0.isEmpty } + return Text(attributedString(from: parts)) } } @@ -61,6 +73,21 @@ struct FunctionMessage: View { } } + private func splitBYOKQuotaMessage(_ message: String) -> [String] { + // Fast path: find the first period followed by a space + capital P (for "Please") + let boundary = ". Please check with" + if let range = message.range(of: boundary) { + // First sentence ends at the period just before " Please" + let firstSentence = String(message[.. String { switch item.source { @@ -23,41 +24,32 @@ struct ImageReferenceItemView: View { } var body: some View { + // The HStack arranges its child views horizontally with a right-to-left layout direction applied via `.environment(\.layoutDirection, .rightToLeft)`. + // This ensures the views are displayed in reverse order to match the desired layout for FlowLayout. HStack(alignment: .center, spacing: 4) { - let image = loadImageFromData(data: item.data).image - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 28, height: 28) - .clipShape(RoundedRectangle(cornerRadius: 1.72)) - .overlay( - RoundedRectangle(cornerRadius: 1.72) - .inset(by: 0.21) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.43) - ) - let text = getImageTitle() - let font = NSFont.systemFont(ofSize: 12) - let attributes = [NSAttributedString.Key.font: font] - let size = (text as NSString).size(withAttributes: attributes) - let textWidth = min(size.width, 105) Text(text) .lineLimit(1) .scaledFont(size: 12) - .foregroundColor(.primary.opacity(0.85)) .truncationMode(.middle) - .frame(width: textWidth, alignment: .leading) + .scaledFrame(maxWidth: 105, alignment: .center) + .fixedSize(horizontal: true, vertical: false) + + Image(systemName: "photo") + .resizable() + .scaledToFit() + .scaledPadding(.vertical, 2) + .scaledFrame(width: 16, height: 16) } - .padding(4) - .background( - Color(nsColor: .windowBackgroundColor).opacity(0.5) - ) - .cornerRadius(4) + .foregroundColor(.primary.opacity(0.85)) + .scaledPadding(.horizontal, 4) + .scaledPadding(.vertical, 1) + .cornerRadius(6) .overlay( - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: 6) .inset(by: 0.5) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: 1 * fontScale) ) .popover(isPresented: $showPopover, arrowEdge: .bottom) { PopoverImageView(data: item.data) diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift index 68c40d57..f5047793 100644 --- a/Core/Sources/ConversationTab/Views/NotificationBanner.swift +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents public enum BannerStyle { case warning @@ -19,26 +20,26 @@ public enum BannerStyle { struct NotificationBanner: View { var style: BannerStyle @ViewBuilder var content: () -> Content + @AppStorage(\.chatFontSize) var chatFontSize var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 6) { Image(systemName: style.iconName) - .font(Font.system(size: 12)) .foregroundColor(style.color) VStack(alignment: .leading, spacing: 8) { content() } } + .scaledFont(size: chatFontSize - 1) } .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(.vertical, 10) - .padding(.horizontal, 12) + .scaledPadding(.vertical, 10) + .scaledPadding(.horizontal, 12) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color(nsColor: .separatorColor), lineWidth: 1) ) - .padding(.vertical, 4) } } diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index b3b73599..086d724e 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -26,8 +26,12 @@ public struct ThemedMarkdownText: View { @AppStorage(\.chatFontSize) var chatFontSize @Environment(\.colorScheme) var colorScheme + static let defaultForegroundColor: Color = .primary + @StateObject private var fontScaleManager = FontScaleManager.shared + let foregroundColor: Color + var fontScale: Double { fontScaleManager.currentScale } @@ -43,9 +47,10 @@ public struct ThemedMarkdownText: View { let text: String let context: MarkdownActionProvider - public init(text: String, context: MarkdownActionProvider) { + public init(text: String, context: MarkdownActionProvider, foregroundColor: Color? = nil) { self.text = text self.context = context + self.foregroundColor = foregroundColor ?? Self.defaultForegroundColor } init(text: String, chat: StoreOf) { @@ -54,6 +59,7 @@ public struct ThemedMarkdownText: View { self.context = .init(onInsert: { content in chat.send(.insertCode(content)) }) + self.foregroundColor = Self.defaultForegroundColor } public var body: some View { @@ -61,6 +67,7 @@ public struct ThemedMarkdownText: View { .textSelection(.enabled) .markdownTheme(.custom( fontSize: scaledChatFontSize, + foregroundColor: foregroundColor, codeFont: scaledChatCodeFont, codeBlockBackgroundColor: { if syncCodeHighlightTheme { @@ -95,13 +102,14 @@ public struct ThemedMarkdownText: View { extension MarkdownUI.Theme { static func custom( fontSize: Double, + foregroundColor: Color, codeFont: NSFont, codeBlockBackgroundColor: Color, codeBlockLabelColor: Color, context: MarkdownActionProvider ) -> MarkdownUI.Theme { .gitHub.text { - ForegroundColor(.primary) + ForegroundColor(foregroundColor) BackgroundColor(Color.clear) FontSize(fontSize) } @@ -178,4 +186,3 @@ struct ThemedMarkdownText_Previews: PreviewProvider { context: .init(onInsert: {_ in print("Inserted") })) } } - diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift index 858c802b..4b8a22e3 100644 --- a/Core/Sources/ConversationTab/Views/UserMessage.swift +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -9,6 +9,7 @@ import Cache import ChatTab import ConversationServiceProvider import SwiftUIFlowLayout +import ChatAPIService private let MAX_TEXT_LENGTH = 10000 // Maximum characters to prevent crashes @@ -18,27 +19,10 @@ struct UserMessage: View { let text: String let imageReferences: [ImageReference] let chat: StoreOf + let editorCornerRadius: Double + let requestType: RequestType @Environment(\.colorScheme) var colorScheme - @ObservedObject private var statusObserver = StatusObserver.shared - - struct AvatarView: View { - @ObservedObject private var avatarViewModel = AvatarViewModel.shared - - var body: some View { - if let avatarImage = avatarViewModel.avatarImage { - avatarImage - .resizable() - .aspectRatio(contentMode: .fill) - .scaledFrame(width: 24, height: 24) - .clipShape(Circle()) - } else { - Image(systemName: "person.circle") - .resizable() - .scaledToFit() - .scaledFrame(width: 24, height: 24) - } - } - } + @State var isMessageHovering: Bool = false // Truncate the displayed user message if it's too long. private var displayText: String { @@ -47,31 +31,87 @@ struct UserMessage: View { } return text } + + private var isEditing: Bool { + if case .editUserMessage(let editId) = chat.state.editorMode { + return editId == id + } + return false + } + + private var editorMode: Chat.EditorMode { .editUserMessage(id) } + + private var isConversationMessage: Bool { requestType == .conversation } var body: some View { + if !isEditing { + messageView + } else { + MessageInputArea(editorMode: editorMode, chat: chat, editorCornerRadius: editorCornerRadius) + } + } + + var messageView: some View { HStack { VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 4) { - AvatarView() - - Text(statusObserver.authStatus.username ?? "") - .scaledFont(size: 13, weight: .semibold) - .padding(2) - - Spacer() - } - - ThemedMarkdownText(text: displayText, chat: chat) - .frame(maxWidth: .infinity, alignment: .leading) + textView + .scaledPadding(.vertical, 8) + .scaledPadding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: r) + .fill(isMessageHovering ? Color("DarkBlue") : Color("LightBlue")) + ) + .overlay( + Group { + if isConversationMessage { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + chat.send(.setEditorMode(.editUserMessage(id))) + } + .allowsHitTesting(true) + } + } + ) + .onHover { isHovered in + if isConversationMessage { + isMessageHovering = isHovered + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) if !imageReferences.isEmpty { FlowLayout(mode: .scrollable, items: imageReferences, itemSpacing: 4) { item in ImageReferenceItemView(item: item) } + .environment(\.layoutDirection, .rightToLeft) } } } - .shadow(color: .black.opacity(0.05), radius: 6) + } + + var textView: some View { + ThemedMarkdownText(text: displayText, chat: chat) + } +} + +private struct MessageInputArea: View { + let editorMode: Chat.EditorMode + let chat: StoreOf + let editorCornerRadius: Double + + var body: some View { + ChatPanelInputArea( + chat: chat, + r: editorCornerRadius, + editorMode: editorMode + ) + .frame(maxWidth: .infinity) } } @@ -97,7 +137,9 @@ struct UserMessage_Previews: PreviewProvider { chat: .init( initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) } - ) + ), + editorCornerRadius: 4, + requestType: .conversation ) .padding() .fixedSize(horizontal: true, vertical: true) diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift index 8709a4c8..f572454b 100644 --- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift +++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift @@ -7,30 +7,33 @@ import JSONRPC import SharedUIComponents import OrderedCollections import ConversationServiceProvider +import ChatAPIService struct WorkingSetView: View { let chat: StoreOf private let r: Double = 8 + @State private var isExpanded: Bool = false + var body: some View { WithPerceptionTracking { VStack(spacing: 4) { - WorkingSetHeader(chat: chat) - .frame(height: 24) - .padding(.leading, 12) - .padding(.trailing, 5) + WorkingSetHeader(chat: chat, isExpanded: $isExpanded) + .scaledPadding(.vertical, 2) + .scaledPadding(.leading, 7) - VStack(spacing: 0) { - ForEach(chat.fileEditMap.elements, id: \.key.path) { element in - FileEditView(chat: chat, fileEdit: element.value) + if isExpanded { + VStack(spacing: 0) { + ForEach(chat.fileEditMap.elements, id: \.key.path) { element in + FileEditView(chat: chat, fileEdit: element.value) + } } } - .padding(.horizontal, 5) } - .padding(.top, 8) - .padding(.bottom, 10) + .scaledPadding(.horizontal, 5) + .scaledPadding(.vertical, 4) .frame(maxWidth: .infinity) .background( RoundedCorners(tl: r, tr: r, bl: 0, br: 0) @@ -46,6 +49,7 @@ struct WorkingSetView: View { struct WorkingSetHeader: View { let chat: StoreOf + @Binding var isExpanded: Bool @Environment(\.colorScheme) var colorScheme @@ -58,38 +62,47 @@ struct WorkingSetHeader: View { text: String, textForegroundColor: Color = .white, textBackgroundColor: Color = .gray, + buttonStyle: some PrimitiveButtonStyle = .bordered, action: @escaping () -> Void ) -> some View { Button(action: action) { Text(text) - .scaledFont(.body) - .foregroundColor(textForegroundColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(textBackgroundColor) - .cornerRadius(2) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.white.opacity(0.07), lineWidth: 1) - ) - .frame(width: 60, height: 15, alignment: .center) + .scaledFont(size: 11) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(buttonStyle) } var body: some View { WithPerceptionTracking { HStack(spacing: 0) { - Text(getTitle()) - .foregroundColor(.secondary) - .scaledFont(size: 13) - - Spacer() + HStack(spacing: 2) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(3) + .scaledFrame(width: 16, height: 16) + .foregroundColor(.secondary) + + Text(getTitle()) + .foregroundColor(.secondary) + .scaledFont(size: 13) + + Spacer() + } + .frame(maxWidth: .infinity) + .overlay( + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + isExpanded.toggle() + } + .allowsHitTesting(true) + ) if chat.fileEditMap.contains(where: {_, fileEdit in return fileEdit.status == .none }) { - HStack(spacing: -10) { + HStack(spacing: 6) { /// Undo all edits buildActionButton( text: "Undo", @@ -101,7 +114,11 @@ struct WorkingSetHeader: View { .help("Undo All Edits") /// Keep all edits - buildActionButton(text: "Keep", textBackgroundColor: Color("WorkingSetHeaderKeepButtonColor")) { + buildActionButton( + text: "Keep", + textBackgroundColor: Color("WorkingSetHeaderKeepButtonColor"), + buttonStyle: .borderedProminent + ) { chat.send(.keepEdits(fileURLs: chat.fileEditMap.values.map { $0.fileURL })) } .help("Keep All Edits") @@ -189,9 +206,8 @@ struct FileEditView: View { var body: some View { HStack(spacing: 0) { - HStack(spacing: 4) { + HStack(alignment: .center, spacing: 4) { drawFileIcon(fileEdit.fileURL) - .resizable() .scaledToFit() .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) @@ -211,8 +227,8 @@ struct FileEditView: View { .onHover { hovering in isHovering = hovering } - .padding(.leading, 7) - .frame(height: 24) + .scaledPadding(.leading, 7) + .scaledFrame(height: 24) .hoverRadiusBackground( isHovered: isHovering, hoverColor: Color.blue, diff --git a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift index 87e7179a..c2e3d6b8 100644 --- a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift +++ b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift @@ -3,9 +3,10 @@ import ComposableArchitecture public struct ImagesScrollView: View { let chat: StoreOf + let editorMode: Chat.EditorMode public var body: some View { - let attachedImages = chat.state.attachedImages.reversed() + let attachedImages = chat.state.getChatContext(of: editorMode).attachedImages.reversed() return ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 2) { ForEach(attachedImages, id: \.self) { image in @@ -13,7 +14,5 @@ public struct ImagesScrollView: View { } } } - .padding(.horizontal, 8) - .padding(.top, 8) } } diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift index bca4079f..39d298c0 100644 --- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Status import SwiftUI import Cache +import Client public struct SignInResponse { public let status: SignInInitiateStatus @@ -126,7 +127,7 @@ public class GitHubCopilotViewModel: ObservableObject { waitingForSignIn = false } - public func copyAndOpen() { + public func copyAndOpen(fromHostApp: Bool = false) { waitingForSignIn = true guard let signInResponse else { toast("Missing sign in details.", .error) @@ -137,10 +138,10 @@ public class GitHubCopilotViewModel: ObservableObject { pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string) toast("Sign-in code \(signInResponse.userCode) copied", .info) NSWorkspace.shared.open(signInResponse.verificationURL) - waitForSignIn() + waitForSignIn(fromHostApp: fromHostApp) } - public func waitForSignIn() { + public func waitForSignIn(fromHostApp: Bool = false) { Task { do { guard waitingForSignIn else { return } @@ -155,14 +156,19 @@ public class GitHubCopilotViewModel: ObservableObject { self.status = status await Status.shared.updateAuthStatus(.loggedIn, username: username) broadcastStatusChange() - let models = try? await service.models() - if let models = models, !models.isEmpty { - CopilotModelManager.updateLLMs(models) + if !fromHostApp { + let models = try? await service.models() + if let models = models, !models.isEmpty { + CopilotModelManager.updateLLMs(models) + } + } else { + let xpcService = try getService() + _ = try? await xpcService.updateCopilotModels() } } catch let error as GitHubCopilotError { switch error { case .languageServerError(.timeout): - waitForSignIn() + waitForSignIn(fromHostApp: fromHostApp) return case .languageServerError( .serverError( diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index 569b0a56..fc44276e 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -1,15 +1,20 @@ +import AppKitExtension import Client import ComposableArchitecture +import ConversationServiceProvider import SwiftUI import Toast import XcodeInspector import SharedUIComponents import Logger +import SystemUtils struct ChatSection: View { @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode @AppStorage(\.enableFixError) var enableFixError - @State private var isEditorPreviewEnabled: Bool = false + @AppStorage(\.enableSubagent) var enableSubagent + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared var body: some View { SettingsSection(title: "Chat Settings") { @@ -25,12 +30,36 @@ struct ChatSection: View { Divider() - if isEditorPreviewEnabled { + if featureFlags.isEditorPreviewEnabled { // Custom Prompts - .github/prompts/*.prompt.md PromptFileSetting(promptType: .prompt) .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) + + Divider() + } } // Auto Attach toggle @@ -58,28 +87,14 @@ struct ChatSection: View { // Font Size FontSizeSetting() .padding(SettingsToggle.defaultPadding) - } - .onAppear { - Task { - await updateEditorPreviewFeatureFlag() - } - } - .onReceive(DistributedNotificationCenter.default() - .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in - Task { - await updateEditorPreviewFeatureFlag() - } - } - } - - private func updateEditorPreviewFeatureFlag() async { - do { - let service = try getService() - if let featureFlags = try await service.getCopilotFeatureFlags() { - isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + + if featureFlags.isAgentModeEnabled { + Divider() + + // Agent Max Tool Calling Requests + AgentMaxToolCallLoopSetting() + .padding(SettingsToggle.defaultPadding) } - } catch { - Logger.client.error("Failed to get copilot feature flags: \(error)") } } } @@ -260,6 +275,67 @@ struct FontSizeSetting: View { } } +struct AgentMaxToolCallLoopSetting: View { + @AppStorage(\.agentMaxToolCallingLoop) var agentMaxToolCallingLoop + @State private var numberInput: String = "" + @State private var debounceTimer: Timer? + + private static let debounceDelay: TimeInterval = 0.5 + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text("Agent Max Requests") + .font(.body) + Text("Sets the maximum number of tool call requests Copilot can make in a single agent turn.") + .font(.footnote) + } + + Spacer() + + TextField("", text: $numberInput) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 40, maxWidth: 120) + .fixedSize(horizontal: true, vertical: false) + .onChange(of: numberInput) { newValue in + if newValue.isEmpty { return } + + guard let number = Int(newValue.filter { $0.isNumber }), number > 0 else { + numberInput = "" + return + } + + numberInput = "\(number)" + + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer( + withTimeInterval: Self.debounceDelay, + repeats: false + ) { _ in + agentMaxToolCallingLoop = number + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentMaxToolCallingLoopDidChange, object: nil) + } + } + } + .onAppear { + numberInput = "\(agentMaxToolCallingLoop)" + } + .onDisappear { + // Flush before invalidating + if let timer = debounceTimer, timer.isValid { + timer.fire() + } + + debounceTimer?.invalidate() + debounceTimer = nil + } + } + } +} + struct CopilotInstructionSetting: View { @State var isGlobalInstructionsViewOpen = false @Environment(\.toast) var toast @@ -351,8 +427,15 @@ struct PromptFileSetting: View { } .sheet(isPresented: $isCreateSheetPresented) { CreateCustomCopilotFileView( - isOpen: $isCreateSheetPresented, - promptType: promptType + promptType: promptType, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { await getCurrentProjectURL() }, + onSuccess: { message in + toast(message, .info) + }, + onError: { message in + toast(message, .error) + } ) } } @@ -377,6 +460,105 @@ struct PromptFileSetting: View { } } +struct AgentFileSetting: View { + let promptType: PromptType + @State private var isCreateSheetPresented = false + @Environment(\.toast) var toast + + var body: some View { + WithPerceptionTracking { + HStack { + VStack(alignment: .leading) { + Text(promptType.settingTitle) + .font(.body) + Text( + (try? AttributedString(markdown: promptType.description)) ?? AttributedString( + promptType.description + ) + ) + .font(.footnote) + } + + Spacer() + + Button("Create") { + isCreateSheetPresented = true + } + + Button("Browse \(promptType.displayName)s") { + openDirectory() + } + } + .sheet(isPresented: $isCreateSheetPresented) { + CreateCustomCopilotFileView( + promptType: promptType, + editorPluginVersion: SystemUtils.editorPluginVersionString, + getCurrentProjectURL: { await getCurrentProjectURL() }, + onSuccess: { message in + toast(message, .info) + }, + onError: { message in + toast(message, .error) + } + ) + } + } + } + + private func openDirectory() { + Task { + guard let projectURL = await getCurrentProjectURL() else { + toast("No active workspace found", .error) + return + } + + let directory = promptType.getDirectoryPath(projectURL: projectURL) + + do { + try ensureDirectoryExists(at: directory) + + // Open file picker for .agent.md files + await MainActor.run { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.init(filenameExtension: "agent.md") ?? .plainText] + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.level = .modalPanel + panel.directoryURL = directory + panel.message = "Select an existing agent file" + panel.prompt = "Select" + panel.showsHiddenFiles = false + + panel.allowsOtherFileTypes = false + panel.isExtensionHidden = false + + panel.begin { response in + if response == .OK, let selectedURL = panel.url { + // If the file doesn't exist, create it + if !FileManager.default.fileExists(atPath: selectedURL.path) { + do { + // Create empty agent file with basic structure + let template = promptType.defaultTemplate + try template.write(to: selectedURL, atomically: true, encoding: .utf8) + } catch { + toast("Failed to create agent file: \(error)", .error) + return + } + } + + // Open the file in Xcode + NSWorkspace.openFileInXcode(fileURL: selectedURL) + } + } + } + } catch { + toast("Failed to create \(promptType.directoryName) directory: \(error)", .error) + } + } + } +} + #Preview { ChatSection() .frame(width: 600) diff --git a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift index 072dd21f..d93ae8d9 100644 --- a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift +++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift @@ -5,123 +5,31 @@ import SwiftUI import Toast import XcodeInspector import SystemUtils +import SharedUIComponents +import Workspace +import LanguageServerProtocol -public enum PromptType: String, CaseIterable, Equatable { - case instructions = "instructions" - case prompt = "prompt" - - /// The directory name under .github where files of this type are stored - var directoryName: String { - switch self { - case .instructions: - return "instructions" - case .prompt: - return "prompts" - } - } - - /// The file extension for this prompt type - var fileExtension: String { - switch self { - case .instructions: - return ".instructions.md" - case .prompt: - return ".prompt.md" - } - } - - /// Human-readable name for display purposes - var displayName: String { - switch self { - case .instructions: - return "Instruction File" - case .prompt: - return "Prompt File" - } - } - - /// Human-readable name for settings - var settingTitle: String { - switch self { - case .instructions: - return "Custom Instructions" - case .prompt: - return "Prompt Files" - } - } - - /// Description for the prompt type - var description: String { - switch self { - case .instructions: - return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks." - case .prompt: - return "Configure `.github/prompts/*.prompt.md` files for reusable prompts. Trigger with '/' commands in the Chat view." - } - } - - /// Default template content for new files - var defaultTemplate: String { - switch self { - case .instructions: - return """ - --- - applyTo: '**' - --- - Provide project context and coding guidelines that AI should follow when generating code, or answering questions. - - """ - case .prompt: - return """ - --- - description: Prompt Description - --- - Define the task to achieve, including specific requirements, constraints, and success criteria. +// MARK: - Workspace URL Helpers - """ - } - } - - var helpLink: String { - var editorPluginVersion = SystemUtils.editorPluginVersionString - if editorPluginVersion == "0.0.0" { - editorPluginVersion = "main" - } - - switch self { - case .instructions: - return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/CustomInstructions.md" - case .prompt: - return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/PromptFiles.md" - } - } - - /// Get the full file path for a given name and project URL - func getFilePath(fileName: String, projectURL: URL) -> URL { - let directory = getDirectoryPath(projectURL: projectURL) - return directory.appendingPathComponent("\(fileName)\(fileExtension)") - } - - /// Get the directory path for this prompt type - func getDirectoryPath(projectURL: URL) -> URL { - return projectURL.appendingPathComponent(".github/\(directoryName)") +private func getCurrentWorkspaceURL() async -> URL? { + guard let service = try? getService(), + let inspectorData = try? await service.getXcodeInspectorData() else { + return nil } -} - -func getCurrentProjectURL() async -> URL? { - let service = try? getService() - let inspectorData = try? await service?.getXcodeInspectorData() - var currentWorkspace: URL? - if let url = inspectorData?.realtimeActiveWorkspaceURL, + if let url = inspectorData.realtimeActiveWorkspaceURL, let workspaceURL = URL(string: url), workspaceURL.path != "/" { - currentWorkspace = workspaceURL - } else if let url = inspectorData?.latestNonRootWorkspaceURL { - currentWorkspace = URL(string: url) + return workspaceURL + } else if let url = inspectorData.latestNonRootWorkspaceURL { + return URL(string: url) } - guard let workspaceURL = currentWorkspace, + return nil +} + +func getCurrentProjectURL() async -> URL? { + guard let workspaceURL = await getCurrentWorkspaceURL(), let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL( workspaceURL: workspaceURL, documentURL: nil @@ -132,6 +40,22 @@ func getCurrentProjectURL() async -> URL? { return projectURL } +// MARK: - Workspace Folders + +func getWorkspaceFolders() async -> [WorkspaceFolder]? { + guard let workspaceURL = await getCurrentWorkspaceURL(), + let workspaceInfo = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) else { + return nil + } + + let projects = WorkspaceFile.getProjects(workspace: workspaceInfo) + return projects.map { project in + WorkspaceFolder(uri: project.uri, name: project.name) + } +} + +// MARK: - File System Helpers + func ensureDirectoryExists(at url: URL) throws { let fileManager = FileManager.default if !fileManager.fileExists(atPath: url.path) { diff --git a/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift index cb86bde3..689ccaa5 100644 --- a/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift @@ -4,8 +4,10 @@ struct SuggestionSection: View { @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab + @AppStorage(\.realtimeNESToggle) var realtimeNESToggle @State var isSuggestionFeatureDisabledLanguageListViewOpen = false @State private var shouldPresentTurnoffSheet = false + @ObservedObject private var featureFlags = FeatureFlagManager.shared var realtimeSuggestionBinding : Binding { Binding( @@ -23,9 +25,18 @@ struct SuggestionSection: View { var body: some View { SettingsSection(title: "Suggestion Settings") { SettingsToggle( - title: "Request suggestions while typing", + title: "Enable completions while typing", isOn: realtimeSuggestionBinding ) + + if featureFlags.isEditorPreviewEnabled { + Divider() + SettingsToggle( + title: "Enable Next Edit Suggestions (NES)", + isOn: $realtimeNESToggle + ) + } + Divider() SettingsToggle( title: "Accept suggestions with Tab", diff --git a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift index e3ce7ba9..4f93eee0 100644 --- a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift +++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift @@ -1,5 +1,6 @@ import GitHubCopilotService import SwiftUI +import SharedUIComponents struct ApiKeySheet: View { @ObservedObject var dataManager: BYOKModelManagerObservable diff --git a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift index 4474dff4..4ce44c91 100644 --- a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift +++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift @@ -1,5 +1,6 @@ import GitHubCopilotService import SwiftUI +import SharedUIComponents struct ModelSheet: View { @ObservedObject var dataManager: BYOKModelManagerObservable diff --git a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift index b29ed3cc..194c4f91 100644 --- a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift +++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift @@ -83,15 +83,7 @@ struct BYOKProviderConfigView: View { isSelectedCustomModel = false } } - .cornerRadius(12) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .inset(by: 0.5) - .stroke(SecondarySystemFillColor, lineWidth: 1) - .animation(.easeInOut(duration: 0.3), value: isExpanded) - ) - .animation(.easeInOut(duration: 0.3), value: isExpanded) + .settingsContainerStyle(isExpanded: isExpanded) } // MARK: - UI Components @@ -150,7 +142,7 @@ struct BYOKProviderConfigView: View { private var ConfiguredProviderActions: some View { HStack(spacing: 8) { if provider.authType == .GlobalApiKey && isExpanded { - SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) Button(action: { Task { await dataManager.listModelsWithFetch(providerName: provider) diff --git a/Core/Sources/HostApp/CopilotPolicyManager.swift b/Core/Sources/HostApp/CopilotPolicyManager.swift new file mode 100644 index 00000000..9cf22eff --- /dev/null +++ b/Core/Sources/HostApp/CopilotPolicyManager.swift @@ -0,0 +1,107 @@ +import Client +import Combine +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +/// Centralized manager for GitHub Copilot policies in the HostApp +/// Use as @StateObject or @ObservedObject in SwiftUI views +@MainActor +public class CopilotPolicyManager: ObservableObject { + public static let shared = CopilotPolicyManager() + + // MARK: - Published Properties + + @Published public private(set) var isMCPContributionPointEnabled = true + @Published public private(set) var isCustomAgentEnabled = true + @Published public private(set) var isSubagentEnabled = true + @Published public private(set) var isCVERemediatorAgentEnabled = true + @Published public private(set) var isAgentModeAutoApprovalEnabled = true + + // MARK: - Private Properties + + private var cancellables = Set() + private var lastUpdateTime: Date? + private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates + + // MARK: - Initialization + + private init() { + setupNotificationObserver() + Task { + await updatePolicy() + } + } + + // MARK: - Public Methods + + /// Manually refresh policies from the service + public func refresh() async { + await updatePolicy() + } + + // MARK: - Private Methods + + private func setupNotificationObserver() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotPolicyDidChange) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.updatePolicy() + } + } + .store(in: &cancellables) + } + + private func updatePolicy() async { + // Throttle updates to prevent excessive calls + if let lastUpdate = lastUpdateTime, + Date().timeIntervalSince(lastUpdate) < updateThrottle { + return + } + + lastUpdateTime = Date() + + do { + let service = try getService() + guard let policy = try await service.getCopilotPolicy() else { + Logger.client.info("Copilot policy returned nil, using defaults") + return + } + + // Update all policies at once + isMCPContributionPointEnabled = policy.mcpContributionPointEnabled + isCustomAgentEnabled = policy.customAgentEnabled + isSubagentEnabled = policy.subagentEnabled + isCVERemediatorAgentEnabled = policy.cveRemediatorAgentEnabled + isAgentModeAutoApprovalEnabled = policy.agentModeAutoApprovalEnabled + + Logger.client.info("Copilot policy updated: customAgent=\(policy.customAgentEnabled), mcp=\(policy.mcpContributionPointEnabled), subagent=\(policy.subagentEnabled)") + } catch { + Logger.client.error("Failed to update copilot policy: \(error.localizedDescription)") + } + } +} + +// MARK: - Environment Key + +private struct CopilotPolicyManagerKey: EnvironmentKey { + static let defaultValue = CopilotPolicyManager.shared +} + +public extension EnvironmentValues { + var copilotPolicyManager: CopilotPolicyManager { + get { self[CopilotPolicyManagerKey.self] } + set { self[CopilotPolicyManagerKey.self] = newValue } + } +} + +// MARK: - View Extension + +public extension View { + /// Inject the copilot policy manager into the environment + func withCopilotPolicyManager(_ manager: CopilotPolicyManager = .shared) -> some View { + self.environment(\.copilotPolicyManager, manager) + } +} diff --git a/Core/Sources/HostApp/FeatureFlagManager.swift b/Core/Sources/HostApp/FeatureFlagManager.swift new file mode 100644 index 00000000..189d5a4e --- /dev/null +++ b/Core/Sources/HostApp/FeatureFlagManager.swift @@ -0,0 +1,111 @@ +import Client +import Combine +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +/// Centralized manager for GitHub Copilot feature flags in the HostApp +/// Use as @StateObject or @ObservedObject in SwiftUI views +@MainActor +public class FeatureFlagManager: ObservableObject { + public static let shared = FeatureFlagManager() + + // MARK: - Published Properties + + @Published public private(set) var isAgentModeEnabled = true + @Published public private(set) var isBYOKEnabled = true + @Published public private(set) var isMCPEnabled = true + @Published public private(set) var isEditorPreviewEnabled = true + @Published public private(set) var isChatEnabled = true + @Published public private(set) var isCodeReviewEnabled = true + @Published public private(set) var isAgenModeAutoApprovalEnabled = true + + // MARK: - Private Properties + + private var cancellables = Set() + private var lastUpdateTime: Date? + private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates + + // MARK: - Initialization + + private init() { + setupNotificationObserver() + Task { + await updateFeatureFlags() + } + } + + // MARK: - Public Methods + + /// Manually refresh feature flags from the service + public func refresh() async { + await updateFeatureFlags() + } + + // MARK: - Private Methods + + private func setupNotificationObserver() { + DistributedNotificationCenter.default() + .publisher(for: .gitHubCopilotFeatureFlagsDidChange) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.updateFeatureFlags() + } + } + .store(in: &cancellables) + } + + private func updateFeatureFlags() async { + // Throttle updates to prevent excessive calls + if let lastUpdate = lastUpdateTime, + Date().timeIntervalSince(lastUpdate) < updateThrottle { + return + } + + lastUpdateTime = Date() + + do { + let service = try getService() + guard let featureFlags = try await service.getCopilotFeatureFlags() else { + Logger.client.info("Feature flags returned nil, using defaults") + return + } + + // Update all flags at once + isAgentModeEnabled = featureFlags.agentMode + isBYOKEnabled = featureFlags.byok + isMCPEnabled = featureFlags.mcp + isEditorPreviewEnabled = featureFlags.editorPreviewFeatures + isChatEnabled = featureFlags.chat + isCodeReviewEnabled = featureFlags.ccr + isAgenModeAutoApprovalEnabled = featureFlags.agentModeAutoApproval + + Logger.client.info("Feature flags updated: agentMode=\(featureFlags.agentMode), byok=\(featureFlags.byok), mcp=\(featureFlags.mcp), editorPreview=\(featureFlags.editorPreviewFeatures)") + } catch { + Logger.client.error("Failed to update feature flags: \(error.localizedDescription)") + } + } +} + +// MARK: - Environment Key + +private struct FeatureFlagManagerKey: EnvironmentKey { + static let defaultValue = FeatureFlagManager.shared +} + +public extension EnvironmentValues { + var featureFlagManager: FeatureFlagManager { + get { self[FeatureFlagManagerKey.self] } + set { self[FeatureFlagManagerKey.self] = newValue } + } +} + +// MARK: - View Extension + +public extension View { + /// Inject the feature flag manager into the environment + func withFeatureFlagManager(_ manager: FeatureFlagManager = .shared) -> some View { + self.environment(\.featureFlagManager, manager) + } +} diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift index 5a454b7a..81f7b9fc 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -57,7 +57,10 @@ struct CopilotConnectionView: View { isPresented: $viewModel.isSignInAlertPresented, presenting: viewModel.signInResponse) { _ in Button("Cancel", role: .cancel, action: {}) - Button("Copy Code and Open", action: viewModel.copyAndOpen) + Button( + "Copy Code and Open", + action: { viewModel.copyAndOpen(fromHostApp: true) } + ) } message: { response in Text(""" Please enter the above code in the \ @@ -117,7 +120,7 @@ struct CopilotConnectionView: View { ) Divider() SettingsLink( - url: "https://github.com/orgs/community/discussions/categories/copilot", + url: "https://github.com/github/CopilotForXcode/discussions", title: "View Copilot Feedback Forum" ) } diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift index 6c264821..19418245 100644 --- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift +++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import SwiftUI +import SharedUIComponents struct GeneralSettingsView: View { @AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool @@ -19,7 +20,7 @@ struct GeneralSettingsView: View { return "" } } - + var extensionPermissionSubtitle: any View { switch store.isExtensionPermissionGranted { case .notGranted: @@ -40,8 +41,7 @@ struct GeneralSettingsView: View { return Text("") } } - - + var extensionPermissionBadge: BadgeItem? { switch store.isExtensionPermissionGranted { case .notGranted: @@ -52,8 +52,8 @@ struct GeneralSettingsView: View { return nil } } - - var extensionPermissionAction: ()->Void { + + var extensionPermissionAction: () -> Void { switch store.isExtensionPermissionGranted { case .disabled: return { shouldShowRestartXcodeAlert = true } @@ -89,12 +89,9 @@ struct GeneralSettingsView: View { } footer: { HStack { Spacer() - Button("?") { - NSWorkspace.shared.open( - URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")! - ) - } - .clipShape(Circle()) + AdaptiveHelpLink(action: { NSWorkspace.shared.open( + URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")! + )}) } } .alert( @@ -102,10 +99,10 @@ struct GeneralSettingsView: View { isPresented: $shouldPresentExtensionPermissionAlert ) { Button( - "Open System Preferences", - action: { - NSWorkspace.openXcodeExtensionsPreferences() - }).keyboardShortcut(.defaultAction) + "Open System Preferences", + action: { + NSWorkspace.openXcodeExtensionsPreferences() + }).keyboardShortcut(.defaultAction) Button("View How-to Guide", action: { let url = "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission" NSWorkspace.shared.open(URL(string: url)!) @@ -126,7 +123,7 @@ struct GeneralSettingsView: View { Button("Restart Now") { NSWorkspace.restartXcode() }.keyboardShortcut(.defaultAction) - + Button("Cancel", role: .cancel) {} } message: { Text("Quit and restart Xcode to enable Github Copilot for Xcode extension.") diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index e9d2253e..ba3c3da7 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -39,18 +39,25 @@ public enum TabIndex: Int, CaseIterable { } } +public enum ToolsSubTab: String, CaseIterable, Identifiable { + case MCP, BuiltIn, AutoApprove + public var id: Self { self } +} + @Reducer public struct HostApp { @ObservableState public struct State: Equatable { var general = General.State() public var activeTabIndex: TabIndex = .general + public var activeToolsSubTab: ToolsSubTab = .MCP } public enum Action: Equatable { case appear case general(General.Action) case setActiveTab(TabIndex) + case setActiveToolsSubTab(ToolsSubTab) } @Dependency(\.toast) var toast @@ -75,6 +82,10 @@ public struct HostApp { case .setActiveTab(let index): state.activeTabIndex = index return .none + + case .setActiveToolsSubTab(let tab): + state.activeToolsSubTab = tab + return .none } } } diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift index 615d03e9..7c0b2e03 100644 --- a/Core/Sources/HostApp/SharedComponents/Badge.swift +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -11,33 +11,50 @@ struct BadgeItem { let level: Level let icon: String? let isSelected: Bool + let tooltip: String? - init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false) { + init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { self.text = text self.level = level self.icon = icon self.isSelected = isSelected + self.tooltip = tooltip } } struct Badge: View { let text: String + let attributedText: AttributedString? let level: BadgeItem.Level let icon: String? let isSelected: Bool + let tooltip: String? init(badgeItem: BadgeItem) { text = badgeItem.text + attributedText = nil level = badgeItem.level icon = badgeItem.icon isSelected = badgeItem.isSelected + tooltip = badgeItem.tooltip } - init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false) { + init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { self.text = text + self.attributedText = nil self.level = level self.icon = icon self.isSelected = isSelected + self.tooltip = tooltip + } + + init(attributedText: AttributedString, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) { + self.text = String(attributedText.characters) + self.attributedText = attributedText + self.level = level + self.icon = icon + self.isSelected = isSelected + self.tooltip = tooltip } var body: some View { @@ -47,10 +64,18 @@ struct Badge: View { .font(.caption2) .padding(.vertical, 1) } - Text(text) - .fontWeight(.semibold) - .font(.caption2) - .lineLimit(1) + if let attributedText = attributedText, attributedText.characters.count > 0 { + Text(attributedText) + .fontWeight(.semibold) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + } else if !text.isEmpty { + Text(text) + .fontWeight(.semibold) + .font(.caption2) + .lineLimit(1) + } } .padding(.vertical, 1) .padding(.horizontal, 3) @@ -77,5 +102,20 @@ struct Badge: View { lineWidth: 1 ) ) + .help(tooltip ?? text) + } +} + +extension BadgeItem { + static func disabledByPolicy(feature: String, isPlural: Bool = false) -> BadgeItem { + let verb = isPlural ? "are" : "is" + let pronoun = isPlural ? "them" : "it" + return .init( + text: "Disabled by organization policy", + level: .warning, + icon: "exclamationmark.triangle.fill", + tooltip: "\(feature) \(verb) disabled by your organization's policy. Please contact your administrator to enable \(pronoun)." + ) } } + diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift index 1cc98444..7ab60d87 100644 --- a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift +++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents public struct CardGroupBoxStyle: GroupBoxStyle { public var backgroundColor: Color @@ -15,7 +16,8 @@ public struct CardGroupBoxStyle: GroupBoxStyle { configuration.label.foregroundColor(.primary) configuration.content.foregroundColor(.primary) } - .padding(8) + .padding(.vertical, 12) + .padding(.horizontal, 20) .frame(maxWidth: .infinity, alignment: .topLeading) .background(backgroundColor) .cornerRadius(12) diff --git a/Core/Sources/HostApp/SharedComponents/Color.swift b/Core/Sources/HostApp/SharedComponents/Color.swift deleted file mode 100644 index 2d5a7682..00000000 --- a/Core/Sources/HostApp/SharedComponents/Color.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI - -public var QuinarySystemFillColor: Color { - if #available(macOS 14.0, *) { - return Color(nsColor: .quinarySystemFill) - } else { - return Color("QuinarySystemFillColor") - } -} - -public var QuaternarySystemFillColor: Color { - if #available(macOS 14.0, *) { - return Color(nsColor: .quaternarySystemFill) - } else { - return Color("QuaternarySystemFillColor") - } -} - -public var TertiarySystemFillColor: Color { - if #available(macOS 14.0, *) { - return Color(nsColor: .tertiarySystemFill) - } else { - return Color("TertiarySystemFillColor") - } -} - -public var SecondarySystemFillColor: Color { - if #available(macOS 14.0, *) { - return Color(nsColor: .secondarySystemFill) - } else { - return Color("SecondarySystemFillColor") - } -} diff --git a/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift index 38559dcc..6567985c 100644 --- a/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift +++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedUIComponents public struct DisclosureSettingsRow: View { @Binding private var isExpanded: Bool @@ -22,7 +23,7 @@ public struct DisclosureSettingsRow: onToggle: ((Bool, Bool) -> Void)? = nil, @ViewBuilder title: @escaping () -> Title, @ViewBuilder subtitle: @escaping (() -> Subtitle) = { EmptyView() }, - @ViewBuilder actions: @escaping () -> Actions + @ViewBuilder actions: @escaping () -> Actions = { EmptyView() } ) { _isExpanded = isExpanded self.isEnabled = isEnabled diff --git a/Core/Sources/HostApp/SharedComponents/EditableText.swift b/Core/Sources/HostApp/SharedComponents/EditableText.swift new file mode 100644 index 00000000..41db896a --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/EditableText.swift @@ -0,0 +1,55 @@ +import SwiftUI +import Perception + +struct EditableText: View { + let title: String + let initialText: String + let onCommit: (String) -> Bool + + @State private var text: String + @State private var lastCommittedText: String + @State private var isReverting: Bool = false + + init(_ title: String, text: String, onCommit: @escaping (String) -> Bool) { + self.title = title + self.initialText = text + self._text = State(initialValue: text) + self._lastCommittedText = State(initialValue: text) + self.onCommit = onCommit + } + + var body: some View { + TextField(title, text: $text, onEditingChanged: { editing in + if !editing { + commit() + } + }) + .onSubmit { + commit() + } + .onChange(of: initialText) { newValue in + if text != newValue { + text = newValue + } + if lastCommittedText != newValue { + lastCommittedText = newValue + } + } + } + + private func commit() { + guard !isReverting else { return } + guard text != lastCommittedText else { return } + + if onCommit(text) { + lastCommittedText = text + } else { + isReverting = true + // Async revert to ensure textField updates even during focus change + DispatchQueue.main.async { + text = lastCommittedText + isReverting = false + } + } + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SearchBar.swift b/Core/Sources/HostApp/SharedComponents/SearchBar.swift deleted file mode 100644 index 5104d29c..00000000 --- a/Core/Sources/HostApp/SharedComponents/SearchBar.swift +++ /dev/null @@ -1,100 +0,0 @@ -import SharedUIComponents -import SwiftUI - -/// Reusable search control with a toggleable magnifying glass button that expands -/// into a styled search field with clear button, focus handling, and auto-hide -/// when focus is lost and the text is empty. -/// -/// Usage: -/// SearchBar(isVisible: $isSearchBarVisible, text: $searchText) -struct SearchBar: View { - @Binding var isVisible: Bool - @Binding var text: String - - @FocusState private var isFocused: Bool - - var placeholder: String = "Search..." - var accessibilityIdentifier: String = "searchTextField" - - var body: some View { - Group { - if isVisible { - HStack(spacing: 5) { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - .onTapGesture { withAnimation(.easeInOut) { - isVisible = false - } } - - TextField(placeholder, text: $text) - .accessibilityIdentifier(accessibilityIdentifier) - .textFieldStyle(PlainTextFieldStyle()) - .focused($isFocused) - - if !text.isEmpty { - Button(action: { text = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - .buttonStyle(PlainButtonStyle()) - .help("Clear search") - } - } - .padding(.leading, 7) - .padding(.trailing, 3) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(Color(.textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 5) - .stroke( - isFocused - ? Color(red: 0, green: 0.48, blue: 1).opacity(0.5) - : Color.gray.opacity(0.4), - lineWidth: isFocused ? 3 : 1 - ) - ) - .cornerRadius(5) - .frame(width: 212, height: 20, alignment: .leading) - .shadow(color: Color(red: 0, green: 0.48, blue: 1).opacity(0.5), radius: isFocused ? 1.25 : 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0) - .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5) - .padding(2) - // Removed the move(edge: .trailing) to prevent overlap; keep a clean fade instead - .transition(.asymmetric(insertion: .opacity, removal: .opacity)) - .onChange(of: isFocused) { focused in - if !focused && text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - withAnimation(.easeInOut) { - isVisible = false - } - } - } - .onChange(of: isVisible) { newValue in - if newValue { - // Delay to ensure the field is mounted before requesting focus. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isFocused = true - } - } - } - } else { - Button(action: { - withAnimation(.easeInOut) { - isVisible = true - } - }) { - Image(systemName: "magnifyingglass") - .padding(.trailing, 2) - } - .buttonStyle(HoverButtonStyle()) - .frame(height: 24) - .transition(.opacity) - .help("Show search") - } - } - .contentShape(Rectangle()) - .onTapGesture { if isFocused { isFocused = false } } - } -} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift b/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift new file mode 100644 index 00000000..119edd80 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift @@ -0,0 +1,17 @@ +import SwiftUI +import SharedUIComponents + +extension View { + func settingsContainerStyle(isExpanded: Bool) -> some View { + self + .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .inset(by: 0.5) + .stroke(SecondarySystemFillColor, lineWidth: 1) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + ) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift index 2576dc1c..a3dc805d 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift @@ -4,11 +4,33 @@ struct SettingsToggle: View { static let defaultPadding: CGFloat = 10 let title: String + let subtitle: String? let isOn: Binding + let badge: BadgeItem? + + init(title: String, subtitle: String? = nil, isOn: Binding, badge: BadgeItem? = nil) { + self.title = title + self.subtitle = subtitle + self.isOn = isOn + self.badge = badge + } var body: some View { HStack(alignment: .center) { - Text(title) + VStack(alignment: .leading) { + HStack(spacing: 6) { + Text(title).font(.body) + + if let badge = badge { + Badge(badgeItem: badge) + .allowsHitTesting(true) + } + } + + if let subtitle = subtitle { + Text(subtitle).font(.footnote) + } + } Spacer() Toggle(isOn: isOn) {} .controlSize(.mini) diff --git a/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift b/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift new file mode 100644 index 00000000..c672e420 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension View { + @ViewBuilder + func transparentBackground() -> some View { + if #available(macOS 14.0, *) { + self.scrollContentBackground(.hidden).alternatingRowBackgrounds(.disabled) + } else { + self + } + } +} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 3b81636a..c4a372cd 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -15,9 +15,8 @@ public let hostAppStore: StoreOf = .init(initialState: .init(), reducer public struct TabContainer: View { let store: StoreOf @ObservedObject var toastController: ToastController + @ObservedObject private var featureFlags = FeatureFlagManager.shared @State private var tabBarItems = [TabBarItem]() - @State private var isAgentModeFFEnabled = true - @State private var isBYOKFFEnabled = true @Binding var tag: TabIndex public init() { @@ -37,23 +36,6 @@ public struct TabContainer: View { set: { store.send(.setActiveTab($0)) } ) } - - private func updateHostAppFeatureFlags() async { - do { - let service = try getService() - let featureFlags = try await service.getCopilotFeatureFlags() - isAgentModeFFEnabled = featureFlags?.agentMode ?? true - isBYOKFFEnabled = featureFlags?.byok ?? true - if hostAppStore.state.activeTabIndex == .tools && !isAgentModeFFEnabled { - hostAppStore.send(.setActiveTab(.general)) - } - if hostAppStore.state.activeTabIndex == .byok && !isBYOKFFEnabled { - hostAppStore.send(.setActiveTab(.general)) - } - } catch { - Logger.client.error("Failed to get copilot feature flags: \(error)") - } - } public var body: some View { WithPerceptionTracking { @@ -63,10 +45,10 @@ public struct TabContainer: View { ZStack(alignment: .center) { GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general) AdvancedSettings().tabBarItem(for: .advanced) - if isAgentModeFFEnabled { + if featureFlags.isAgentModeEnabled { MCPConfigView().tabBarItem(for: .tools) } - if isBYOKFFEnabled { + if featureFlags.isBYOKEnabled { BYOKConfigView().tabBarItem(for: .byok) } } @@ -82,16 +64,17 @@ public struct TabContainer: View { } .onAppear { store.send(.appear) - Task { - await updateHostAppFeatureFlags() + } + .onChange(of: featureFlags.isAgentModeEnabled) { isEnabled in + if hostAppStore.state.activeTabIndex == .tools && !isEnabled { + hostAppStore.send(.setActiveTab(.general)) } } - .onReceive(DistributedNotificationCenter.default() - .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in - Task { - await updateHostAppFeatureFlags() - } + .onChange(of: featureFlags.isBYOKEnabled) { isEnabled in + if hostAppStore.state.activeTabIndex == .byok && !isEnabled { + hostAppStore.send(.setActiveTab(.general)) } + } } } } diff --git a/Core/Sources/HostApp/ToolsConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift index 16b1bb8e..7dbb1ba1 100644 --- a/Core/Sources/HostApp/ToolsConfigView.swift +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -1,90 +1,115 @@ import Client +import ComposableArchitecture +import ConversationServiceProvider import Foundation +import GitHubCopilotService import Logger +import Persist import SharedUIComponents import SwiftUI +import SystemUtils import Toast -import ConversationServiceProvider -import GitHubCopilotService -import ComposableArchitecture struct MCPConfigView: View { @State private var mcpConfig: String = "" @Environment(\.toast) var toast + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared @State private var configFilePath: String = mcpConfigFilePath @State private var isMonitoring: Bool = false @State private var lastModificationDate: Date? = nil @State private var fileMonitorTask: Task? = nil - @State private var isMCPFFEnabled = false - @State private var selectedOption = ToolType.MCP + @State private var selectedMode: ConversationMode = .defaultAgent @Environment(\.colorScheme) var colorScheme - private static var lastSyncTimestamp: Date? = nil - - enum ToolType: String, CaseIterable, Identifiable { - case MCP, BuiltIn - var id: Self { self } + private var isCustomAgentEnabled: Bool { + featureFlags.isEditorPreviewEnabled && copilotPolicy.isCustomAgentEnabled } + private static var lastSyncTimestamp: Date? = nil + @State private var debounceTimer: Timer? + private static let refreshDebounceInterval: TimeInterval = 1.0 // 1.0 second debounce + var body: some View { WithPerceptionTracking { ScrollView { - Picker("", selection: $selectedOption) { - Text("MCP").tag(ToolType.MCP) - Text("Built-In").tag(ToolType.BuiltIn) + Picker("", selection: Binding( + get: { hostAppStore.state.activeToolsSubTab }, + set: { hostAppStore.send(.setActiveToolsSubTab($0)) } + )) { + if #available(macOS 26.0, *) { + Text("MCP".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.MCP) + Text("Built-In".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.BuiltIn) + Text("Auto-Approve".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.AutoApprove) + } else { + Text("MCP").tag(ToolsSubTab.MCP) + Text("Built-In").tag(ToolsSubTab.BuiltIn) + Text("Auto-Approve").tag(ToolsSubTab.AutoApprove) + } } - .pickerStyle(.segmented) .frame(width: 400) - + .labelsHidden() + .pickerStyle(.segmented) + .padding(.top, 12) + .padding(.bottom, 4) + Group { - if selectedOption == .MCP { + if hostAppStore.activeToolsSubTab == .MCP { VStack(alignment: .leading, spacing: 8) { - MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled) - if isMCPFFEnabled { + MCPIntroView(isMCPFFEnabled: featureFlags.isMCPEnabled) + if featureFlags.isMCPEnabled { MCPManualInstallView() - MCPToolsListView() + + if featureFlags.isEditorPreviewEnabled { + MCPRegistryURLView() + } + + MCPToolsListView( + selectedMode: $selectedMode, + isCustomAgentEnabled: isCustomAgentEnabled + ) + + HStack { + Spacer() + AdaptiveHelpLink(action: { NSWorkspace.shared.open( + URL(string: "https://modelcontextprotocol.io/introduction")! + ) }) + } } } .onAppear { setupConfigFilePath() - Task { - await updateMCPFeatureFlag() + if featureFlags.isMCPEnabled { + startMonitoringConfigFile() } } .onDisappear { stopMonitoringConfigFile() } - .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in + .onChange(of: featureFlags.isMCPEnabled) { newMCPFFEnabled in if newMCPFFEnabled { startMonitoringConfigFile() - refreshConfiguration(()) + refreshConfiguration() } else { stopMonitoringConfigFile() } } - .onReceive(DistributedNotificationCenter.default() - .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in - Task { - await updateMCPFeatureFlag() - } + .onChange(of: isCustomAgentEnabled) { isEnabled in + if !isEnabled && !selectedMode.isDefaultAgent { + selectedMode = .defaultAgent } + } + } else if hostAppStore.activeToolsSubTab == .BuiltIn { + BuiltInToolsListView( + selectedMode: $selectedMode, + isCustomAgentEnabled: isCustomAgentEnabled + ) } else { - BuiltInToolsListView() + AutoApproveContainerView() } } - .padding(20) - } - } - } - - private func updateMCPFeatureFlag() async { - do { - let service = try getService() - if let featureFlags = try await service.getCopilotFeatureFlags() { - isMCPFFEnabled = featureFlags.mcp + .padding(.horizontal, 20) } - } catch { - Logger.client.error("Failed to get copilot feature flags: \(error)") } } @@ -101,7 +126,7 @@ struct MCPConfigView: View { try? """ { "servers": { - + } } """.write(to: configFileURL, atomically: true, encoding: .utf8) @@ -153,48 +178,56 @@ struct MCPConfigView: View { } private func startMonitoringConfigFile() { - stopMonitoringConfigFile() // Stop existing monitoring if any + stopMonitoringConfigFile() // Stop existing monitoring if any isMonitoring = true + Logger.client.info("Starting MCP config file monitoring") fileMonitorTask = Task { let configFileURL = URL(fileURLWithPath: configFilePath) // Check for file changes periodically while isMonitoring { - try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 3 seconds + try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 3 second for better responsiveness + + guard isMonitoring else { break } // Extra check after sleep let currentDate = getFileModificationDate(url: configFileURL) if let currentDate = currentDate, currentDate != lastModificationDate { // File modification date has changed, update our record + Logger.client.info("MCP config file change detected") lastModificationDate = currentDate // Read and validate the updated content if let validJson = readAndValidateJSON(from: configFileURL) { await MainActor.run { mcpConfig = validJson - refreshConfiguration(validJson) + refreshConfiguration() toast("MCP configuration file updated", .info) } } else { // If JSON is invalid, show error await MainActor.run { toast("Invalid JSON in MCP configuration file", .error) + Logger.client.info("Invalid JSON detected during monitoring") } } } } + Logger.client.info("Stopped MCP config file monitoring") } } private func stopMonitoringConfigFile() { + guard isMonitoring else { return } + Logger.client.info("Stopping MCP config file monitoring") isMonitoring = false fileMonitorTask?.cancel() fileMonitorTask = nil } - func refreshConfiguration(_: Any) { + func refreshConfiguration() { if MCPConfigView.lastSyncTimestamp == lastModificationDate { return } @@ -206,22 +239,35 @@ struct MCPConfigView: View { UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig) } - Task { - do { - let service = try getService() - try await service.postNotification( - name: Notification.Name - .gitHubCopilotShouldRefreshEditorInformation.rawValue - ) - toast("MCP configuration updated", .info) - } catch { - toast(error.localizedDescription, .error) + // Debounce the refresh notification to avoid sending too frequently + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer(withTimeInterval: MCPConfigView.refreshDebounceInterval, repeats: false) { _ in + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + await MainActor.run { + toast("Fetching MCP tools...", .info) + } + } catch { + await MainActor.run { + toast(error.localizedDescription, .error) + } + } } } } } -#Preview { - MCPConfigView() - .frame(width: 800, height: 600) +extension String { + func padded(centerTo total: Int, with pad: Character = " ") -> String { + guard count < total else { return self } + let deficit = total - count + let left = deficit / 2 + let right = deficit - left + return String(repeating: pad, count: left) + self + String(repeating: pad, count: right) + } } diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift new file mode 100644 index 00000000..3ae5239e --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift @@ -0,0 +1,34 @@ +import SwiftUI +import ConversationServiceProvider + +struct AgentModeDescription { + static func descriptionText(for mode: ConversationMode) -> String { + // Check if it's the built-in "Agent" mode + if mode.isDefaultAgent { + return "The selected tools will be applied globally for all chat sessions that use the default agent." + } + + // Check if it's a custom mode + if !mode.isBuiltIn { + return "The selected tools are configured by the '\(mode.name)' custom agent. Changes to the tools will be applied to the custom agent file as well." + } + + // Other built-in modes (like Plan, etc.) + return "The selected tools are configured by the '\(mode.name)' agent. Changes to the tools are not allowed for now." + } +} + +/// Shared description view for agent modes +struct AgentModeDescriptionView: View { + let selectedMode: ConversationMode + let isLoadingMode: Bool + + var body: some View { + if !isLoadingMode { + Text(AgentModeDescription.descriptionText(for: selectedMode)) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift new file mode 100644 index 00000000..69a028d9 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift @@ -0,0 +1,87 @@ +import Client +import ConversationServiceProvider +import HostAppActivator +import Logger +import Persist +import SwiftUI + +struct AgentModeDropdown: View { + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode + + public init(modes: Binding<[ConversationMode]>, selectedMode: Binding) { + _modes = modes + _selectedMode = selectedMode + } + + var builtInModes: [ConversationMode] { + modes.filter { $0.isBuiltIn } + } + + var customModes: [ConversationMode] { + modes.filter { !$0.isBuiltIn } + } + + var body: some View { + Picker(selection: Binding( + get: { selectedMode.id }, + set: { newId in + if let mode = modes.first(where: { $0.id == newId }) { + selectedMode = mode + } + } + )) { + ForEach(builtInModes, id: \.id) { mode in + Text(mode.name).tag(mode.id) + } + + if !customModes.isEmpty { + Divider() + ForEach(customModes, id: \.id) { mode in + Text(mode.name).tag(mode.id) + } + } + } label: { + Text("Applied for").fontWeight(.bold) + } + .pickerStyle(.menu) + .frame(maxWidth: 300, alignment: .leading) + .padding(.leading, -4) + .onAppear { + loadModes() + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .selectedAgentSubModeDidChange)) { notification in + if let userInfo = notification.userInfo as? [String: String], + let newModeId = userInfo["agentSubMode"], + newModeId != selectedMode.id, + let mode = modes.first(where: { $0.id == newModeId }) { + Logger.client.info("AgentModeDropdown: Mode changed to: \(newModeId)") + selectedMode = mode + } + } + } + + // MARK: - Helper Methods + + private func loadModes() { + Task { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + Logger.client.info("AgentModeDropdown: Fetched \(fetchedModes.count) modes") + await MainActor.run { + modes = fetchedModes.filter { $0.kind == .Agent } + + if !modes.contains(where: { $0.id == selectedMode.id }), + let firstMode = modes.first { + selectedMode = firstMode + } + } + } + } catch { + Logger.client.error("AgentModeDropdown: Failed to load modes: \(error.localizedDescription)") + } + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift new file mode 100644 index 00000000..7a74b12f --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift @@ -0,0 +1,25 @@ +import Client +import Foundation +import Logger +import SharedUIComponents +import SwiftUI + +struct AutoApprovalDisableView: View { + var body: some View { + GroupBox { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle.fill") + .font(.body) + .foregroundColor(.gray) + Text( + "Auto approval is disabled by your organization's policy. To enable it, please contact your administrator. [Get More Info about Copilot policies](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies)" + ) + } + } + .groupBoxStyle( + CardGroupBoxStyle( + backgroundColor: Color(nsColor: .textBackgroundColor) + ) + ) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift new file mode 100644 index 00000000..0c75e42b --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift @@ -0,0 +1,32 @@ +// AutoApproveContainerView.swift +// Container view for the auto-approve feature in Tools Settings +// Created: 2026-01-08 +// +// This view wraps EditsAutoApproveView in a VStack for layout. + +import AppKit +import Logger +import SharedUIComponents +import SwiftUI + +struct AutoApproveContainerView: View { + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + + private var isAutoApprovalEnabled: Bool { + featureFlags.isAgenModeAutoApprovalEnabled && copilotPolicy.isAgentModeAutoApprovalEnabled + } + + var body: some View { + VStack(spacing: 16) { + if isAutoApprovalEnabled { + EditsAutoApproveView() + TerminalAutoApproveView() + MCPAutoApproveView() + } else { + AutoApprovalDisableView() + } + } + .padding(.bottom, 20) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift new file mode 100644 index 00000000..4d9415f6 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift @@ -0,0 +1,280 @@ +import AppKit +import Client +import Logger +import Preferences +import SharedUIComponents +import SwiftUI +import UserDefaultsObserver +import ComposableArchitecture + +struct EditsAutoApproveView: View { + @State private var isExpanded: Bool = true + @StateObject private var viewModel = ViewModel() + @State private var selection = Set() + + let rowHeight: CGFloat = 28 + + private var canRemoveSelection: Bool { + guard !selection.isEmpty else { return false } + return !viewModel.rules.contains { rule in + selection.contains(rule.id) && rule.isDefault + } + } + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse edits auto-approve section" : "Expand edits auto-approve section" }, + title: { Text("Edits Auto-Approve").font(.headline) }, + subtitle: { Text("Controls whether file edits generated by Copilot are approved automatically. Set to **true** to auto-approve edits to matching files; set to **false** to always require explicit approval.") } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 0) { + Divider() + + rulesTable + + Divider() + + toolbar + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .settingsContainerStyle(isExpanded: isExpanded) + .onAppear { + viewModel.loadRules() + } + } + + @ViewBuilder + private var rulesTable: some View { + if #available(macOS 13.5, *) { + Table(viewModel.rules, selection: $selection) { + TableColumn(Text("Pattern").bold()) { rule in + if rule.isDefault { + Text(rule.pattern).help(rule.pattern) + } else { + EditableText("Pattern", text: rule.pattern) { newText in + viewModel.updateRule(id: rule.id, pattern: newText) + } + .help("Click to edit pattern") + } + } + TableColumn("Description") { rule in + if rule.isDefault { + Text(rule.description).help(rule.description) + } else { + EditableText("Description", text: rule.description) { newText in + viewModel.updateRule(id: rule.id, description: newText) + } + .help(rule.description) + } + } + TableColumn("Type") { rule in + Text(rule.isDefault ? "Default" : "Custom") + .foregroundStyle(.secondary) + } + TableColumn("Auto-Approve") { rule in + Toggle(rule.isDefault ? "Default to false" : "", isOn: Binding( + get: { rule.autoApprove }, + set: { viewModel.updateRule(id: rule.id, autoApprove: $0) } + )) + .disabled(rule.isDefault) + } + } + .frame(height: CGFloat(max(viewModel.rules.count, 1)) * rowHeight + 40) + .padding(.horizontal, 20) + .transparentBackground() + } + } + + @ViewBuilder + private var toolbar: some View { + HStack(spacing: 8) { + Button(action: { viewModel.addRule() }) { + Image(systemName: "plus") + } + .foregroundColor(.primary) + .buttonStyle(.borderless) + .padding(.leading, 8) + + Divider() + + Group { + if canRemoveSelection { + Button(action: { + viewModel.removeRules(ids: selection) + selection.removeAll() + }) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } else { + Image(systemName: "minus") + } + } + .foregroundColor( + canRemoveSelection ? .primary : Color( + nsColor: .quaternaryLabelColor + ) + ) + .help("Remove selected rules") + + Spacer() + } + .frame(height: 24) + .background(TertiarySystemFillColor) + } +} + +extension EditsAutoApproveView { + final class ViewModel: ObservableObject { + @Dependency(\.toast) var toast + + struct Rule: Identifiable { + var id = UUID() + var pattern: String + var description: String + var autoApprove: Bool + var isDefault: Bool + } + + @Published var rules: [Rule] = [] + private let defaults = UserDefaults.autoApproval + private var observer = UserDefaultsObserver( + object: UserDefaults.autoApproval, + forKeyPaths: [UserDefaultPreferenceKeys().sensitiveFilesGlobalApprovals.key], + context: nil + ) + + private let defaultRules: [Rule] = [ + Rule(pattern: "**/.github/instructions/*", description: "Github instructions files", autoApprove: false, isDefault: true), + Rule(pattern: "**/github-copilot/**/*", description: "Github Copilot settings and token files", autoApprove: false, isDefault: true), + ] + + init() { + observer.onChange = { [weak self] in + DispatchQueue.main.async { + self?.loadRules() + } + } + } + + func loadRules() { + var loadedRules: [Rule] = [] + + // Load from UserDefaults + let state = defaults.value(for: \.sensitiveFilesGlobalApprovals) + let savedRules = state.rules + + func findExistingID(pattern: String) -> UUID { + return rules.first(where: { $0.pattern == pattern })?.id ?? UUID() + } + + // Add default rules first + for defaultRule in defaultRules { + var rule = defaultRule + // If it exists in persisted config, override properties that can be changed (autoApprove) + // We keep the default description unless we want to allow overriding it. + if let savedRule = savedRules[defaultRule.pattern] { + rule.autoApprove = savedRule.autoApprove + if !savedRule.description.isEmpty { + rule.description = savedRule.description + } + } + rule.id = findExistingID(pattern: rule.pattern) + loadedRules.append(rule) + } + + // Add custom rules + for (patternKey, value) in savedRules { + // Skip if it's a default rule + if defaultRules.contains(where: { $0.pattern == patternKey }) { continue } + + let id = findExistingID(pattern: patternKey) + + loadedRules.append(Rule(id: id, pattern: patternKey, description: value.description, autoApprove: value.autoApprove, isDefault: false)) + } + + rules = loadedRules.sorted { + if $0.isDefault != $1.isDefault { + return $0.isDefault // Defaults first + } + return $0.pattern < $1.pattern + } + } + + func addRule() { + var counter = 0 + var newPattern = "New Pattern" + while rules.contains(where: { $0.pattern == newPattern }) { + counter += 1 + newPattern = "New Pattern \(counter)" + } + rules.append(Rule(pattern: newPattern, description: "Description", autoApprove: false, isDefault: false)) + saveRules() + } + + func removeRules(ids: Set) { + rules.removeAll { ids.contains($0.id) && !$0.isDefault } + saveRules() + } + + @discardableResult + func updateRule(id: UUID, pattern: String? = nil, description: String? = nil, autoApprove: Bool? = nil) -> Bool { + guard let index = rules.firstIndex(where: { $0.id == id }) else { return false } + + if let pattern { + var newPattern = pattern.filter { !$0.isNewline } + newPattern = newPattern.trimmingCharacters(in: .whitespacesAndNewlines) + + if !rules.contains(where: { $0.id != id && $0.pattern == newPattern }) { + rules[index].pattern = newPattern + } else { + toast("Duplicate patterns are not allowed. Please ensure each rule has a unique pattern.", .warning) + return false + } + } + if let description { rules[index].description = description } + if let autoApprove { rules[index].autoApprove = autoApprove } + + saveRules() + return true + } + + func saveRules() { + // Check for duplicate patterns + let patterns = rules.map(\.pattern) + let uniquePatterns = Set(patterns) + if patterns.count != uniquePatterns.count { + return + } + + var state = defaults.value(for: \.sensitiveFilesGlobalApprovals) + var newRules: [String: SensitiveFileRule] = [:] + + for rule in rules { + newRules[rule.pattern] = SensitiveFileRule(description: rule.description, autoApprove: rule.autoApprove) + } + + state.rules = newRules + defaults.set(state, for: \.sensitiveFilesGlobalApprovals) + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift new file mode 100644 index 00000000..962f2630 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift @@ -0,0 +1,294 @@ +import AppKit +import Combine +import Client +import GitHubCopilotService +import Logger +import Preferences +import SharedUIComponents +import SwiftUI +import UserDefaultsObserver + +struct MCPAutoApproveView: View { + @State private var isExpanded: Bool = true + @StateObject private var viewModel = ViewModel() + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse MCP auto-approve section" : "Expand MCP auto-approve section" }, + title: { Text("MCP Auto-Approve").font(.headline) }, + subtitle: { Text("Controls whether MCP tool calls triggered by Copilot are automatically approved. You can enable MCP auto-approval per server or per tool.") } + ) + + if isExpanded { + Divider() + AgentTrustToolAnnotationsSetting() + .padding(.horizontal, 26) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + Divider() + if #available(macOS 14.0, *) { + if viewModel.rows.isEmpty { + Text(noRunningServersMessage) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .environment(\.openURL, OpenURLAction { url in + if url.scheme == "action", url.host == "open-mcp-tab" { + hostAppStore.send(.setActiveTab(.tools)) + hostAppStore.send(.setActiveToolsSubTab(.MCP)) + return .handled + } + NSWorkspace.openFileInXcode(fileURL: url) + return .handled + }) + .frame(maxWidth: .infinity, alignment: .center) + .padding() + .background(QuaternarySystemFillColor.opacity(0.75)) + } else { + Table(viewModel.rows, children: \.children) { + TableColumn(Text("MCP Server").bold()) { row in + HStack(alignment: .center, spacing: 4) { + if case .runAny = row.type { + Image(systemName: "play.rectangle.on.rectangle") + .foregroundColor(.secondary) + } else if case .tool = row.type { + Image(systemName: "play.rectangle.on.rectangle") + .opacity(0) + .accessibilityHidden(true) + } + + Text(row.title) + if case .tool = row.type { + Text("without approval") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + TableColumn("Auto-Approve") { row in + if case .server = row.type { + EmptyView() + } else { + Toggle(isOn: binding(for: row)) { + Text("") + } + .toggleStyle(CheckboxToggleStyle()) + .labelsHidden() + } + } + .width(100) + } + .frame(minHeight: 300, maxHeight: .infinity) + .transparentBackground() + .padding(.horizontal, 10) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + } + } + .settingsContainerStyle(isExpanded: isExpanded) + } + + private var noRunningServersMessage: AttributedString { + var text = AttributedString(localized: "No running MCP servers found. Please verify the status in the MCP section or add configs in mcp.json.") + if let range = text.range(of: "mcp.json") { + text[range].link = URL(fileURLWithPath: mcpConfigFilePath) + } + if let range = text.range(of: "MCP section") { + text[range].link = URL(string: "action://open-mcp-tab") + } + return text + } + + private func binding(for row: RowItem) -> Binding { + Binding( + get: { + switch row.type { + case .server(let name): + return viewModel.isServerAllowed(name) + case .runAny(let serverName): + return viewModel.isServerAllowed(serverName) + case .tool(let serverName, let toolName): + return viewModel.isToolAllowed(serverName: serverName, toolName: toolName) + } + }, + set: { newValue in + switch row.type { + case .server(let name), .runAny(let name): + viewModel.setServerAllowed(name, allowed: newValue) + case .tool(let serverName, let toolName): + viewModel.setToolAllowed(serverName, toolName: toolName, allowed: newValue) + } + } + ) + } +} + +struct RowItem: Identifiable { + let id: String + let title: String + let type: ItemType + var children: [RowItem]? +} + +enum ItemType: Equatable { + case server(String) + case runAny(serverName: String) + case tool(serverName: String, toolName: String) +} + +extension MCPAutoApproveView { + @MainActor + class ViewModel: ObservableObject { + @Published var rows: [RowItem] = [] + private var serverTools: [MCPServerToolsCollection] = [] + private var approvals: AutoApprovedMCPServers = AutoApprovedMCPServers() + private var cancellables = Set() + + private let mcpToolManager = CopilotMCPToolManagerObservable.shared + private var observer: UserDefaultsObserver? + + @Environment(\.toast) private var toast + + init() { + // Observe tools availability + mcpToolManager.$availableMCPServerTools + .sink { [weak self] tools in + guard let self = self else { return } + self.serverTools = tools + self.rebuildRows() + } + .store(in: &cancellables) + + // Observe user defaults + observer = UserDefaultsObserver( + object: UserDefaults.autoApproval, + forKeyPaths: [UserDefaultPreferenceKeys().mcpServersGlobalApprovals.key], + context: nil + ) + + observer?.onChange = { [weak self] in + guard let self = self else { return } + DispatchQueue.main.async { + self.loadApprovals() + } + } + + // Initial load so the table reflects saved state on first appearance. + loadApprovals() + } + + private func rebuildRows() { + rows = serverTools + .filter { $0.status == .running } + .map { server in + let isAllowed = approvals.servers[server.name]?.isServerAllowed ?? false + var children: [RowItem] = [] + + // "Run any tool" row + children.append(RowItem( + id: "run-any-\(server.name)", + title: "Run any tool without approval", + type: .runAny(serverName: server.name), + children: nil + )) + + // Tools rows (only if not allowed globally) + if !isAllowed { + let toolRows = server.tools.map { tool in + RowItem( + id: "tool-\(server.name)-\(tool.name)", + title: tool.name, + type: .tool(serverName: server.name, toolName: tool.name), + children: nil + ) + } + children.append(contentsOf: toolRows) + } + + return RowItem( + id: "server-\(server.name)", + title: server.name, + type: .server(server.name), + children: children + ) + } + } + + private func loadApprovals() { + self.approvals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + rebuildRows() + } + + func isServerAllowed(_ serverName: String) -> Bool { + return approvals.servers[serverName]?.isServerAllowed ?? false + } + + func isToolAllowed(serverName: String, toolName: String) -> Bool { + return approvals.servers[serverName]?.allowedTools.contains(toolName) ?? false + } + + func setServerAllowed(_ serverName: String, allowed: Bool) { + var currentApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + var serverState = currentApprovals.servers[serverName] ?? MCPServerApprovalState() + + serverState.isServerAllowed = allowed + currentApprovals.servers[serverName] = serverState + + save(currentApprovals) + // Rebuild happens via observer + } + + func setToolAllowed(_ serverName: String, toolName: String, allowed: Bool) { + var currentApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + var serverState = currentApprovals.servers[serverName] ?? MCPServerApprovalState() + + if allowed { + serverState.allowedTools.insert(toolName) + } else { + serverState.allowedTools.remove(toolName) + } + currentApprovals.servers[serverName] = serverState + + save(currentApprovals) + } + + private func save(_ approvals: AutoApprovedMCPServers) { + UserDefaults.autoApproval.set(approvals, for: \.mcpServersGlobalApprovals) + notifyChange() + } + + private func notifyChange() { + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } + } +} + +struct AgentTrustToolAnnotationsSetting: View { + @AppStorage(\.trustToolAnnotations) var trustToolAnnotations + + var body: some View { + SettingsToggle( + title: "Trust MCP Tool Annotations", + subtitle: "If enabled, Copilot will use tool annotations to decide whether to automatically approve readonly MCP tool calls.", + isOn: $trustToolAnnotations + ) + .onChange(of: trustToolAnnotations) { _ in + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentTrustToolAnnotationsDidChange, object: nil) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift new file mode 100644 index 00000000..bec514f8 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift @@ -0,0 +1,233 @@ +import AppKit +import Client +import Logger +import Preferences +import SharedUIComponents +import SwiftUI +import UserDefaultsObserver +import ComposableArchitecture + +struct TerminalAutoApproveView: View { + @State private var isExpanded: Bool = true + @StateObject private var viewModel = ViewModel() + @State private var selection = Set() + + let rowHeight: CGFloat = 28 + + private var canRemoveSelection: Bool { + !selection.isEmpty + } + + var body: some View { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse terminal auto-approve section" : "Expand terminal auto-approve section" }, + title: { Text("Terminal Auto-Approve").font(.headline) }, + subtitle: { + Text( + "Controls whether chat-initiated terminal commands are automatically approved. Set to **true** to auto-approve matching commands; set to **false** to always require explicit approval." + ) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 0) { + Divider() + + rulesTable + + Divider() + + toolbar + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .settingsContainerStyle(isExpanded: isExpanded) + .onAppear { + viewModel.loadRules() + } + } + + @ViewBuilder + private var rulesTable: some View { + Table(viewModel.rules, selection: $selection) { + TableColumn("Command") { rule in + EditableText("Command", text: rule.command) { newText in + viewModel.updateRule(id: rule.id, command: newText) + } + .help("Click to edit command") + } + TableColumn("Auto-Approve") { rule in + Toggle("", isOn: Binding( + get: { rule.autoApprove }, + set: { viewModel.updateRule(id: rule.id, autoApprove: $0) } + )) + } + } + .frame(height: CGFloat(max(viewModel.rules.count, 1)) * rowHeight + 42) + .padding(.horizontal, 20) + .transparentBackground() + } + + @ViewBuilder + private var toolbar: some View { + HStack(spacing: 8) { + Button(action: { viewModel.addRule() }) { + Image(systemName: "plus") + } + .foregroundColor(.primary) + .buttonStyle(.borderless) + .padding(.leading, 8) + + Divider() + + Group { + if canRemoveSelection { + Button(action: { + viewModel.removeRules(ids: selection) + selection.removeAll() + }) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } else { + Image(systemName: "minus") + } + } + .foregroundColor( + canRemoveSelection ? .primary : Color( + nsColor: .quaternaryLabelColor + ) + ) + .help("Remove selected rules") + + Spacer() + } + .frame(height: 24) + .background(TertiarySystemFillColor) + } +} + +extension TerminalAutoApproveView { + final class ViewModel: ObservableObject { + @Dependency(\.toast) var toast + + struct Rule: Identifiable { + var id = UUID() + var command: String + var autoApprove: Bool + } + + @Published var rules: [Rule] = [] + + private let defaults = UserDefaults.autoApproval + private var observer = UserDefaultsObserver( + object: UserDefaults.autoApproval, + forKeyPaths: [UserDefaultPreferenceKeys().terminalCommandsGlobalApprovals.key], + context: nil + ) + + init() { + observer.onChange = { [weak self] in + DispatchQueue.main.async { + self?.loadRules() + } + } + } + + func loadRules() { + let state = defaults.value(for: \.terminalCommandsGlobalApprovals) + let savedRules = state.commands + + func findExistingID(command: String) -> UUID { + rules.first(where: { $0.command == command })?.id ?? UUID() + } + + var loadedRules: [Rule] = [] + for (commandKey, autoApprove) in savedRules { + loadedRules.append( + Rule(id: findExistingID(command: commandKey), command: commandKey, autoApprove: autoApprove) + ) + } + + rules = loadedRules.sorted { $0.command.localizedCaseInsensitiveCompare($1.command) == .orderedAscending } + } + + func addRule() { + var counter = 0 + var newCommand = "New Command" + while rules.contains(where: { $0.command == newCommand }) { + counter += 1 + newCommand = "New Command \(counter)" + } + rules.append(Rule(command: newCommand, autoApprove: false)) + saveRules() + } + + func removeRules(ids: Set) { + rules.removeAll { ids.contains($0.id) } + saveRules() + } + + @discardableResult + func updateRule(id: UUID, command: String? = nil, autoApprove: Bool? = nil) -> Bool { + guard let index = rules.firstIndex(where: { $0.id == id }) else { return false } + + if let command { + var newCommand = command.filter { !$0.isNewline } + newCommand = newCommand.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !newCommand.isEmpty else { + toast("Command cannot be empty.", .warning) + return false + } + + if !rules.contains(where: { $0.id != id && $0.command == newCommand }) { + rules[index].command = newCommand + } else { + toast("Duplicate commands are not allowed. Please ensure each rule has a unique command.", .warning) + return false + } + } + if let autoApprove { rules[index].autoApprove = autoApprove } + + saveRules() + return true + } + + func saveRules() { + let commands = rules.map(\.command) + let uniqueCommands = Set(commands) + if commands.count != uniqueCommands.count { + return + } + if commands.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { + toast("Command cannot be empty.", .warning) + return + } + + var state = defaults.value(for: \.terminalCommandsGlobalApprovals) + var newRules: [String: Bool] = [:] + for rule in rules { + newRules[rule.command] = rule.autoApprove + } + state.commands = newRules + defaults.set(state, for: \.terminalCommandsGlobalApprovals) + + Task { + do { + let service = try getService() + try await service.postNotification( + name: Notification.Name.githubCopilotAgentAutoApprovalDidChange.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift index 021ea437..6cdd7a0c 100644 --- a/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift +++ b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift @@ -5,12 +5,16 @@ import GitHubCopilotService import Logger import Persist import SwiftUI +import SharedUIComponents struct BuiltInToolsListView: View { @ObservedObject private var builtInToolManager = CopilotBuiltInToolManagerObservable.shared @State private var isSearchBarVisible: Bool = false @State private var searchText: String = "" @State private var toolEnabledStates: [String: Bool] = [:] + @State private var modes: [ConversationMode] = [] + @Binding var selectedMode: ConversationMode + let isCustomAgentEnabled: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -21,23 +25,51 @@ struct BuiltInToolsListView: View { } .onAppear { initializeToolStates() + // Refresh client tools to get any late-arriving server tools + Task { + do { + let service = try getService() + _ = try await service.refreshClientTools() + } catch { + Logger.client.error("Failed to refresh client tools: \(error)") + } + } } .onChange(of: builtInToolManager.availableLanguageModelTools) { _ in initializeToolStates() } + .onChange(of: selectedMode) { _ in + toolEnabledStates = [:] // Clear state immediately + initializeToolStates() + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in + Logger.client.info("Custom agent tools change notification received in BuiltInToolsListView") + if !selectedMode.isDefaultAgent { + Task { + await reloadModesAndUpdateStates() + } + } + } } // MARK: - Header View private var headerView: some View { - HStack(alignment: .center) { - Text("Built-In Tools").fontWeight(.bold) - Spacer() - SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Built-In Tools").fontWeight(.bold) + if isCustomAgentEnabled { + AgentModeDropdown(modes: $modes, selectedMode: $selectedMode) + } + Spacer() + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + } + .clipped() + + AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false) } - .clipped() } - + // MARK: - Content View private var contentView: some View { @@ -61,6 +93,7 @@ struct BuiltInToolsListView: View { toolStatus: tool.status, isServerEnabled: true, isToolEnabled: toolBindingFor(tool), + isInteractionAllowed: isInteractionAllowed(), onToolToggleChanged: { isEnabled in handleToolToggleChange(tool: tool, isEnabled: isEnabled) } @@ -72,21 +105,19 @@ struct BuiltInToolsListView: View { // MARK: - Helper Methods private func initializeToolStates() { + // When mode changes, recalculate everything from scratch var map: [String: Bool] = [:] for tool in builtInToolManager.availableLanguageModelTools { - // Preserve existing state if already toggled locally - if let existing = toolEnabledStates[tool.name] { - map[tool.name] = existing - } else { - map[tool.name] = (tool.status == .enabled) - } + map[tool.name] = isToolEnabledInMode(tool) } toolEnabledStates = map } private func toolBindingFor(_ tool: LanguageModelTool) -> Binding { Binding( - get: { toolEnabledStates[tool.name] ?? (tool.status == .enabled) }, + get: { + toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool) + }, set: { newValue in toolEnabledStates[tool.name] = newValue } @@ -105,7 +136,6 @@ struct BuiltInToolsListView: View { } private func handleToolToggleChange(tool: LanguageModelTool, isEnabled: Bool) { - // Optimistically update local state already done in binding. let toolUpdate = ToolStatusUpdate(name: tool.name, status: isEnabled ? .enabled : .disabled) updateToolStatus([toolUpdate]) } @@ -114,17 +144,73 @@ struct BuiltInToolsListView: View { Task { do { let service = try getService() - let updatedTools = try await service.updateToolsStatus(toolUpdates) - if updatedTools == nil { - Logger.client.error("Failed to update built-in tool status: No updated tools returned") + + if !selectedMode.isDefaultAgent { + let chatMode = selectedMode.kind + let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil + let workspaceFolders = await getWorkspaceFolders() + + let updatedTools = try await service + .updateToolsStatus( + toolUpdates, + chatAgentMode: chatMode, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders + ) + + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } + + await reloadModesAndUpdateStates() + } else { + let updatedTools = try await service.updateToolsStatus(toolUpdates) + if updatedTools == nil { + Logger.client.error("Failed to update built-in tool status: No updated tools returned") + } } - // CopilotLanguageModelToolManager will broadcast changes; our local - // toolEnabledStates keep rows visible even if disabled. } catch { Logger.client.error("Failed to update built-in tool status: \(error.localizedDescription)") } } } + + @MainActor + private func reloadModesAndUpdateStates() async { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + modes = fetchedModes.filter { $0.kind == .Agent } + + if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) { + selectedMode = updatedMode + + for tool in builtInToolManager.availableLanguageModelTools { + if let customTools = updatedMode.customTools { + toolEnabledStates[tool.name] = customTools.contains(tool.name) + } else { + toolEnabledStates[tool.name] = false + } + } + } + } + } catch { + Logger.client.error("Failed to reload modes: \(error.localizedDescription)") + } + } + + private func isToolEnabledInMode(_ tool: LanguageModelTool) -> Bool { + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: selectedMode + ) + } + + private func isInteractionAllowed() -> Bool { + return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode) + } } /// Empty state view when no tools are available diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift index d493b8be..8c3444d2 100644 --- a/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift +++ b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift @@ -17,6 +17,7 @@ class CopilotMCPToolManagerObservable: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } + Logger.client.info("MCP tools change notification received") Task { await self.refreshMCPServerTools() } @@ -31,6 +32,7 @@ class CopilotMCPToolManagerObservable: ObservableObject { @MainActor private func refreshMCPServerTools() async { + Logger.client.info("Refreshing MCP server tools...") do { let service = try getService() let mcpTools = try await service.getAvailableMCPServerToolsCollections() @@ -43,11 +45,14 @@ class CopilotMCPToolManagerObservable: ObservableObject { private func refreshTools(tools: [MCPServerToolsCollection]?) { guard let tools = tools else { // nil means the tools data is ready, and skip it first. + Logger.client.info("MCP tools data not ready yet, skipping refresh") return } - AppState.shared.cleanupMCPToolsStatus(availableTools: tools) - AppState.shared.createMCPToolsStatus(tools) + let totalToolsCount = tools.reduce(0) { $0 + $1.tools.count } + let serverNames = tools.map { $0.name }.joined(separator: ", ") + Logger.client.info("Refreshed MCP tools - Servers: \(tools.count), Total tools: \(totalToolsCount), Server names: [\(serverNames)]") + self.availableMCPServerTools = tools } } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift b/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift deleted file mode 100644 index f6d16d98..00000000 --- a/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Persist -import GitHubCopilotService -import Foundation - -public let MCP_TOOLS_STATUS = "mcpToolsStatus" - -extension AppState { - public func getMCPToolsStatus() -> [UpdateMCPToolsStatusServerCollection]? { - guard let savedJSON = get(key: MCP_TOOLS_STATUS), - let data = try? JSONEncoder().encode(savedJSON), - let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) else { - return nil - } - return savedStatus - } - - public func updateMCPToolsStatus(_ servers: [UpdateMCPToolsStatusServerCollection]) { - var existingServers = getMCPToolsStatus() ?? [] - - // Update or add servers - for newServer in servers { - if let existingIndex = existingServers.firstIndex(where: { $0.name == newServer.name }) { - // Update existing server - let updatedTools = mergeTools(original: existingServers[existingIndex].tools, new: newServer.tools) - existingServers[existingIndex].tools = updatedTools - } else { - // Add new server - existingServers.append(newServer) - } - } - - update(key: MCP_TOOLS_STATUS, value: existingServers) - } - - private func mergeTools(original: [UpdatedMCPToolsStatus], new: [UpdatedMCPToolsStatus]) -> [UpdatedMCPToolsStatus] { - var result = original - - for newTool in new { - if let index = result.firstIndex(where: { $0.name == newTool.name }) { - result[index].status = newTool.status - } else { - result.append(newTool) - } - } - - return result - } - - public func createMCPToolsStatus(_ serverCollections: [MCPServerToolsCollection]) { - var existingServers = getMCPToolsStatus() ?? [] - var serversChanged = false - - for serverCollection in serverCollections { - // Find or create a server entry - let serverIndex = existingServers.firstIndex(where: { $0.name == serverCollection.name }) - var toolsToUpdate: [UpdatedMCPToolsStatus] - - if let index = serverIndex { - toolsToUpdate = existingServers[index].tools - } else { - toolsToUpdate = [] - serversChanged = true - } - - // Add new tools with default enabled status - let existingToolNames = Set(toolsToUpdate.map { $0.name }) - let newTools = serverCollection.tools - .filter { !existingToolNames.contains($0.name) } - .map { UpdatedMCPToolsStatus(name: $0.name, status: .enabled) } - - if !newTools.isEmpty { - serversChanged = true - toolsToUpdate.append(contentsOf: newTools) - } - - // Update or add the server - if let index = serverIndex { - existingServers[index].tools = toolsToUpdate - } else { - existingServers.append(UpdateMCPToolsStatusServerCollection( - name: serverCollection.name, - tools: toolsToUpdate - )) - } - } - - // Only update storage if changes were made - if serversChanged { - update(key: MCP_TOOLS_STATUS, value: existingServers) - } - } - - public func cleanupMCPToolsStatus(availableTools: [MCPServerToolsCollection]) { - guard var existingServers = getMCPToolsStatus() else { return } - - // Get all available server names and their respective tool names - let availableServerMap = Dictionary( - uniqueKeysWithValues: availableTools.map { collection in - (collection.name, Set(collection.tools.map { $0.name })) - } - ) - - // Remove servers that don't exist in available tools - existingServers.removeAll { !availableServerMap.keys.contains($0.name) } - - // For each remaining server, remove tools that don't exist in available tools - for i in 0..) { - _isMCPFFEnabled = isMCPFFEnabled + public init(isMCPFFEnabled: Bool) { + self.isMCPFFEnabled = isMCPFFEnabled } var body: some View { @@ -35,11 +35,11 @@ struct MCPIntroView: View { } #Preview { - MCPIntroView(isMCPFFEnabled: .constant(true)) + MCPIntroView(isMCPFFEnabled: true) .frame(width: 800) } #Preview { - MCPIntroView(isMCPFFEnabled: .constant(false)) + MCPIntroView(isMCPFFEnabled: false) .frame(width: 800) } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift index 36334622..6909b851 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift @@ -10,8 +10,8 @@ struct MCPManualInstallView: View { VStack(spacing: 0) { DisclosureSettingsRow( isExpanded: $isExpanded, - accessibilityLabel: { $0 ? "Collapse manual install section" : "Expand manual install section" }, - title: { Text("Manual Install").font(.headline) }, + accessibilityLabel: { $0 ? "Collapse MCP configuration section" : "Expand MCP configuration section" }, + title: { Text("MCP Configuration").font(.headline) }, subtitle: { Text("Add MCP Servers to power AI with tools for files, databases, and external APIs.") }, actions: { HStack(spacing: 8) { @@ -55,7 +55,6 @@ struct MCPManualInstallView: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Text("Example Configuration").foregroundColor(.primary.opacity(0.85)) - CopyButton( copy: { NSPasteboard.general.clearContents() @@ -76,15 +75,7 @@ struct MCPManualInstallView: View { .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) } } - .cornerRadius(12) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .inset(by: 0.5) - .stroke(SecondarySystemFillColor, lineWidth: 1) - .animation(.easeInOut(duration: 0.3), value: isExpanded) - ) - .animation(.easeInOut(duration: 0.3), value: isExpanded) + .settingsContainerStyle(isExpanded: isExpanded) } var exampleConfig: String { @@ -152,9 +143,3 @@ struct MCPManualInstallView: View { NSWorkspace.shared.open(url) } } - -#Preview { - MCPManualInstallView() - .padding() - .frame(width: 900) -} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift new file mode 100644 index 00000000..c54712ca --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift @@ -0,0 +1,435 @@ +import Client +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +// MARK: - Installation Option + +public struct InstallationOption { + public let displayName: String + public let description: String + public let config: [String: Any] + public let isDefault: Bool + + public init(displayName: String, description: String, config: [String: Any], isDefault: Bool = false) { + self.displayName = displayName + self.description = description + self.config = config + self.isDefault = isDefault + } +} + +// MARK: - Registry Types + +private struct RegistryType { + let displayName: String + let commandName: String + + func buildArguments(for package: Package) -> [String] { + let identifier = package.identifier + let version = package.version ?? "" + + switch package.registryType { + case "npm": + return ["-y", version.isEmpty ? identifier : "\(identifier)@\(version)"] + case "pypi": + return [version.isEmpty ? identifier : "\(identifier)==\(version)"] + case "oci": + return ["run", "-i", "--rm", version.isEmpty ? identifier : "\(identifier):\(version)"] + case "nuget": + var args = [version.isEmpty ? identifier : "\(identifier)@\(version)", "--yes"] + if package.packageArguments?.isEmpty == false { args.append("--") } + return args + default: + return [version.isEmpty ? identifier : "\(identifier)@\(version)"] + } + } +} + +private let registryTypes: [String: RegistryType] = [ + "npm": RegistryType(displayName: "NPM", commandName: "npx"), + "pypi": RegistryType(displayName: "PyPI", commandName: "uvx"), + "oci": RegistryType(displayName: "OCI", commandName: "docker"), + "nuget": RegistryType(displayName: "NuGet", commandName: "dnx") +] + +public extension Remote { + var transportType: TransportType { + switch self { + case .streamableHTTP(let transport): + return transport.type + case .sse(let transport): + return transport.type + } + } + + var url: String { + switch self { + case .streamableHTTP(let transport): + return transport.url + case .sse(let transport): + return transport.url + } + } + + var headers: [KeyValueInput]? { + switch self { + case .streamableHTTP(let transport): + return transport.headers + case .sse(let transport): + return transport.headers + } + } +} + +// MARK: - MCP Registry Service + +@MainActor +public class MCPRegistryService: ObservableObject { + public static let shared = MCPRegistryService() + public static let apiVersion = "v0.1" + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL + + private init() {} + + public static func getServerName(from serverDetail: MCPRegistryServerDetail) -> String { + return serverDetail.name + } + + public func getRegistryBaseURL() throws -> String { + let url = mcpRegistryBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + throw MCPRegistryError.registryURLNotConfigured + } + return url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + public func getRegistryURL() throws -> String { + return try getRegistryBaseURL() + "/\(MCPRegistryService.apiVersion)/servers" + } + + // MARK: - Installation Options + + public func getAllInstallationOptions(for serverDetail: MCPRegistryServerDetail) -> [InstallationOption] { + var options: [InstallationOption] = [] + + // Add remote options + serverDetail.remotes?.enumerated().forEach { index, remote in + let config = createServerConfig(for: serverDetail, remote: remote) + options.append(InstallationOption( + displayName: "\(remote.transportType.displayText): \(remote.url)", + description: "Connect to remote server at \(remote.url)", + config: config, + isDefault: index == 0 && options.isEmpty + )) + } + + // Add package options + serverDetail.packages?.enumerated().forEach { index, package in + let config = createServerConfig(for: serverDetail, package: package) + let registryDisplay = package.registryType.registryDisplayText + + options.append(InstallationOption( + displayName: "\(registryDisplay) : \(package.identifier)", + description: "Install \(package.identifier) from \(registryDisplay)", + config: config, + isDefault: index == 0 && options.isEmpty + )) + } + + return options + } + + public func createServerConfiguration(for serverDetail: MCPRegistryServerDetail) throws -> [String: Any] { + let options = getAllInstallationOptions(for: serverDetail) + guard let defaultOption = options.first(where: { $0.isDefault }) ?? options.first else { + throw MCPRegistryError.noInstallationOptionsAvailable(serverName: serverDetail.name) + } + return defaultOption.config + } + + // MARK: - Install/Uninstall Operations + + public func installMCPServer(_ serverDetail: MCPRegistryServerDetail, installationOption: InstallationOption? = nil) async throws { + Logger.client.info("Installing MCP Server '\(serverDetail.name)'...") + + let serverConfig: [String: Any] + if let option = installationOption { + serverConfig = option.config + } else { + serverConfig = try createServerConfiguration(for: serverDetail) + } + + var currentConfig = loadConfiguration() ?? [:] + if currentConfig["servers"] == nil { + currentConfig["servers"] = [String: Any]() + } + + guard var serversDict = currentConfig["servers"] as? [String: Any] else { + throw MCPRegistryError.invalidConfigurationStructure + } + + serversDict[serverDetail.name] = serverConfig + currentConfig["servers"] = serversDict + + try saveConfiguration(currentConfig) + Logger.client.info("Successfully installed MCP Server '\(serverDetail.name)'") + } + + public func uninstallMCPServer(_ serverDetail: MCPRegistryServerDetail) async throws { + Logger.client.info("Uninstalling MCP Server '\(serverDetail.name)'...") + + var currentConfig = loadConfiguration() ?? [:] + guard var serversDict = currentConfig["servers"] as? [String: Any] else { + throw MCPRegistryError.serverNotFound(serverName: serverDetail.name) + } + + guard serversDict[serverDetail.name] != nil else { + throw MCPRegistryError.serverNotFound(serverName: serverDetail.name) + } + + serversDict.removeValue(forKey: serverDetail.name) + currentConfig["servers"] = serversDict + + try saveConfiguration(currentConfig) + Logger.client.info("Successfully uninstalled MCP Server '\(serverDetail.name)'") + } + + // MARK: - Configuration Creation + + public func createServerConfig(for serverDetail: MCPRegistryServerDetail, remote: Remote) -> [String: Any] { + var config: [String: Any] = [ + "type": "http", + "url": remote.url + ] + + // Add headers if present + if let headers = remote.headers, !headers.isEmpty { + let headersDict = Dictionary(headers.map { ($0.name, $0.value ?? "") }) { first, _ in first } + config["requestInit"] = ["headers": headersDict] + } + + addMetadata(to: &config, serverDetail: serverDetail) + return config + } + + public func createServerConfig(for serverDetail: MCPRegistryServerDetail, package: Package) -> [String: Any] { + let registryType = registryTypes[package.registryType] + let command = package.runtimeHint ?? registryType?.commandName ?? package.registryType + + var config: [String: Any] = [ + "type": "stdio", + "command": command + ] + + // Build arguments + var args: [String] = [] + + // Runtime arguments + package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + + // Default arguments if no runtime arguments + if package.runtimeArguments?.isEmpty != false { + args + .append( + contentsOf: registryType?.buildArguments(for: package) ?? [package.identifier] + ) + } + + // Package arguments + package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + + config["args"] = args + + // Environment variables + if let envVars = package.environmentVariables, !envVars.isEmpty { + config["env"] = Dictionary(envVars.map { ($0.name, $0.value ?? "") }) { first, _ in first } + } + + addMetadata(to: &config, serverDetail: serverDetail) + return config + } + + private func addMetadata(to config: inout [String: Any], serverDetail: MCPRegistryServerDetail) { + guard let baseURL = try? getRegistryBaseURL() else { return } + + let api: [String: Any] = [ + "baseUrl": baseURL, + "version": MCPRegistryService.apiVersion + ] + + let mcpServer: [String: Any] = [ + "name": Self.getServerName(from: serverDetail), + "version": serverDetail.version + ] + + config["x-metadata"] = [ + "registry": [ + "api": api, + "mcpServer": mcpServer + ] + ] + } + + private func extractArgumentValues(from argument: Argument) -> [String] { + switch argument { + case let .positional(positionalArg): + return (positionalArg.value ?? positionalArg.valueHint).map { [$0] } ?? [] + case let .named(namedArg): + return [namedArg.name] + (namedArg.value.map { [$0] } ?? []) + } + } + + // MARK: - Configuration File Management + + private func loadConfiguration() -> [String: Any]? { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return jsonObject + } + + private func saveConfiguration(_ config: [String: Any]) throws { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + + // Ensure directory exists + let configDirectory = configFileURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: configDirectory.path) { + try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) + } + + // Save configuration + let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted]) + try jsonData.write(to: configFileURL) + + // Note: UserDefaults update and notification will be handled by ToolsConfigView's file monitor + // with debouncing to prevent duplicate notifications + } + + // MARK: - Server Installation Status + + public func isServerInstalled(_ serverDetail: MCPRegistryServerDetail) -> Bool { + guard let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + return serversDict.values.contains { (value) -> Bool in + guard let serverConfigDict = value as? [String: Any], + let key = registryKey(from: serverConfigDict) else { return false } + return key == expectedKey + } + } + + // MARK: - Option Installed Helpers + + public func isPackageOptionInstalled(serverDetail: MCPRegistryServerDetail, package: Package) -> Bool { + guard isServerInstalled(serverDetail), + let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + + let command = package.runtimeHint ?? registryTypes[package.registryType]?.commandName ?? ( + package.registryType + ) + let expectedArgsFirst: String? = { + var args: [String] = [] + package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + if package.runtimeArguments?.isEmpty != false { + args.append( + contentsOf: registryTypes[package.registryType]?.buildArguments(for: package) ?? [package.identifier] + ) + } + package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } + return args.first + }() + + return serversDict.values.contains { value in + guard let cfg = value as? [String: Any], + let key = registryKey(from: cfg), + key == expectedKey, + (cfg["type"] as? String)?.lowercased() == "stdio", + let c = cfg["command"] as? String, + let args = cfg["args"] as? [String] else { return false } + return c == command && args.first == expectedArgsFirst + } + } + + public func isRemoteOptionInstalled(serverDetail: MCPRegistryServerDetail, remote: Remote) -> Bool { + guard isServerInstalled(serverDetail), + let config = loadConfiguration(), + let serversDict = config["servers"] as? [String: Any], + let expectedKey = expectedRegistryKey(for: serverDetail) else { return false } + + return serversDict.values.contains { value in + guard let cfg = value as? [String: Any], + let key = registryKey(from: cfg), + key == expectedKey, + (cfg["type"] as? String)?.lowercased() == "http", + let url = cfg["url"] as? String else { return false } + return url == remote.url + } + } + + public func createRegistryServerKey(registryBaseURL: String, serverName: String) -> String { + let trimmedBaseURL = registryBaseURL + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return "\(trimmedBaseURL)|\(serverName)" + } + + // MARK: - Registry Key Helpers + + private func expectedRegistryKey(for serverDetail: MCPRegistryServerDetail) -> String? { + guard let registryBaseURL = try? getRegistryBaseURL() else { return nil } + return createRegistryServerKey( + registryBaseURL: registryBaseURL, + serverName: Self.getServerName(from: serverDetail) + ) + } + + private func registryKey(from serverConfig: [String: Any]) -> String? { + guard let metadata = serverConfig["x-metadata"] as? [String: Any], + let registry = metadata["registry"] as? [String: Any], + let api = registry["api"] as? [String: Any], + let baseUrl = api["baseUrl"] as? String, + let mcpServer = registry["mcpServer"] as? [String: Any], + let name = mcpServer["name"] as? String else { return nil } + return createRegistryServerKey(registryBaseURL: baseUrl, serverName: name) + } +} + +// MARK: - Error Types + +public enum MCPRegistryError: LocalizedError { + case registryURLNotConfigured + case noInstallationOptionsAvailable(serverName: String) + case invalidConfigurationStructure + case serverNotFound(serverName: String) + case configurationFileError(String) + + public var errorDescription: String? { + switch self { + case .registryURLNotConfigured: + return "MCP Registry base URL is not configured. Please configure the registry URL in Settings > Tools > GitHub Copilot > MCP to browse and install servers from the registry." + case let .noInstallationOptionsAvailable(serverName): + return "Cannot create server configuration for '\(serverName)' - no installation options available" + case .invalidConfigurationStructure: + return "Invalid MCP configuration file structure" + case let .serverNotFound(serverName): + return "MCP Server '\(serverName)' not found in configuration" + case let .configurationFileError(message): + return "Configuration file error: \(message)" + } + } +} + +// MARK: - Extensions + +extension String { + var registryDisplayText: String { + return registryTypes[self]?.displayName ?? self.capitalized + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift new file mode 100644 index 00000000..af7621e5 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift @@ -0,0 +1,160 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct MCPRegistryURLInputField: View { + @Binding var urlText: String + @AppStorage(\.mcpRegistryBaseURLHistory) private var urlHistory + @State private var showHistory: Bool = false + @FocusState private var isFocused: Bool + + let defaultMCPRegistryBaseURL = "https://api.mcp.github.com" + let maxURLLength: Int + let isSheet: Bool + let mcpRegistryEntry: MCPRegistryEntry? + let onValidationChange: ((Bool) -> Void)? + let onCommit: (() -> Void)? + + private var isRegistryOnly: Bool { + mcpRegistryEntry?.registryAccess == .registryOnly + } + + init( + urlText: Binding, + maxURLLength: Int = 2048, + isSheet: Bool = false, + mcpRegistryEntry: MCPRegistryEntry? = nil, + onValidationChange: ((Bool) -> Void)? = nil, + onCommit: (() -> Void)? = nil + ) { + self._urlText = urlText + self.maxURLLength = maxURLLength + self.isSheet = isSheet + self.mcpRegistryEntry = mcpRegistryEntry + self.onValidationChange = onValidationChange + self.onCommit = onCommit + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + if isSheet { + TextFieldsContainer { + TextField("MCP Registry Base URL", text: $urlText) + .focused($isFocused) + .disabled(isRegistryOnly) + .onChange(of: urlText) { newValue in + handleURLChange(newValue) + } + .onSubmit { + onCommit?() + } + } + } else { + TextField("MCP Registry Base URL:", text: $urlText) + .textFieldStyle(.roundedBorder) + .focused($isFocused) + .disabled(isRegistryOnly) + .onChange(of: urlText) { newValue in + handleURLChange(newValue) + } + .onSubmit { + onCommit?() + } + } + + Menu { + ForEach(urlHistory, id: \.self) { url in + Button(url) { + urlText = url + isFocused = false + onCommit?() + } + } + + Divider() + + Button("Reset to Default") { + urlText = defaultMCPRegistryBaseURL + onCommit?() + } + + if !urlHistory.isEmpty { + Button("Clear History") { + urlHistory = [] + } + } + } label: { + Image(systemName: "chevron.down") + .resizable() + .scaledToFit() + .frame(width: 11, height: 11) + .padding(isSheet ? 9 : 3) + } + .labelStyle(.iconOnly) + .menuIndicator(.hidden) + .buttonStyle( + HoverButtonStyle( + hoverColor: SecondarySystemFillColor, + backgroundColor: SecondarySystemFillColor, + cornerRadius: isSheet ? 12 : 6 + ) + ) + .opacity(isRegistryOnly ? 0.5 : 1) + .disabled(isRegistryOnly) + } + + if isRegistryOnly { + Badge( + text: "This URL is managed by \(mcpRegistryEntry!.owner.login) and cannot be modified", + level: .info, + icon: "info.circle.fill" + ) + } + } + .onAppear { + if isRegistryOnly, let entryURL = mcpRegistryEntry?.url { + urlText = entryURL + } + } + .onChange(of: mcpRegistryEntry) { newEntry in + if newEntry?.registryAccess == .registryOnly, let entryURL = newEntry?.url { + urlText = entryURL + } + } + } + + private func handleURLChange(_ newValue: String) { + // If registryOnly, force the URL back to the registry entry URL + if isRegistryOnly, let entryURL = mcpRegistryEntry?.url { + urlText = entryURL + return + } + + let limitedText = String(newValue.prefix(maxURLLength)) + if limitedText != newValue { + urlText = limitedText + } + + let isValid = limitedText.isEmpty || isValidURL(limitedText) + onValidationChange?(isValid) + } + + private func isValidURL(_ string: String) -> Bool { + guard !string.isEmpty else { return true } + return URL(string: string) != nil && (string.hasPrefix("http://") || string.hasPrefix("https://")) + } +} + +extension Array where Element == String { + mutating func addToHistory(_ url: String, maxItems: Int = 10) { + // Remove if already exists + removeAll { $0 == url } + // Add to beginning + insert(url, at: 0) + // Keep only maxItems + if count > maxItems { + removeLast(count - maxItems) + } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift new file mode 100644 index 00000000..efbc922a --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift @@ -0,0 +1,75 @@ +import GitHubCopilotService +import SwiftUI +import SharedUIComponents + +struct MCPRegistryURLSheet: View { + @AppStorage(\.mcpRegistryBaseURL) private var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory + @Environment(\.dismiss) private var dismiss + @State private var originalMcpRegistryBaseURL: String = "" + @State private var isFormValid: Bool = true + + let mcpRegistryEntry: MCPRegistryEntry? + let onURLUpdated: (() -> Void)? + + init(mcpRegistryEntry: MCPRegistryEntry? = nil, onURLUpdated: (() -> Void)? = nil) { + self.mcpRegistryEntry = mcpRegistryEntry + self.onURLUpdated = onURLUpdated + } + + var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("MCP Registry Base URL").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) + } + + VStack(alignment: .leading, spacing: 4) { + MCPRegistryURLInputField( + urlText: $originalMcpRegistryBaseURL, + isSheet: true, + mcpRegistryEntry: mcpRegistryEntry, + onValidationChange: { isValid in + isFormValid = isValid + } + ) + } + + HStack(spacing: 8) { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Update") { + // Check if URL changed before updating + originalMcpRegistryBaseURL = originalMcpRegistryBaseURL + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if originalMcpRegistryBaseURL != mcpRegistryBaseURL { + mcpRegistryBaseURL = originalMcpRegistryBaseURL + onURLUpdated?() + } + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(!isFormValid || mcpRegistryEntry?.registryAccess == .registryOnly) + } + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) + } + .onAppear { + loadExistingURL() + } + } + + private func loadExistingURL() { + originalMcpRegistryBaseURL = mcpRegistryBaseURL + } + + private func openHelpLink() { + NSWorkspace.shared.open(URL(string: "https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/select-an-mcp-registry")!) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift new file mode 100644 index 00000000..b3cb3537 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift @@ -0,0 +1,232 @@ +import AppKit +import Logger +import SharedUIComponents +import SwiftUI +import Client +import XPCShared +import GitHubCopilotService +import ComposableArchitecture + +struct MCPRegistryURLView: View { + @State private var isExpanded: Bool = false + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory + @State private var isLoading: Bool = false + @State private var tempURLText: String = "" + @State private var errorMessage: String = "" + @State private var mcpRegistry: [MCPRegistryEntry]? = nil + + private let maxURLLength = 2048 + private let mcpRegistryUrlVersion = "/v0.1/servers" + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + DisclosureSettingsRow( + isExpanded: $isExpanded, + accessibilityLabel: { $0 ? "Collapse mcp registry base URL section" : "Expand mcp registry base URL section" }, + title: { Text("MCP Registry Base URL").font(.headline) + Text(" (Optional)") }, + subtitle: { Text("Connect to available MCP servers for your AI workflows using the Registry URL.") }, + actions: { + HStack(spacing: 8) { + if isLoading { + ProgressView().controlSize(.small) + } + + Button { + isExpanded = true + } label: { + HStack(spacing: 0) { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Edit URL") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Configure your MCP Registry Base URL") + .disabled(mcpRegistry?.first?.registryAccess == .registryOnly) + + Button { Task{ await loadMCPServers() } } label: { + HStack(spacing: 0) { + Image(systemName: "square.grid.2x2") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(4) + Text("Browse MCP Servers...") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .help("Browse MCP Servers") + } + .padding(.vertical, 12) + } + ) + + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + MCPRegistryURLInputField( + urlText: $tempURLText, + maxURLLength: maxURLLength, + isSheet: false, + mcpRegistryEntry: mcpRegistry?.first, + onValidationChange: { _ in + // Only validate, don't update mcpRegistryURL here + }, + onCommit: { + // Update mcpRegistryURL when user presses Enter + tempURLText = tempURLText + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if tempURLText != mcpRegistryBaseURL { + mcpRegistryBaseURL = tempURLText + } + } + ) + + if !errorMessage.isEmpty { + Badge(text: errorMessage, level: .danger, icon: "xmark.circle.fill") + } + } + .padding(.leading, 36) + .padding([.trailing, .bottom], 20) + .background(QuaternarySystemFillColor.opacity(0.75)) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + .onAppear { + tempURLText = mcpRegistryBaseURL + } + } + } + .settingsContainerStyle(isExpanded: isExpanded) + .onAppear { + tempURLText = mcpRegistryBaseURL + Task { await getMCPRegistryAllowlist() } + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in + Task { await getMCPRegistryAllowlist() } + } + .onChange(of: mcpRegistryBaseURL) { newValue in + // Update the temp text to reflect the new URL + tempURLText = newValue + Task { await updateGalleryWindowIfOpen() } + } + .onChange(of: mcpRegistry) { _ in + Task { await updateGalleryWindowIfOpen() } + } + } + } + + private func loadMCPServers() async { + // Update mcpRegistryURL with current tempURLText before loading + tempURLText = tempURLText.trimmingCharacters(in: .whitespacesAndNewlines) + if tempURLText != mcpRegistryBaseURL { + mcpRegistryBaseURL = tempURLText + } + + isLoading = true + defer { isLoading = false } + do { + let service = try getService() + let serverList = try await service.listMCPRegistryServers( + .init(baseUrl: mcpRegistryBaseURL + mcpRegistryUrlVersion, limit: 30, version: "latest") + ) + + guard let serverList = serverList, !serverList.servers.isEmpty else { + Logger.client.info("No MCP servers found at registry URL: \(mcpRegistryBaseURL)") + return + } + + // Add to history on successful load + mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL) + errorMessage = "" + + MCPServerGalleryWindow.open(serverList: serverList, mcpRegistryEntry: mcpRegistry?.first) + } catch { + Logger.client.error("Failed to load MCP servers from registry: \(error.localizedDescription)") + if let serviceError = error as? XPCExtensionServiceError { + errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription + } else { + errorMessage = error.localizedDescription + } + isExpanded = true + } + } + + private func getMCPRegistryAllowlist() async { + isLoading = true + defer { isLoading = false } + do { + let service = try getService() + + // Only fetch allowlist if user is logged in + let authStatus = try await service.getXPCServiceAuthStatus() + guard authStatus?.status == .loggedIn else { + Logger.client.info("User not logged in, skipping MCP registry allowlist fetch") + return + } + + let result = try await service.getMCPRegistryAllowlist() + + guard let result = result, !result.mcpRegistries.isEmpty else { + if result == nil { + Logger.client.error("Failed to get allowlist result") + } else { + mcpRegistry = [] + } + return + } + + if let firstRegistry = result.mcpRegistries.first { + let entry = MCPRegistryEntry( + url: firstRegistry.url, + registryAccess: firstRegistry.registryAccess, + owner: firstRegistry.owner + ) + mcpRegistry = [entry] + Logger.client.info("Current MCP Registry Entry: \(entry)") + + // If registryOnly, force the URL to be the registry URL + if entry.registryAccess == .registryOnly { + mcpRegistryBaseURL = entry.url + tempURLText = entry.url + } + } + } catch { + Logger.client.error("Failed to get MCP allowlist from registry: \(error)") + } + } + + private func updateGalleryWindowIfOpen() async { + // Only update if the gallery window is currently open + guard MCPServerGalleryWindow.isOpen() else { + return + } + + isLoading = true + defer { isLoading = false } + + // Let the view model handle the entire update flow including clearing and fetching + if let error = await MCPServerGalleryWindow.refreshFromURL(mcpRegistryEntry: mcpRegistry?.first) { + // Display error in the URL view + if let serviceError = error as? XPCExtensionServiceError { + errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription + } else { + errorMessage = error.localizedDescription + } + isExpanded = true + } else { + errorMessage = "" + } + } +} + +#Preview { + MCPRegistryURLView() + .padding() + .frame(width: 900) +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift new file mode 100644 index 00000000..6c086ba6 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift @@ -0,0 +1,590 @@ +import SwiftUI +import AppKit +import GitHubCopilotService +import SharedUIComponents +import Foundation + +@available(macOS 13.0, *) +struct MCPServerDetailSheet: View { + let server: MCPRegistryServerDetail + let meta: ServerMeta? + @State private var selectedTab = TabType.Packages + @State private var expandedPackages: Set = [] + @State private var expandedRemotes: Set = [] + @State private var packageConfigs: [Int: [String: Any]] = [:] + @State private var remoteConfigs: [Int: [String: Any]] = [:] + // Track installation progress per item so we can disable buttons / show feedback + @State private var installingPackages: Set = [] + @State private var installingRemotes: Set = [] + // Track whether the server (any option) is already installed + @State private var isInstalled: Bool + // Overwrite confirmation alert + @State private var showOverwriteAlert: Bool = false + @State private var pendingInstallAction: (() -> Void)? = nil + + @Environment(\.dismiss) private var dismiss + + enum TabType: String, CaseIterable, Identifiable { + case Packages, Remotes, Metadata + var id: Self { self } + } + + init(response: MCPRegistryServerResponse) { + self.server = response.server + self.meta = response.meta + // Determine installed status using registry service (same logic as gallery view) + _isInstalled = State(initialValue: MCPRegistryService.shared.isServerInstalled(server)) + } + + // Shared visual constants + private let labelColumnWidth: CGFloat = 80 + private let detailTopPadding: CGFloat = 6 + + var body: some View { + VStack(spacing: 0) { + // Header + headerSection + + // Tab selector + tabSelector + + // Content + OverlayScrollView { + VStack(alignment: .leading, spacing: 16) { + switch selectedTab { + case .Packages: + packagesTab + case .Remotes: + remotesTab + case .Metadata: + metadataTab + } + } + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 400) + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: { dismiss() }) { Text("Close") } + } + ToolbarItem(placement: .secondaryAction) { + if isInstalled { + Button("Open Config") { openConfig() } + .help("Open mcp.json") + } + } + } + .toolbarRole(.automatic) + .frame(width: 600, height: 450) + .background(Color(nsColor: .controlBackgroundColor)) + .onAppear { + isInstalled = MCPRegistryService.shared.isServerInstalled(server) + } + .alert("Overwrite Existing Installation?", isPresented: $showOverwriteAlert) { + Button("Cancel", role: .cancel) { pendingInstallAction = nil } + Button("Overwrite", role: .destructive) { + pendingInstallAction?() + pendingInstallAction = nil + } + } message: { + Text("Installing this option will replace the currently installed variant of this server.") + } + } + + // MARK: - Header Section + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center) { + Text(server.title ?? server.name) + .font(.system(size: 18, weight: .semibold)) + + if let status = meta?.official?.status, status == .deprecated { + statusBadge(status) + } + + Spacer() + } + + HStack(spacing: 24) { + HStack(spacing: 6) { + Image(systemName: "tag") + Text(server.version) + } + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + + if let publishedAt = meta?.official?.publishedAt { + dateMetadataTag(title: "Published ", dateString: publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + if let updatedAt = meta?.official?.updatedAt { + dateMetadataTag(title: "Updated ", dateString: updatedAt, image: "icloud.and.arrow.up") + } + + if let repo = server.repository, !repo.url.isEmpty, !repo.source.isEmpty { + if let repoURL = URL(string: repo.url) { + HStack(spacing: 6) { + Image(systemName: "link") + Link(destination: repoURL) { + Text("Repository") + } + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + } + + Text(server.description) + .font(.system(size: 13)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(2) + .padding(.top, 4) + } + .padding(28) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private func dateMetadataTag(title: String, dateString: String, image: String) -> some View { + HStack(spacing: 6) { + Image(systemName: image) + if let date = parseDate(dateString) { + (Text("\(title)\(relativeDateString(date))")) + .help(formatExactDate(date)) + } else { + Text("\(title) \(dateString)").help(dateString) + } + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + // MARK: - Tab Selector + + private var tabSelector: some View { + HStack(spacing: 0) { + Picker("", selection: $selectedTab) { + Text("Packages (\(server.packages?.count ?? 0))") + .tag(TabType.Packages) + Text("Remotes (\(server.remotes?.count ?? 0))") + .tag(TabType.Remotes) + if meta?.official != nil { + Text("Metadata") + .tag(TabType.Metadata) + } + } + .pickerStyle(.segmented) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.3)) + .overlay( + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame(height: 1), + alignment: .bottom + ) + } + + // MARK: - Packages Tab + + private var packagesTab: some View { + Group { + if let packages = server.packages, !packages.isEmpty { + ForEach(Array(packages.enumerated()), id: \.offset) { index, package in + packageItem(package, index: index) + } + } else { + EmptyStateView(message: "No packages available for this server", type: .Packages) + } + } + } + + private func packageItem(_ package: Package, index: Int) -> some View { + let isExpanded = expandedPackages.contains(index) + let optionInstalled = MCPRegistryService.shared.isPackageOptionInstalled(serverDetail: server, package: package) + let metadata: [ServerInstallationOptionView.Metadata] = { + var rows: [ServerInstallationOptionView.Metadata] = [] + rows.append(.init(label: "ID", value: package.identifier, monospaced: true)) + if let registryURL = package.registryBaseUrl { + rows.append(.init(label: "Registry", value: registryURL)) + } + if let runtime = package.runtimeHint { rows.append(.init(label: "Runtime", value: runtime)) } + return rows + }() + return ServerInstallationOptionView( + title: package.registryType.registryDisplayText, + iconSystemName: "shippingbox", + versionTag: package.version, + metadata: metadata, + isExpanded: isExpanded, + isInstalled: isInstalled, // overall server installed + isInstalling: installingPackages.contains(index), + showUninstall: optionInstalled, + labelColumnWidth: labelColumnWidth, + onToggleExpand: { + if isExpanded { + expandedPackages.remove(index) + } else { + expandedPackages.insert(index) + if packageConfigs[index] == nil { packageConfigs[index] = generateServerConfig(for: package) } + } + }, + onInstall: { handlePackageInstallButton(package, index: index, optionInstalled: optionInstalled) }, + onUninstall: { uninstallServer() }, + config: packageConfigs[index] + ) + } + + // MARK: - Remotes Tab + + private var remotesTab: some View { + Group { + if let remotes = server.remotes, !remotes.isEmpty { + ForEach(Array(remotes.enumerated()), id: \.offset) { index, remote in + remoteItem(remote, index: index) + } + } else { + EmptyStateView( + message: "No remote endpoints configured for this server", + type: .Remotes + ) + } + } + } + + private func remoteItem(_ remote: Remote, index: Int) -> some View { + let isExpanded = expandedRemotes.contains(index) + let optionInstalled = MCPRegistryService.shared.isRemoteOptionInstalled(serverDetail: server, remote: remote) + let metadata: [ServerInstallationOptionView.Metadata] = [ + .init(label: "URL", value: remote.url, monospaced: true) + ] + return ServerInstallationOptionView( + title: remote.transportType.displayText, + iconSystemName: "globe", + versionTag: nil, + metadata: metadata, + isExpanded: isExpanded, + isInstalled: isInstalled, + isInstalling: installingRemotes.contains(index), + showUninstall: optionInstalled, + labelColumnWidth: labelColumnWidth, + onToggleExpand: { + if isExpanded { + expandedRemotes.remove(index) + } else { + expandedRemotes.insert(index) + if remoteConfigs[index] == nil { remoteConfigs[index] = generateServerConfig(for: remote) } + } + }, + onInstall: { handleRemoteInstallButton(remote, index: index, optionInstalled: optionInstalled) }, + onUninstall: { uninstallServer() }, + config: remoteConfigs[index] + ) + } + + // MARK: - Metadata Tab + + private var metadataTab: some View { + VStack(alignment: .leading, spacing: 16) { + if let officialMeta = meta?.official { + officialMetadataSection(officialMeta) + } + } + } + + private func officialMetadataSection(_ official: OfficialMeta) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Official Registry") + .font(.system(size: 14, weight: .medium)) + } + + VStack(alignment: .leading, spacing: 8) { + if let publishedAt = official.publishedAt { + metadataRow( + label: "Published", + value: parseDate(publishedAt) != nil ? formatExactDate( + parseDate(publishedAt)! + ) : publishedAt + ) + } + + if let updatedAt = official.updatedAt { + metadataRow( + label: "Updated", + value: parseDate(updatedAt) != nil ? formatExactDate( + parseDate(updatedAt)! + ) : updatedAt + ) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + ) + } + + private func metadataRow(label: String, value: String, isLink: Bool = false) -> some View { + HStack(spacing: 8) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + + if isLink, let url = URL(string: value) { + Link(value, destination: url) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.blue) + } else { + Text(value) + .font(.system(size: 12, design: label.contains("ID") || label.contains("Commit") ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + } + + private func serverConfigView(_ config: [String: Any]) -> some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 8) { + Text(formatConfigAsJSON(config)) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } + .padding(12) + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string) + } + .padding(6) + .help("Copy configuration to clipboard") + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + + private func formatConfigAsJSON(_ config: [String: Any]) -> String { + do { + let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + return String(data: jsonData, encoding: .utf8) ?? "{}" + } catch { + return "{}" + } + } + + // MARK: - Configuration Generation Helpers + + private func generateServerConfig(for package: Package) -> [String: Any] { + return MCPRegistryService.shared.createServerConfig(for: server, package: package) + } + + private func generateServerConfig(for remote: Remote) -> [String: Any] { + return MCPRegistryService.shared.createServerConfig(for: server, remote: remote) + } + + // MARK: - Install Helpers + + private func performPackageInstall(_ package: Package, index: Int) { + guard !installingPackages.contains(index) else { return } + installingPackages.insert(index) + Task { + let config = packageConfigs[index] ?? generateServerConfig(for: package) + // Cache generated config for preview if needed later + if packageConfigs[index] == nil { packageConfigs[index] = config } + let option = InstallationOption( + displayName: package.registryType.registryDisplayText, + description: "Install \(package.identifier)", + config: config + ) + do { + try await MCPRegistryService.shared.installMCPServer(server, installationOption: option) + // Mark installed locally so UI reflects the state immediately + isInstalled = true + } catch { + // Silently fail for now; could surface error UI later + } + installingPackages.remove(index) + } + } + + private func handlePackageInstallButton(_ package: Package, index: Int, optionInstalled: Bool) { + if isInstalled && !optionInstalled { + // Show overwrite confirmation + pendingInstallAction = { performPackageInstall(package, index: index) } + showOverwriteAlert = true + } else { + performPackageInstall(package, index: index) + } + } + + private func performRemoteInstall(_ remote: Remote, index: Int) { + guard !installingRemotes.contains(index) else { return } + installingRemotes.insert(index) + Task { + let config = remoteConfigs[index] ?? generateServerConfig(for: remote) + if remoteConfigs[index] == nil { remoteConfigs[index] = config } + let option = InstallationOption( + displayName: "\(remote.transportType.rawValue)", + description: "Install remote endpoint \(remote.url)", + config: config + ) + do { + try await MCPRegistryService.shared.installMCPServer(server, installationOption: option) + isInstalled = true + } catch { + // Silently fail for now + } + installingRemotes.remove(index) + } + } + + private func handleRemoteInstallButton(_ remote: Remote, index: Int, optionInstalled: Bool) { + if isInstalled && !optionInstalled { + pendingInstallAction = { performRemoteInstall(remote, index: index) } + showOverwriteAlert = true + } else { + performRemoteInstall(remote, index: index) + } + } + + private func uninstallServer() { + Task { + do { + try await MCPRegistryService.shared.uninstallMCPServer(server) + isInstalled = false + } catch { + // TODO: Consider surfacing error to user + } + } + } + + // MARK: - Helper Views + + private func statusBadge(_ status: ServerStatus) -> some View { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.orange) + .padding(.horizontal, 6) + .help("The server is deprecated.") + } + + private struct EmptyStateView: View { + let message: String + let type: PackageType + + enum PackageType: String { + case Packages, Remotes, Metadata + } + + var Logo: some View { + switch type { + case .Packages: + return Image(systemName: "shippingbox") + case .Remotes: + return Image(systemName: "globe") + case .Metadata: + return Image(systemName: "info.circle") + } + } + + var body: some View { + VStack(spacing: 12) { + Logo.font(.system(size: 32)) + + Text(message) + .font(.system(size: 13)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + } + + // MARK: - Utilities + + private func parseDate(_ dateString: String) -> Date? { + // Try multiple ISO8601 formatters in order of specificity + let formatters: [ISO8601DateFormatter] = [ + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime, .withTimeZone] + return formatter + }(), + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime] + return formatter + }() + ] + + // Try each formatter until one succeeds + for formatter in formatters { + if let date = formatter.date(from: dateString) { + return date + } + } + + return nil + } + + private func formatExactDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + private func relativeDateString(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + + // MARK: - Open Config / Selection Support + + private func openConfig() { + // Simplified to just open the MCP config file, mirroring manual install behavior. + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } +} + diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift new file mode 100644 index 00000000..31e138fa --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift @@ -0,0 +1,360 @@ +import AppKit +import Client +import CryptoKit +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI +import XPCShared + +enum MCPServerGalleryWindow { + static let identifier = "MCPServerGalleryWindow" + private static weak var currentViewModel: MCPServerGalleryViewModel? + + @MainActor static func open( + serverList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil + ) { + if let existing = NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) { + // Update existing window with new data + update(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry) + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let viewModel = MCPServerGalleryViewModel( + initialList: serverList, + mcpRegistryEntry: mcpRegistryEntry + ) + currentViewModel = viewModel + + let controller = NSHostingController( + rootView: MCPServerGalleryView( + viewModel: viewModel + ) + ) + + let window = NSWindow(contentViewController: controller) + window.title = "MCP Servers Marketplace" + window.identifier = NSUserInterfaceItemIdentifier(identifier) + window.setContentSize(NSSize(width: 800, height: 600)) + window.minSize = NSSize(width: 600, height: 400) + window.styleMask.insert([.titled, .closable, .resizable, .miniaturizable]) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @MainActor static func update( + serverList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil + ) { + currentViewModel?.updateData(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry) + } + + @MainActor static func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? { + return await currentViewModel?.refreshFromURL(mcpRegistryEntry: mcpRegistryEntry) + } + + static func isOpen() -> Bool { + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) != nil + } +} + +// MARK: - Stable ID helper + +extension MCPRegistryServerResponse { + var stableID: String { + server.name + server.version + } +} + +private struct IdentifiableServerResponse: Identifiable { + let response: MCPRegistryServerResponse + var id: String { response.stableID } +} + +struct MCPServerGalleryView: View { + @ObservedObject var viewModel: MCPServerGalleryViewModel + @State private var isShowingURLSheet = false + @State private var searchTask: Task? + + init(viewModel: MCPServerGalleryViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + if let error = viewModel.lastError { + if let serviceError = error as? XPCExtensionServiceError { + Badge(text: serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription, level: .danger, icon: "xmark.circle.fill") + } else { + Badge(text: error.localizedDescription, level: .danger, icon: "xmark.circle.fill") + } + } + + tableHeaderView + serverListView + } + .padding(20) + .background(Color(nsColor: .controlBackgroundColor)) + .background(.ultraThinMaterial) + .onAppear { + viewModel.loadInstalledServers() + } + .sheet(isPresented: $isShowingURLSheet) { + urlSheet + } + .sheet(isPresented: Binding( + get: { viewModel.infoSheetServer != nil }, + set: { isPresented in + if !isPresented { + viewModel.dismissInfo() + } + } + )) { + if let server = viewModel.infoSheetServer { + infoSheet(server) + } + } + .searchable(text: $viewModel.searchText, prompt: "Search") + .onChange(of: viewModel.searchText) { newValue in + // Debounce search input before triggering a new server-side query + searchTask?.cancel() + searchTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3s + if !Task.isCancelled { + viewModel.refreshForSearch() + } + } + } + .toolbar { + ToolbarItem { + Button(action: { viewModel.refresh() }) { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + } + + ToolbarItem { + Button(action: { isShowingURLSheet = true }) { + Image(systemName: "square.and.pencil") + } + .help("Configure your MCP Registry Base URL") + } + } + } + + private var tableHeaderView: some View { + VStack(spacing: 0) { + HStack { + Text("Name") + .font(.system(size: 11, weight: .bold)) + .padding(.horizontal, 8) + .frame(width: 220, alignment: .leading) + + Divider().frame(height: 20) + + Text("Description") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Text("Actions") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + } + .padding(.trailing, 8) + .frame(width: 120, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.clear) + + Divider() + } + } + + private var serverListView: some View { + ZStack { + ScrollView { + LazyVStack(spacing: 0) { + serverRows + + if viewModel.shouldShowLoadMoreSentinel { + Color.clear + .frame(height: 1) + .onAppear { viewModel.loadMoreIfNeeded() } + .accessibilityHidden(true) + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 12) + Spacer() + } + } + } + } + + if viewModel.isRefreshing { + VStack(spacing: 12) { + ProgressView() + Text("Loading servers...") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.95)) + } + } + } + + private var serverRows: some View { + ForEach(Array(viewModel.filteredServers.enumerated()), id: \.element.stableID) { index, server in + let isInstalled = viewModel.isServerInstalled(serverId: server.stableID) + row(for: server, index: index, isInstalled: isInstalled) + .background(rowBackground(for: index)) + .cornerRadius(8) + .onAppear { + handleRowAppear(index: index) + } + } + } + + private var urlSheet: some View { + MCPRegistryURLSheet( + mcpRegistryEntry: viewModel.mcpRegistryEntry, + onURLUpdated: { + viewModel.refresh() + } + ) + .frame(width: 500, height: 200) + } + + private func rowBackground(for index: Int) -> Color { + index.isMultiple(of: 2) ? Color.clear : Color.primary.opacity(0.03) + } + + private func handleRowAppear(index: Int) { + let currentFilteredCount = viewModel.filteredServers.count + let totalServerCount = viewModel.servers.count + + // Prefetch when approaching the end of filtered results + if index >= currentFilteredCount - 5 { + // If we're filtering and the filtered results are small compared to total servers, + // or if we're near the end of all available data, try to load more + if currentFilteredCount < 20 || index >= totalServerCount - 5 { + viewModel.loadMoreIfNeeded() + } + } + } + + // MARK: - Subviews + + private func row(for response: MCPRegistryServerResponse, index: Int, isInstalled: Bool) -> some View { + HStack { + Text(response.server.title ?? response.server.name) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .frame(width: 220, alignment: .leading) + + Divider().frame(height: 20).foregroundColor(Color.clear) + + Text(response.server.description) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + if isInstalled { + Button("Uninstall") { + Task { + await viewModel.uninstallServer(response.server) + } + } + .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") { + Task { + await viewModel.installServer(response.server) + } + } + .disabled(viewModel.hasNoDeployments(response.server)) + .help("Install") + } + } + + Button { + viewModel.showInfo(response) + } label: { + Image(systemName: "info.circle") + .font(.system(size: 13)) + .foregroundColor(.primary) + .multilineTextAlignment(.trailing) + } + .buttonStyle(.plain) + .help("View Details") + } + .padding(.horizontal, 8) + .frame(width: 120, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private func infoSheet(_ response: MCPRegistryServerResponse) -> some View { + if #available(macOS 13.0, *) { + return AnyView(MCPServerDetailSheet(response: response)) + } else { + return AnyView(EmptyView()) + } + } +} + +func defaultInstallation(for server: MCPRegistryServerDetail) -> String { + // Get the first available type from remotes or packages + if let firstRemote = server.remotes?.first { + return firstRemote.transportType.rawValue + } + if let firstPackage = server.packages?.first { + return firstPackage.registryType + } + return "" +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift new file mode 100644 index 00000000..26cfaf63 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift @@ -0,0 +1,320 @@ +import Client +import CryptoKit +import Foundation +import GitHubCopilotService +import Logger +import SwiftUI + +@MainActor +final class MCPServerGalleryViewModel: ObservableObject { + // Input invariants + private let pageSize: Int + + // User / UI state + @Published var searchText: String = "" + + // Data + @Published private(set) var servers: [MCPRegistryServerResponse] + @Published private(set) var installedServers: Set = [] + @Published private(set) var registryMetadata: MCPRegistryServerListMetadata? + + // Loading flags + @Published private(set) var isInitialLoading: Bool = false + @Published private(set) var isLoadingMore: Bool = false + @Published private(set) var isRefreshing: Bool = false + + // Transient presentation state + @Published var pendingServer: MCPRegistryServerResponse? + @Published var infoSheetServer: MCPRegistryServerResponse? + @Published var mcpRegistryEntry: MCPRegistryEntry? + @Published private(set) var lastError: Error? + + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory + + // Service integration + private let registryService = MCPRegistryService.shared + + init( + initialList: MCPRegistryServerList, + mcpRegistryEntry: MCPRegistryEntry? = nil, + pageSize: Int = 30 + ) { + self.pageSize = pageSize + servers = initialList.servers + registryMetadata = initialList.metadata + self.mcpRegistryEntry = mcpRegistryEntry + } + + // MARK: - Derived Data + + var filteredServers: [MCPRegistryServerResponse] { + // Only filter for latest official servers; search is handled server-side. + // Also ensure we don't surface duplicate stable IDs, which can confuse SwiftUI's diffing. + var seen = Set() + return servers.compactMap { server in + let id = server.stableID + if seen.contains(id) { return nil } + seen.insert(id) + return server + } + } + + var shouldShowLoadMoreSentinel: Bool { + // Show load more sentinel if there's more data available + if let next = registryMetadata?.nextCursor, !next.isEmpty { + return true + } + return false + } + + func isServerInstalled(serverId: String) -> Bool { + // Find the server by ID and check installation status using the service + if let server = servers.first(where: { $0.stableID == serverId }) { + return registryService.isServerInstalled(server.server) + } + + // Fallback to the existing key-based check for backwards compatibility + let key = createRegistryServerKey(registryBaseURL: mcpRegistryBaseURL, serverName: serverId) + return installedServers.contains(key) + } + + func hasNoDeployments(_ server: MCPRegistryServerDetail) -> Bool { + return server.remotes?.isEmpty ?? true && server.packages?.isEmpty ?? true + } + + // MARK: - User Intents (Updated with Service Integration) + + func requestInstall(_ server: MCPRegistryServerDetail) { + Task { + await installServer(server) + } + } + + func requestInstallWithConfiguration(_ server: MCPRegistryServerDetail, configuration: String) { + Task { + await installServer(server, configuration: configuration) + } + } + + func installServer(_ server: MCPRegistryServerDetail, configuration: String? = nil) async { + do { + let installationOption: InstallationOption? + + if let configName = configuration { + // Find the specific installation option + let options = registryService.getAllInstallationOptions(for: server) + installationOption = options.first { option in + option.displayName.contains(configName) || + option.description.contains(configName) + } + } else { + installationOption = nil + } + + try await registryService.installMCPServer(server, installationOption: installationOption) + + // Refresh installed servers list + loadInstalledServers() + + Logger.client.info("Successfully installed MCP Server '\(server.name)'") + + } catch { + Logger.client.error("Failed to install server '\(server.name)': \(error)") + // TODO: Consider adding error handling UI feedback here + } + } + + func uninstallServer(_ server: MCPRegistryServerDetail) async { + do { + try await registryService.uninstallMCPServer(server) + + // Refresh installed servers list + loadInstalledServers() + + Logger.client.info("Successfully uninstalled MCP Server '\(server.name)'") + + } catch { + Logger.client.error("Failed to uninstall server '\(server.name)': \(error)") + // TODO: Consider adding error handling UI feedback here + } + } + + func refresh() { + Task { + isRefreshing = true + defer { isRefreshing = false } + + // Clear the current server list and search text + servers = [] + registryMetadata = nil + searchText = "" + + // Load servers from the base URL with empty query + _ = await loadServerList(resetToFirstPage: true) + } + } + + // Called from Settings view to refresh with optional new registry entry + func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? { + isRefreshing = true + defer { isRefreshing = false } + + // Clear the current server list and reset search text when URL changes + servers = [] + registryMetadata = nil + searchText = "" + self.mcpRegistryEntry = mcpRegistryEntry + Logger.client.info("Cleared gallery view model data for refresh") + + // Load servers from the base URL + let error = await loadServerList(resetToFirstPage: true) + + // Reload installed servers after fetching new data + loadInstalledServers() + + return error + } + + func updateData(serverList: MCPRegistryServerList, mcpRegistryEntry: MCPRegistryEntry? = nil) { + servers = serverList.servers + registryMetadata = serverList.metadata + self.mcpRegistryEntry = mcpRegistryEntry + searchText = "" + loadInstalledServers() + Logger.client.info("Updated gallery view model with \(serverList.servers.count) servers and registry entry: \(String(describing: mcpRegistryEntry))") + } + + func clearData() { + servers = [] + registryMetadata = nil + searchText = "" + Logger.client.info("Cleared gallery view model data") + } + + /// Refresh the server list in response to a search query change without + /// resetting the search text. This is used by the debounced searchable field. + func refreshForSearch() { + Task { + isRefreshing = true + defer { isRefreshing = false } + + // Clear current data but keep the active search query + servers = [] + registryMetadata = nil + + _ = await loadServerList(resetToFirstPage: true) + } + } + + func showInfo(_ server: MCPRegistryServerResponse) { + infoSheetServer = server + } + + func dismissInfo() { + infoSheetServer = nil + } + + // MARK: - Data Loading + + func loadMoreIfNeeded() { + guard !isLoadingMore, + !isInitialLoading, + let nextCursor = registryMetadata?.nextCursor, + !nextCursor.isEmpty + else { return } + + Task { + await loadServerList(resetToFirstPage: false) + } + } + + private func loadServerList(resetToFirstPage: Bool) async -> Error? { + if resetToFirstPage { + isInitialLoading = true + } else { + isLoadingMore = true + } + + defer { + isInitialLoading = false + isLoadingMore = false + } + + lastError = nil + + do { + let service = try getService() + let cursor = resetToFirstPage ? nil : registryMetadata?.nextCursor + + let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + + let serverList = try await service.listMCPRegistryServers( + .init( + baseUrl: registryService.getRegistryURL(), + cursor: cursor, + limit: pageSize, + search: trimmedQuery.isEmpty ? nil : trimmedQuery, + version: "latest" + ) + ) + + if resetToFirstPage { + // Replace all servers when refreshing or resetting + servers = serverList?.servers ?? [] + registryMetadata = serverList?.metadata + } else { + // Append when loading more + servers.append(contentsOf: serverList?.servers ?? []) + registryMetadata = serverList?.metadata + } + + mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL) + + return nil + } catch { + Logger.client.error("Failed to load MCP servers: \(error)") + lastError = error + return error + } + } + + func loadInstalledServers() { + // Clear the set and rebuild it + installedServers.removeAll() + + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let currentConfig = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let serversDict = currentConfig["servers"] as? [String: Any] else { + return + } + + for (_, serverConfig) in serversDict { + guard + let serverConfigDict = serverConfig as? [String: Any], + let metadata = serverConfigDict["x-metadata"] as? [String: Any], + let registry = metadata["registry"] as? [String: Any], + let api = registry["api"] as? [String: Any], + let baseUrl = api["baseUrl"] as? String, + let mcpServer = registry["mcpServer"] as? [String: Any], + let name = mcpServer["name"] as? String + else { continue } + + installedServers.insert( + createRegistryServerKey(registryBaseURL: baseUrl, serverName: name) + ) + } + } + + private func createRegistryServerKey(registryBaseURL: String, serverName: String) -> String { + return registryService.createRegistryServerKey(registryBaseURL: registryBaseURL, serverName: serverName) + } + + // MARK: - Installation Options Helper + + func getInstallationOptions(for server: MCPRegistryServerDetail) -> [InstallationOption] { + return registryService.getAllInstallationOptions(for: server) + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift new file mode 100644 index 00000000..fcc129e4 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import AppKit +import Foundation +import SharedUIComponents + +struct ServerInstallationOptionView: View { + struct Metadata: Identifiable { + let id = UUID() + let label: String + let value: String + var monospaced: Bool = false + var isLink: Bool = false + } + + let title: String + let iconSystemName: String + let versionTag: String? + let metadata: [Metadata] + + // State/control flags passed from parent + let isExpanded: Bool + let isInstalled: Bool + let isInstalling: Bool + let showUninstall: Bool + + // Layout constants + let labelColumnWidth: CGFloat + + // Behavior closures supplied by parent + let onToggleExpand: () -> Void + let onInstall: () -> Void + let onUninstall: () -> Void + + // Optional configuration JSON (already generated by parent) shown when expanded + let config: [String: Any]? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + header + if isExpanded, let config { + configSection(config) + .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + ) + .animation(.easeInOut(duration: 0.2), value: isExpanded) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 8) { + Label(title, systemImage: iconSystemName) + .font(.system(size: 14, weight: .medium)) + + if let versionTag { + Text(versionTag) + .font(.system(size: 12, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(Color.green.opacity(0.15))) + } + + Spacer() + + Button(isExpanded ? "Hide" : "Preview") { onToggleExpand() } + .buttonStyle(.bordered) + .help(isExpanded ? "Hide configuration details" : "Preview configuration details") + + if showUninstall { + Button("Uninstall") { onUninstall() } + .buttonStyle(DestructiveButtonStyle()) + .help("Uninstall this installed option") + } else { + Button(action: onInstall) { + if isInstalling { + ProgressView().controlSize(.mini) + } else { + Text("Install") + } + } + .disabled(isInstalling) + .buttonStyle(.borderedProminent) + .help("Install this server using the selected option") + } + } + + // Metadata rows + Group { + ForEach(metadata) { item in + HStack(spacing: 6) { + Text(item.label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: labelColumnWidth, alignment: .leading) + + if item.isLink, let url = URL(string: item.value) { + Link(item.value, destination: url) + .font(.system(size: 12, design: item.monospaced ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } else { + Text(item.value) + .font(.system(size: 12, design: item.monospaced ? .monospaced : .default)) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + } + } + .padding(.top, 6) + } + } + + private func configSection(_ config: [String: Any]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Divider().padding(.vertical, 4) + HStack { + Text("Server Configuration") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + configView(config) + } + } + + @ViewBuilder + private func configView(_ config: [String: Any]) -> some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 8) { + Text(formatConfigAsJSON(config)) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } + .padding(12) + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string) + } + .padding(6) + .help("Copy configuration to clipboard") + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + private func formatConfigAsJSON(_ config: [String: Any]) -> String { + do { + let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { return "{}" } + } +} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift index f9c45687..47abc27a 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift @@ -3,37 +3,119 @@ import Persist import GitHubCopilotService import Client import Logger +import Foundation +import SharedUIComponents +import ConversationServiceProvider /// Section for a single server's tools struct MCPServerToolsSection: View { let serverTools: MCPServerToolsCollection @Binding var isServerEnabled: Bool var forceExpand: Bool = false + var isInteractionAllowed: Bool = true + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode @State private var toolEnabledStates: [String: Bool] = [:] @State private var isExpanded: Bool = true + @State private var checkboxMixedState: CheckboxMixedState = .off private var originalServerName: String { serverTools.name } + @State private var isShowingDeleteConfirmation: Bool = false + private var serverToggleLabel: some View { HStack(spacing: 8) { - Text("MCP Server: \(serverTools.name)").fontWeight(.medium) - if serverTools.status == .error { + Text("MCP Server: \(serverTools.name)") + .fontWeight(.medium) + .foregroundStyle( + serverTools.status == .running ? .primary : .tertiary + ) + if serverTools.status == .error || serverTools.status == .blocked { let message = extractErrorMessage(serverTools.error?.description ?? "") - Badge(text: message, level: .danger, icon: "xmark.circle.fill") + if serverTools.status == .error { + Badge( + attributedText: createErrorMessage(message), + level: .danger, + icon: "xmark.circle.fill" + ) + .environment((\.openURL), OpenURLAction { url in + if url.absoluteString == "mcp://open-config" { + openMCPConfigFile() + return .handled + } + return .systemAction + }) + } else if serverTools.status == .blocked { + Badge(text: serverTools.registryInfo ?? "Blocked", level: .warning, icon: "exclamationmark.triangle.fill") + } + } else if let registryInfo = serverTools.registryInfo { + Text(registryInfo) + .foregroundStyle(.secondary) + .font(.system(size: 11)) } - Spacer() + } + } + + private func openMCPConfigFile() { + let url = URL(fileURLWithPath: mcpConfigFilePath) + NSWorkspace.shared.open(url) + } + + private func createErrorMessage(_ baseMessage: String) -> AttributedString { + if hasServerConfigPlaceholders() { + let prefix = baseMessage.isEmpty ? "" : baseMessage + ". " + var attributedString = AttributedString(prefix + "You may need to update placeholders in ") + + var mcpLink = AttributedString("mcp.json") + mcpLink.link = URL(string: "mcp://open-config") + mcpLink.underlineStyle = .single + + attributedString.append(mcpLink) + attributedString.append(AttributedString(".")) + + return attributedString + } else { + return AttributedString(baseMessage) } } private var serverToggle: some View { - Toggle(isOn: Binding( - get: { isServerEnabled }, - set: { updateAllToolsStatus(enabled: $0) } - )) { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxMixedState + ) { + switch checkboxMixedState { + case .off, .mixed: + // Enable all tools + updateAllToolsStatus(enabled: true) + case .on: + // Disable all tools + updateAllToolsStatus(enabled: false) + } + updateMixedState() + } + .disabled(serverTools.status == .error || serverTools.status == .blocked || !isInteractionAllowed) + serverToggleLabel + .contentShape(Rectangle()) + .onTapGesture { + if serverTools.status != .error && serverTools.status != .blocked { + withAnimation { + isExpanded.toggle() + } + } + } + + Spacer() + + Button(action: { isShowingDeleteConfirmation = true }) { + Image(systemName: "trash").font(.system(size: 12)) + } + .buttonStyle(HoverButtonStyle()) + .padding(-4) } - .toggleStyle(.checkbox) .padding(.leading, 4) - .disabled(serverTools.status == .error) } private var divider: some View { @@ -53,6 +135,7 @@ struct MCPServerToolsSection: View { toolStatus: tool._status, isServerEnabled: isServerEnabled, isToolEnabled: toolBindingFor(tool), + isInteractionAllowed: isInteractionAllowed, onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } ) .padding(.leading, 36) @@ -60,6 +143,7 @@ struct MCPServerToolsSection: View { } .onChange(of: serverTools) { newValue in initializeToolStates(server: newValue) + updateMixedState() } } @@ -67,10 +151,12 @@ struct MCPServerToolsSection: View { var body: some View { VStack(alignment: .leading, spacing: 0) { // Conditional view rendering based on error state - if serverTools.status == .error { + if serverTools.status == .error || serverTools.status == .blocked { // No disclosure group for error state VStack(spacing: 0) { - serverToggle.padding(.leading, 12) + serverToggle + .padding(.leading, 11) + .padding(.trailing, 4) divider.padding(.top, 4) } } else { @@ -82,6 +168,7 @@ struct MCPServerToolsSection: View { } .onAppear { initializeToolStates(server: serverTools) + updateMixedState() if forceExpand { isExpanded = true } @@ -91,12 +178,63 @@ struct MCPServerToolsSection: View { isExpanded = true } } + .onChange(of: selectedMode) { _ in + toolEnabledStates = [:] + initializeToolStates(server: serverTools) + updateMixedState() + } + .onChange(of: selectedMode.customTools) { _ in + Task { + await reloadModesAndUpdateStates() + } + } + .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in + Logger.client.info("Custom agent tools change notification received in MCPServerToolsSection") + if !selectedMode.isDefaultAgent { + Task { + await reloadModesAndUpdateStates() + } + } + } if !isExpanded { divider } } } + .confirmationDialog( + "Do you want to delete '\(serverTools.name)'?", + isPresented: $isShowingDeleteConfirmation + ) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteServerConfig() } + } + } + + private func deleteServerConfig() { + let fileURL = URL(fileURLWithPath: mcpConfigFilePath) + + guard let data = try? Data(contentsOf: fileURL) else { + Logger.client.error("Failed to read mcp.json when deleting server config.") + return + } + + guard var rootObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + Logger.client.error("Failed to parse mcp.json when deleting server config.") + return + } + + if var servers = rootObject["servers"] as? [String: Any] { + servers.removeValue(forKey: serverTools.name) + rootObject["servers"] = servers + } + + do { + let newData = try JSONSerialization.data(withJSONObject: rootObject, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: fileURL) + } catch { + Logger.client.error("Failed to write updated mcp.json when deleting server config: \(error.localizedDescription)") + } } private func extractErrorMessage(_ description: String) -> String { @@ -108,18 +246,59 @@ struct MCPServerToolsSection: View { let end = description.index(stackRange.lowerBound, offsetBy: 0) return description[start.. Bool { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + + guard FileManager.default.fileExists(atPath: mcpConfigFilePath), + let data = try? Data(contentsOf: configFileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let servers = jsonObject["servers"] as? [String: Any], + let serverConfig = servers[serverTools.name] else { + return false + } + + // Convert server config to JSON string + guard let serverData = try? JSONSerialization.data(withJSONObject: serverConfig, options: []), + let serverConfigString = String(data: serverData, encoding: .utf8) else { + return false + } + + // Check for placeholder patterns ending with }" + // Matches: "{PLACEHOLDER}", "${PLACEHOLDER}", "key={PLACEHOLDER}", "key=${PLACEHOLDER}", "${prefix:PLACEHOLDER}" + let placeholderPattern = "\"([a-zA-Z0-9_]+=)?\\$?\\{[a-zA-Z0-9_:\\-\\.]+\\}\"" + + guard let regex = try? NSRegularExpression(pattern: placeholderPattern, options: []) else { + return false + } + + let range = NSRange(serverConfigString.startIndex.. Binding { Binding( - get: { toolEnabledStates[tool.name] ?? (tool._status == .enabled) }, + get: { + toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool.name, currentStatus: tool._status) + }, set: { toolEnabledStates[tool.name] = $0 } ) } @@ -140,6 +321,9 @@ struct MCPServerToolsSection: View { // Update server state based on tool states updateServerState() + // Update mixed state + updateMixedState() + // Update only this specific tool status updateToolStatus(tool: tool, isEnabled: isEnabled) } @@ -178,25 +362,98 @@ struct MCPServerToolsSection: View { // Create status update for all tools let serverUpdate = UpdateMCPToolsStatusServerCollection( name: serverTools.name, - tools: allServerTools.map { + tools: allServerTools.map { UpdatedMCPToolsStatus(name: $0.name, status: enabled ? .enabled : .disabled) } ) updateMCPStatus([serverUpdate]) } + + private func updateMixedState() { + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + let enabledCount = allServerTools.filter { tool in + toolEnabledStates[tool.name] ?? (tool._status == .enabled) + }.count + + let totalCount = allServerTools.count + + if enabledCount == 0 { + checkboxMixedState = .off + } else if enabledCount == totalCount { + checkboxMixedState = .on + } else { + checkboxMixedState = .mixed + } + } private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) { - // Update status in AppState and CopilotMCPToolManager - AppState.shared.updateMCPToolsStatus(serverUpdates) - + let isDefaultAgentMode = selectedMode.isDefaultAgent Task { do { let service = try getService() - try await service.updateMCPServerToolsStatus(serverUpdates) + + if !isDefaultAgentMode { + let chatMode = selectedMode.kind + let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil + let workspaceFolders = await getWorkspaceFolders() + + try await service + .updateMCPServerToolsStatus( + serverUpdates, + chatAgentMode: chatMode, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders + ) + } else { + try await service.updateMCPServerToolsStatus(serverUpdates) + } } catch { Logger.client.error("Failed to update MCP status: \(error.localizedDescription)") } } } + + @MainActor + private func reloadModesAndUpdateStates() async { + do { + let service = try getService() + let workspaceFolders = await getWorkspaceFolders() + if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) { + modes = fetchedModes.filter { $0.kind == .Agent } + + if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) { + selectedMode = updatedMode + + let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools + .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools + + for tool in allServerTools { + let toolName = "\(serverTools.name)/\(tool.name)" + if let customTools = updatedMode.customTools { + toolEnabledStates[tool.name] = customTools.contains(toolName) + } else { + toolEnabledStates[tool.name] = false + } + } + + updateMixedState() + updateServerState() + } + } + } catch { + Logger.client.error("Failed to reload modes: \(error.localizedDescription)") + } + } + + private func isToolEnabledInMode(_ toolName: String, currentStatus: ToolStatus) -> Bool { + let configurationKey = "\(serverTools.name)/\(toolName)" + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: currentStatus, + selectedMode: selectedMode + ) + } } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift index 27f2d6cb..ecf30952 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift @@ -1,5 +1,6 @@ import SwiftUI import GitHubCopilotService +import ConversationServiceProvider /// Main list view containing all the tools struct MCPToolsListContainerView: View { @@ -7,6 +8,9 @@ struct MCPToolsListContainerView: View { @Binding var serverToggleStates: [String: Bool] let searchKey: String let expandedServerNames: Set + var isInteractionAllowed: Bool = true + @Binding var modes: [ConversationMode] + @Binding var selectedMode: ConversationMode var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -14,11 +18,15 @@ struct MCPToolsListContainerView: View { MCPServerToolsSection( serverTools: serverTools, isServerEnabled: serverToggleBinding(for: serverTools.name), - forceExpand: expandedServerNames.contains(serverTools.name) && !searchKey.isEmpty + forceExpand: expandedServerNames.contains(serverTools.name) && !searchKey.isEmpty, + isInteractionAllowed: isInteractionAllowed, + modes: $modes, + selectedMode: $selectedMode ) } } .padding(.vertical, 4) + .id(selectedMode.id) } private func serverToggleBinding(for serverName: String) -> Binding { diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift index 2cb6f530..ba8e1b4f 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift @@ -2,23 +2,35 @@ import Combine import GitHubCopilotService import Persist import SwiftUI +import SharedUIComponents +import ConversationServiceProvider struct MCPToolsListView: View { @ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared @State private var serverToggleStates: [String: Bool] = [:] @State private var isSearchBarVisible: Bool = false @State private var searchText: String = "" + @State private var modes: [ConversationMode] = [] + @Binding var selectedMode: ConversationMode + let isCustomAgentEnabled: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { GroupBox( label: - HStack(alignment: .center) { - Text("Available MCP Tools").fontWeight(.bold) - Spacer() - SearchBar(isVisible: $isSearchBarVisible, text: $searchText) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Available MCP Tools").fontWeight(.bold) + if isCustomAgentEnabled { + AgentModeDropdown(modes: $modes, selectedMode: $selectedMode) + } + Spacer() + CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible) + } + .clipped() + + AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false) } - .clipped() ) { let filteredServerTools = filteredMCPServerTools() if filteredServerTools.isEmpty { @@ -28,7 +40,10 @@ struct MCPToolsListView: View { mcpServerTools: filteredServerTools, serverToggleStates: $serverToggleStates, searchKey: searchText, - expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools) + expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools), + isInteractionAllowed: isInteractionAllowed(), + modes: $modes, + selectedMode: $selectedMode ) } } @@ -38,6 +53,9 @@ struct MCPToolsListView: View { .onChange(of: mcpToolManager.availableMCPServerTools) { _ in updateServerToggleStates() } + .onChange(of: selectedMode) { _ in + updateServerToggleStates() + } } private func updateServerToggleStates() { @@ -73,6 +91,10 @@ struct MCPToolsListView: View { // Expand all groups that have at least one tool in the filtered list Set(filteredServerTools.map { $0.name }) } + + private func isInteractionAllowed() -> Bool { + return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode) + } } /// Empty state view when no tools are available diff --git a/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift index 66a0ab81..d8df5965 100644 --- a/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift +++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift @@ -8,13 +8,17 @@ struct ToolRow: View { let toolStatus: ToolStatus let isServerEnabled: Bool @Binding var isToolEnabled: Bool + var isInteractionAllowed: Bool = true let onToolToggleChanged: (Bool) -> Void var body: some View { HStack(alignment: .center) { Toggle(isOn: Binding( get: { isToolEnabled }, - set: { onToolToggleChanged($0) } + set: { newValue in + isToolEnabled = newValue + onToolToggleChanged(newValue) + } )) { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 8) { @@ -32,9 +36,8 @@ struct ToolRow: View { Divider().padding(.vertical, 4) } } + .disabled(!isInteractionAllowed) } .padding(.vertical, 0) - .onChange(of: toolStatus) { isToolEnabled = $0 == .enabled } - .onChange(of: isServerEnabled) { if !$0 { isToolEnabled = false } } } } diff --git a/Core/Sources/KeyBindingManager/KeyBindingManager.swift b/Core/Sources/KeyBindingManager/KeyBindingManager.swift index 2fcf67fa..e0a22188 100644 --- a/Core/Sources/KeyBindingManager/KeyBindingManager.swift +++ b/Core/Sources/KeyBindingManager/KeyBindingManager.swift @@ -5,16 +5,24 @@ public final class KeyBindingManager { public init( workspacePool: WorkspacePool, acceptSuggestion: @escaping () -> Void, + acceptNESSuggestion: @escaping () -> Void, expandSuggestion: @escaping () -> Void, collapseSuggestion: @escaping () -> Void, - dismissSuggestion: @escaping () -> Void + dismissSuggestion: @escaping () -> Void, + rejectNESSuggestion: @escaping () -> Void, + goToNextEditSuggestion: @escaping () -> Void, + isNESPanelOutOfFrame: @escaping () -> Bool ) { tabToAcceptSuggestion = .init( workspacePool: workspacePool, acceptSuggestion: acceptSuggestion, - dismissSuggestion: dismissSuggestion, + acceptNESSuggestion: acceptNESSuggestion, + dismissSuggestion: dismissSuggestion, expandSuggestion: expandSuggestion, - collapseSuggestion: collapseSuggestion + collapseSuggestion: collapseSuggestion, + rejectNESSuggestion: rejectNESSuggestion, + goToNextEditSuggestion: goToNextEditSuggestion, + isNESPanelOutOfFrame: isNESPanelOutOfFrame ) } diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index f2d4c147..07568796 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -8,6 +8,7 @@ import SuggestionBasic import UserDefaultsObserver import Workspace import XcodeInspector +import SuggestionWidget final class TabToAcceptSuggestion { let hook: CGEventHookType = CGEventHook(eventsOfInterest: [.keyDown]) { message in @@ -16,9 +17,13 @@ final class TabToAcceptSuggestion { let workspacePool: WorkspacePool let acceptSuggestion: () -> Void + let acceptNESSuggestion: () -> Void let expandSuggestion: () -> Void let collapseSuggestion: () -> Void let dismissSuggestion: () -> Void + let rejectNESSuggestion: () -> Void + let goToNextEditSuggestion: () -> Void + let isNESPanelOutOfFrame: () -> Bool private var modifierEventMonitor: Any? private let userDefaultsObserver = UserDefaultsObserver( object: UserDefaults.shared, forKeyPaths: [ @@ -47,16 +52,24 @@ final class TabToAcceptSuggestion { init( workspacePool: WorkspacePool, acceptSuggestion: @escaping () -> Void, + acceptNESSuggestion: @escaping () -> Void, dismissSuggestion: @escaping () -> Void, expandSuggestion: @escaping () -> Void, - collapseSuggestion: @escaping () -> Void + collapseSuggestion: @escaping () -> Void, + rejectNESSuggestion: @escaping () -> Void, + goToNextEditSuggestion: @escaping () -> Void, + isNESPanelOutOfFrame: @escaping () -> Bool ) { _ = ThreadSafeAccessToXcodeInspector.shared self.workspacePool = workspacePool self.acceptSuggestion = acceptSuggestion + self.acceptNESSuggestion = acceptNESSuggestion self.dismissSuggestion = dismissSuggestion + self.rejectNESSuggestion = rejectNESSuggestion self.expandSuggestion = expandSuggestion self.collapseSuggestion = collapseSuggestion + self.goToNextEditSuggestion = goToNextEditSuggestion + self.isNESPanelOutOfFrame = isNESPanelOutOfFrame hook.add( .init( @@ -121,18 +134,48 @@ final class TabToAcceptSuggestion { } func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result { - let (accept, reason) = Self.shouldAcceptSuggestion( - event: event, - workspacePool: workspacePool, - xcodeInspector: ThreadSafeAccessToXcodeInspector.shared - ) - if let reason = reason { - Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)") - } - if accept { - acceptSuggestion() - return .discarded + let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) + let tab = 48 + let escape = 53 + + if keycode == tab { + let (accept, reason, codeSuggestionType) = Self.shouldAcceptSuggestion( + event: event, + workspacePool: workspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspector.shared + ) + if let reason = reason { + Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)") + } + if accept, let codeSuggestionType { + switch codeSuggestionType { + case .codeCompletion: + acceptSuggestion() + case .nes: + if isNESPanelOutOfFrame() { + goToNextEditSuggestion() + } else { + acceptNESSuggestion() + } + } + return .discarded + } + return .unchanged + } else if keycode == escape { + let (shouldReject, reason) = Self.shouldRejectNESSuggestion( + event: event, + workspacePool: workspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspector.shared + ) + if let reason = reason { + Logger.service.debug("ShouldRejectNESSuggestion: \(shouldReject ? "" : "not") rejecting due to: \(reason)") + } + if shouldReject { + rejectNESSuggestion() + return .discarded + } } + return .unchanged } @@ -146,36 +189,93 @@ final class TabToAcceptSuggestion { } extension TabToAcceptSuggestion { + + enum SuggestionAction { + case acceptSuggestion, rejectNESSuggestion + } + /// Returns whether a given keyboard event should be intercepted and trigger /// accepting a suggestion. static func shouldAcceptSuggestion( event: CGEvent, workspacePool: WorkspacePool, xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol + ) -> (accept: Bool, reason: String?, codeSuggestionType: CodeSuggestionType?) { + let (isValidEvent, eventReason) = Self.validateEvent(event) + guard isValidEvent else { return (false, eventReason, nil) } + + let (isValidFilespace, filespaceReason, codeSuggestionType) = Self.validateFilespace( + event, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector, + suggestionAction: .acceptSuggestion + ) + guard isValidFilespace else { return (false, filespaceReason, nil) } + + return (true, nil, codeSuggestionType) + } + + static func shouldRejectNESSuggestion( + event: CGEvent, + workspacePool: WorkspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol ) -> (accept: Bool, reason: String?) { - let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) - let tab = 48 - guard keycode == tab else { return (false, nil) } + let (isValidEvent, eventReason) = Self.validateEvent(event) + guard isValidEvent else { return (false, eventReason) } + + let (isValidFilespace, filespaceReason, _) = Self.validateFilespace( + event, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector, + suggestionAction: .rejectNESSuggestion + ) + guard isValidFilespace else { return (false, filespaceReason) } + + return (true, nil) + } + + static private func validateEvent(_ event: CGEvent) -> (Bool, String?) { if event.flags.contains(.maskHelp) { return (false, nil) } if event.flags.contains(.maskShift) { return (false, nil) } if event.flags.contains(.maskControl) { return (false, nil) } if event.flags.contains(.maskCommand) { return (false, nil) } + + return (true, nil) + } + + static private func validateFilespace( + _ event: CGEvent, + workspacePool: WorkspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol, + suggestionAction: SuggestionAction + ) -> (Bool, String?, CodeSuggestionType?) { guard xcodeInspector.hasActiveXcode else { - return (false, "No active Xcode") + return (false, "No active Xcode", nil) } guard xcodeInspector.hasFocusedEditor else { - return (false, "No focused editor") + return (false, "No focused editor", nil) } guard let fileURL = xcodeInspector.activeDocumentURL else { - return (false, "No active document") + return (false, "No active document", nil) } guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) else { - return (false, "No filespace") + return (false, "No filespace", nil) } - if filespace.presentingSuggestion == nil { - return (false, "No suggestion") + + var codeSuggestionType: CodeSuggestionType? = { + if let _ = filespace.presentingSuggestion { return .codeCompletion } + if let _ = filespace.presentingNESSuggestion { return .nes } + return nil + }() + guard let codeSuggestionType = codeSuggestionType else { + return (false, "No suggestion", nil) } - return (true, nil) + + if suggestionAction == .rejectNESSuggestion, codeSuggestionType != .nes { + return (false, "Invalid NES suggestion", nil) + } + + return (true, nil, codeSuggestionType) } } diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift index 77d91bb0..d1837411 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -8,6 +8,7 @@ extension ChatMessage { struct TurnItemData: Codable { var content: String + var contentImageReferences: [ImageReference] var rating: ConversationRating var references: [ConversationReference] var followUp: ConversationFollowUp? @@ -15,12 +16,19 @@ extension ChatMessage { var errorMessages: [String] = [] var steps: [ConversationProgressStep] var editAgentRounds: [AgentRound] + var parentTurnId: String? var panelMessages: [CopilotShowMessageParams] + var fileEdits: [FileEdit] + var turnStatus: ChatMessage.TurnStatus? + let requestType: RequestType + var modelName: String? + var billingMultiplier: Float? // Custom decoder to provide default value for steps init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) content = try container.decode(String.self, forKey: .content) + contentImageReferences = try container.decodeIfPresent([ImageReference].self, forKey: .contentImageReferences) ?? [] rating = try container.decode(ConversationRating.self, forKey: .rating) references = try container.decode([ConversationReference].self, forKey: .references) followUp = try container.decodeIfPresent(ConversationFollowUp.self, forKey: .followUp) @@ -28,12 +36,19 @@ extension ChatMessage { errorMessages = try container.decodeIfPresent([String].self, forKey: .errorMessages) ?? [] steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] + parentTurnId = try container.decodeIfPresent(String.self, forKey: .parentTurnId) panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] + fileEdits = try container.decodeIfPresent([FileEdit].self, forKey: .fileEdits) ?? [] + turnStatus = try container.decodeIfPresent(ChatMessage.TurnStatus.self, forKey: .turnStatus) + requestType = try container.decodeIfPresent(RequestType.self, forKey: .requestType) ?? .conversation + modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + billingMultiplier = try container.decodeIfPresent(Float.self, forKey: .billingMultiplier) } // Default memberwise init for encoding init( content: String, + contentImageReferences: [ImageReference]? = nil, rating: ConversationRating, references: [ConversationReference], followUp: ConversationFollowUp?, @@ -41,9 +56,16 @@ extension ChatMessage { errorMessages: [String] = [], steps: [ConversationProgressStep]?, editAgentRounds: [AgentRound]? = nil, - panelMessages: [CopilotShowMessageParams]? = nil + parentTurnId: String? = nil, + panelMessages: [CopilotShowMessageParams]? = nil, + fileEdits: [FileEdit]? = nil, + turnStatus: ChatMessage.TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.content = content + self.contentImageReferences = contentImageReferences ?? [] self.rating = rating self.references = references self.followUp = followUp @@ -51,13 +73,20 @@ extension ChatMessage { self.errorMessages = errorMessages self.steps = steps ?? [] self.editAgentRounds = editAgentRounds ?? [] + self.parentTurnId = parentTurnId self.panelMessages = panelMessages ?? [] + self.fileEdits = fileEdits ?? [] + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier } } func toTurnItem() -> TurnItem { let turnItemData = TurnItemData( content: self.content, + contentImageReferences: self.contentImageReferences, rating: self.rating, references: self.references, followUp: self.followUp, @@ -65,7 +94,13 @@ extension ChatMessage { errorMessages: self.errorMessages, steps: self.steps, editAgentRounds: self.editAgentRounds, - panelMessages: self.panelMessages + parentTurnId: self.parentTurnId, + panelMessages: self.panelMessages, + fileEdits: self.fileEdits, + turnStatus: self.turnStatus, + requestType: self.requestType, + modelName: self.modelName, + billingMultiplier: self.billingMultiplier ) // TODO: handle exception @@ -90,6 +125,7 @@ extension ChatMessage { clsTurnID: turnItem.CLSTurnID, role: ChatMessage.Role(rawValue: turnItem.role)!, content: turnItemData.content, + contentImageReferences: turnItemData.contentImageReferences, references: turnItemData.references, followUp: turnItemData.followUp, suggestedTitle: turnItemData.suggestedTitle, @@ -97,7 +133,13 @@ extension ChatMessage { rating: turnItemData.rating, steps: turnItemData.steps, editAgentRounds: turnItemData.editAgentRounds, + parentTurnId: turnItemData.parentTurnId, panelMessages: turnItemData.panelMessages, + fileEdits: turnItemData.fileEdits, + turnStatus: turnItemData.turnStatus, + requestType: turnItemData.requestType, + modelName: turnItemData.modelName, + billingMultiplier: turnItemData.billingMultiplier, createdAt: turnItem.createdAt, updatedAt: turnItem.updatedAt ) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 5489bf3c..6b8d0094 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -11,6 +11,7 @@ import SuggestionWidget import PersistMiddleware import ChatService import Persist +import Workspace #if canImport(ChatTabPersistent) import ChatTabPersistent @@ -364,6 +365,9 @@ public final class GraphicalUserInterfaceController { } init() { + @Dependency(\.workspacePool) var workspacePool + @Dependency(\.workspaceInvoker) var workspaceInvoker + let chatTabPool = ChatTabPool() let suggestionDependency = SuggestionWidgetControllerDependency() let setupDependency: (inout DependencyValues) -> Void = { dependencies in @@ -425,6 +429,12 @@ public final class GraphicalUserInterfaceController { await commandHandler.handleCustomCommand(command) } } + + workspaceInvoker.invokeFilespaceUpdate = { fileURL, content in + guard let (workspace, _) = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + else { return } + await workspace.didUpdateFilespace(fileURL: fileURL, content: content) + } } func start() { diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 01611d11..2d0ecffc 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -9,6 +9,8 @@ import ChatAPIService import PromptToCodeService import SuggestionBasic import SuggestionWidget +import WorkspaceSuggestionService +import Workspace @MainActor final class WidgetDataSource {} @@ -47,7 +49,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { onAcceptSuggestionTapped: { Task { let handler = PseudoCommandHandler() - await handler.acceptSuggestion() + await handler.acceptSuggestion(.codeCompletion) NSWorkspace.activatePreviousActiveXcode() } }, @@ -63,5 +65,45 @@ extension WidgetDataSource: SuggestionWidgetDataSource { } return nil } + + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? { + for workspace in await Service.shared.workspacePool.workspaces.values { + if let filespace = workspace.filespaces[url], + let nesSuggestion = filespace.presentingNESSuggestion + { + let sourceSnapshot = await getSourceSnapshot(from: filespace) + return .init( + fileURL: url, + code: nesSuggestion.text, + sourceSnapshot: sourceSnapshot, + range: nesSuggestion.range, + language: filespace.language.rawValue, + onRejectSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.rejectNESSuggestions() + } + }, + onAcceptNESSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.acceptSuggestion(.nes) + NSWorkspace.activatePreviousActiveXcode() + } + }, + onDismissNESSuggestionTapped: { + // Refer to Code Completion suggestion, the `dismiss` action is not support + } + ) + } + } + + return nil + } } + +@WorkspaceActor +private func getSourceSnapshot(from filespace: Filespace) -> FilespaceSuggestionSnapshot { + return filespace.nesSuggestionSourceSnapshot +} diff --git a/Core/Sources/Service/Helpers.swift b/Core/Sources/Service/Helpers.swift index 90ac6344..99dc2a65 100644 --- a/Core/Sources/Service/Helpers.swift +++ b/Core/Sources/Service/Helpers.swift @@ -6,6 +6,7 @@ extension NSError { static func from(_ error: Error) -> NSError { if let error = error as? ServerError { var message = "Unknown" + var errorData: Codable? = nil switch error { case let .handlerUnavailable(handler): message = "Handler unavailable: \(handler)." @@ -29,8 +30,9 @@ extension NSError { message = "Unable to send request: \(error.localizedDescription)." case let .unableToSendNotification(error): message = "Unable to send notification: \(error.localizedDescription)." - case let .serverError(code, m, _): + case let .serverError(code, m, data): message = "Server error: (\(code)) \(m)." + errorData = data case let .invalidRequest(error): message = "Invalid request: \(error?.localizedDescription ?? "Unknown")." case .timeout: @@ -38,9 +40,28 @@ extension NSError { case .unknownError: message = "Unknown error: \(error.localizedDescription)." } - return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [ - NSLocalizedDescriptionKey: message, - ]) + + var userInfo: [String: Any] = [NSLocalizedDescriptionKey: message] + + // Try to encode errorData to JSON for XPC transfer + if let errorData = errorData { + // Try to decode as MCPRegistryErrorData first + if let jsonData = try? JSONEncoder().encode(errorData), + let mcpErrorData = try? JSONDecoder().decode(MCPRegistryErrorData.self, from: jsonData) { + userInfo["errorType"] = mcpErrorData.errorType + if let status = mcpErrorData.status { + userInfo["status"] = status + } + if let shouldRetry = mcpErrorData.shouldRetry { + userInfo["shouldRetry"] = shouldRetry + } + } else if let jsonData = try? JSONEncoder().encode(errorData) { + // Fallback to encoding any Codable type + userInfo["serverErrorData"] = jsonData + } + } + + return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: userInfo) } if let error = error as? CancellationError { return NSError(domain: "com.github.CopilotForXcode", code: -100, userInfo: [ diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 899865f1..b3fd109a 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -69,20 +69,13 @@ public actor RealtimeSuggestionController { let handler = { [weak self] in guard let self else { return } await cancelInFlightTasks() - await self.triggerPrefetchDebounced() await self.notifyEditingFileChange(editor: sourceEditor.element) + await self.triggerPrefetchDebounced() } - - if #available(macOS 13.0, *) { - for await _ in valueChange._throttle(for: .milliseconds(200)) { - if Task.isCancelled { return } - await handler() - } - } else { - for await _ in valueChange { - if Task.isCancelled { return } - await handler() - } + + for await _ in valueChange { + if Task.isCancelled { return } + await handler() } } group.addTask { @@ -155,9 +148,6 @@ public actor RealtimeSuggestionController { let authStatus = await Status.shared.getAuthStatus() guard authStatus.status == .loggedIn else { return } - guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) - else { return } - if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 8072778a..ab6c35e2 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -62,7 +62,10 @@ public final class Service { keyBindingManager = .init( workspacePool: workspacePool, acceptSuggestion: { - Task { await PseudoCommandHandler().acceptSuggestion() } + Task { await PseudoCommandHandler().acceptSuggestion(.codeCompletion) } + }, + acceptNESSuggestion: { + Task { await PseudoCommandHandler().acceptSuggestion(.nes) } }, expandSuggestion: { if !ExpandableSuggestionService.shared.isSuggestionExpanded { @@ -76,6 +79,15 @@ public final class Service { }, dismissSuggestion: { Task { await PseudoCommandHandler().dismissSuggestion() } + }, + rejectNESSuggestion: { + Task { await PseudoCommandHandler().rejectNESSuggestions() } + }, + goToNextEditSuggestion: { + Task { await PseudoCommandHandler().goToNextEditSuggestion() } + }, + isNESPanelOutOfFrame: { [weak guiController] in + guiController?.store.state.suggestionWidgetState.panelState.nesSuggestionPanelState.isPanelOutOfFrame ?? false } ) let scheduledCleaner = ScheduledCleaner() diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 2ad3e765..2bdb8b91 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -10,6 +10,7 @@ import WorkspaceSuggestionService import XcodeInspector import XPCShared import AXHelper +import GitHubCopilotService /// It's used to run some commands without really triggering the menu bar item. /// @@ -57,17 +58,92 @@ struct PseudoCommandHandler { .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } if Task.isCancelled { return } + + let codeCompletionEnabled = UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + // Enabled both by Feature Flag and User. + let nesEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures && UserDefaults.shared.value(for: \.realtimeNESToggle) + guard codeCompletionEnabled || nesEnabled else { + cleanupAllSuggestions(filespace: filespace, presenter: nil) + return + } // Can't use handler if content is not available. guard let editor = await getEditorContent(sourceEditor: sourceEditor) else { return } - let fileURL = filespace.fileURL let presenter = PresentInWindowSuggestionPresenter() presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } + do { + if codeCompletionEnabled { + try await _generateRealtimeCodeCompletionSuggestions( + editor: editor, + sourceEditor: sourceEditor, + filespace: filespace, + workspace: workspace, + presenter: presenter + ) + } else { + cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter) + } + + if nesEnabled, + (codeCompletionEnabled == false || filespace.presentingSuggestion == nil) { + try await _generateRealtimeNESSuggestions( + editor: editor, + sourceEditor: sourceEditor, + filespace: filespace, + workspace: workspace, + presenter: presenter + ) + } else { + cleanupNESSuggestion(filespace: filespace, presenter: presenter) + } + + } catch { + cleanupAllSuggestions(filespace: filespace, presenter: presenter) + } + } + + @WorkspaceActor + private func cleanupCodeCompletionSuggestion( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + filespace.reset() + presenter?.discardSuggestion(fileURL: filespace.fileURL) + } + + @WorkspaceActor + private func cleanupNESSuggestion( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + filespace.resetNESSuggestion() + presenter?.discardNESSuggestion(fileURL: filespace.fileURL) + } + + @WorkspaceActor + private func cleanupAllSuggestions( + filespace: Filespace, + presenter: PresentInWindowSuggestionPresenter? + ) { + cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter) + cleanupNESSuggestion(filespace: filespace, presenter: presenter) + filespace.resetSnapshot() + filespace.resetNESSnapshot() + } + + @WorkspaceActor + func _generateRealtimeCodeCompletionSuggestions( + editor: EditorContent, + sourceEditor: SourceEditor?, + filespace: Filespace, + workspace: Workspace, + presenter: PresentInWindowSuggestionPresenter + ) async throws { if filespace.presentingSuggestion != nil { // Check if the current suggestion is still valid. if filespace.validateSuggestions( @@ -76,37 +152,78 @@ struct PseudoCommandHandler { ) { return } else { + filespace.reset() presenter.discardSuggestion(fileURL: filespace.fileURL) } } - - do { - try await workspace.generateSuggestions( - forFileAt: fileURL, - editor: editor + + let fileURL = filespace.fileURL + + try await workspace.generateSuggestions( + forFileAt: fileURL, + editor: editor + ) + let editorContent = sourceEditor?.getContent() + if let editorContent { + _ = filespace.validateSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition ) - if let sourceEditor { - let editorContent = sourceEditor.getContent() - _ = filespace.validateSuggestions( - lines: editorContent.lines, - cursorPosition: editorContent.cursorPosition + } + + if !filespace.errorMessage.isEmpty { + presenter + .presentWarningMessage( + filespace.errorMessage, + url: "https://github.com/github-copilot/signup/copilot_individual" ) - } - if !filespace.errorMessage.isEmpty { - presenter - .presentWarningMessage( - filespace.errorMessage, - url: "https://github.com/github-copilot/signup/copilot_individual" - ) - } - if filespace.presentingSuggestion != nil { - presenter.presentSuggestion(fileURL: fileURL) - workspace.notifySuggestionShown(fileFileAt: fileURL) + } + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } + + @WorkspaceActor + func _generateRealtimeNESSuggestions( + editor: EditorContent, + sourceEditor: SourceEditor?, + filespace: Filespace, + workspace: Workspace, + presenter: PresentInWindowSuggestionPresenter + ) async throws { + if filespace.presentingNESSuggestion != nil { + // Check if the current NES suggestion is still valid. + if filespace.validateNESSuggestions( + lines: editor.lines, + cursorPosition: editor.cursorPosition + ) { + return } else { - presenter.discardSuggestion(fileURL: fileURL) + filespace.resetNESSuggestion() + presenter.discardNESSuggestion(fileURL: filespace.fileURL) } - } catch { - return + } + + let fileURL = filespace.fileURL + + try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor) + + let editorContent = sourceEditor?.getContent() + if let editorContent { + _ = filespace.validateNESSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition + ) + } + // TODO: handle errorMessage if any + if filespace.presentingNESSuggestion != nil { + presenter.presentNESSuggestion(fileURL: fileURL) + workspace.notifyNESSuggestionShown(forFileAt: fileURL) + } else { + presenter.discardNESSuggestion(fileURL: fileURL) } } @@ -127,6 +244,24 @@ struct PseudoCommandHandler { PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL) } } + + @WorkspaceActor + func invalidateRealtimeNESSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async { + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } + + if filespace.presentingNESSuggestion == nil { + return // skip if there's no NES suggestion presented. + } + + let content = sourceEditor.getContent() + if !filespace.validateNESSuggestions( + lines: content.lines, + cursorPosition: content.cursorPosition + ) { + PresentInWindowSuggestionPresenter().discardNESSuggestion(fileURL: fileURL) + } + } func rejectSuggestions() async { let handler = WindowBaseCommandHandler() @@ -142,6 +277,21 @@ struct PseudoCommandHandler { usesTabsForIndentation: false )) } + + func rejectNESSuggestions() async { + let handler = WindowBaseCommandHandler() + _ = try? await handler.rejectNESSuggestion(editor: .init( + content: "", + lines: [], + uti: "", + cursorPosition: .outOfScope, + cursorOffset: -1, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) + } func handleCustomCommand(_ command: CustomCommand) async { guard let editor = await { @@ -248,14 +398,20 @@ struct PseudoCommandHandler { } } - func acceptSuggestion() async { + func acceptSuggestion(_ suggestionType: CodeSuggestionType) async { do { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? - .triggerCopilotCommand(name: "Accept Suggestion") + switch suggestionType { + case .codeCompletion: + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion") + case .nes: + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Next Edit Suggestion") + } } catch { let lastBundleNotFoundTime = Self.lastBundleNotFoundTime let lastBundleDisabledTime = Self.lastBundleDisabledTime @@ -318,7 +474,7 @@ struct PseudoCommandHandler { } let handler = WindowBaseCommandHandler() do { - guard let result = try await handler.acceptSuggestion(editor: .init( + let editor: EditorContent = .init( content: content, lines: lines, uti: "", @@ -328,7 +484,18 @@ struct PseudoCommandHandler { tabSize: 0, indentSize: 0, usesTabsForIndentation: false - )) else { return } + ) + + let result = try await { + switch suggestionType { + case .codeCompletion: + return try await handler.acceptSuggestion(editor: editor) + case .nes: + return try await handler.acceptNESSuggestion(editor: editor) + } + }() + + guard let result else { return } try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) } catch { @@ -336,6 +503,27 @@ struct PseudoCommandHandler { } } } + + func goToNextEditSuggestion() async { + do { + guard let sourceEditor = await XcodeInspector.shared.safe.focusedEditor, + let fileURL = sourceEditor.realtimeDocumentURL + else { return } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + guard let suggestion = await workspace.getNESSuggestion(forFileAt: fileURL) + else { return } + + AXHelper.scrollSourceEditorToLine( + suggestion.range.start.line, + content: sourceEditor.getContent().content, + focusedElement: sourceEditor.element + ) + } catch { + // Handle if needed + } + } func dismissSuggestion() async { guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index 3d612e82..7aa5d20a 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -11,8 +11,12 @@ protocol SuggestionCommandHandler { @ServiceActor func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 694bff25..4e0b2a74 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -57,8 +57,21 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { if filespace.presentingSuggestion != nil { presenter.presentSuggestion(fileURL: fileURL) workspace.notifySuggestionShown(fileFileAt: fileURL) + presenter.discardNESSuggestion(fileURL: fileURL) } else { presenter.discardSuggestion(fileURL: fileURL) + try Task.checkCancellation() + + // When no code completion generated, fallback to NES + try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor) + + try Task.checkCancellation() + + if filespace.presentingNESSuggestion != nil { + presenter.presentNESSuggestion(fileURL: fileURL) + } else { + presenter.discardNESSuggestion(fileURL: fileURL) + } } } @@ -137,6 +150,28 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) presenter.discardSuggestion(fileURL: fileURL) } + + func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await _rejectNESSuggestion(editor: editor) + } catch { + presenter.presentError(error) + } + } + return nil + } + + @WorkspaceActor + private func _rejectNESSuggestion(editor: EditorContent) async throws { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + workspace.rejectNESSuggestion(forFileAt: fileURL, editor: editor) + presenter.discardNESSuggestion(fileURL: fileURL) + } @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { @@ -174,6 +209,41 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + + @WorkspaceActor + func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return nil } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + if let acceptedSuggestion = workspace.acceptNESSuggestion( + forFileAt: fileURL, editor: editor + ) { + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: acceptedSuggestion, + extraInfo: &extraInfo, + isNES: true + ) + + presenter.discardNESSuggestion(fileURL: fileURL) + + return .init( + content: String(lines.joined(separator: "")), + newSelection: .cursor(cursorPosition), + modifications: extraInfo.modifications + ) + } + + return nil + } func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 4007a06c..80f60141 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -11,6 +11,13 @@ struct PresentInWindowSuggestionPresenter { controller.suggestCode() } } + + func presentNESSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.suggestNESCode() + } + } func expandSuggestion(fileURL: URL) { Task { @MainActor in @@ -25,6 +32,13 @@ struct PresentInWindowSuggestionPresenter { controller.discardSuggestion() } } + + func discardNESSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.discardNESSuggestion() + } + } func markAsProcessing(_ isProcessing: Bool) { Task { @MainActor in diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 72769162..b64e841c 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -9,6 +9,7 @@ import XPCShared import HostAppActivator import XcodeInspector import GitHubCopilotViewModel +import Workspace import ConversationServiceProvider public class XPCService: NSObject, XPCServiceProtocol { @@ -120,6 +121,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.rejectSuggestion(editor: editor) } } + + public func getNESSuggestionRejectedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.rejectNESSuggestion(editor: editor) + } + } public func getSuggestionAcceptedCode( editorContent: Data, @@ -129,6 +139,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.acceptSuggestion(editor: editor) } } + + public func getNESSuggestionAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptNESSuggestion(editor: editor) + } + } public func getPromptToCodeAcceptedCode( editorContent: Data, @@ -229,6 +248,29 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(nil) } } + + public func toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void) { + guard AXIsProcessTrusted() else { + reply(NoAccessToAccessibilityAPIError()) + return + } + Task { @ServiceActor in + await Service.shared.realtimeSuggestionController.cancelInFlightTasks() + let on = !UserDefaults.shared.value(for: \.realtimeNESToggle) + UserDefaults.shared.set(on, for: \.realtimeNESToggle) + Task { @MainActor in + Service.shared.guiController.store + .send(.suggestionWidget(.toastPanel(.toast(.toast( + "Next Edit Suggestions (NES) is turned \(on ? "on" : "off")", + .info, + nil + ))))) + Service.shared.guiController.store + .send(.suggestionWidget(.panel(.onRealtimeNESToggleChanged(on)))) + } + reply(nil) + } + } public func postNotification(name: String, withReply reply: @escaping () -> Void) { reply() @@ -290,22 +332,61 @@ public class XPCService: NSObject, XPCServiceProtocol { } } - public func updateMCPServerToolsStatus(tools: Data) { + public func updateMCPServerToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data? + ) { // Decode the data let decoder = JSONDecoder() var collections: [UpdateMCPToolsStatusServerCollection] = [] + var folders: [WorkspaceFolder]? = nil + var mode: ChatMode? = nil + var modeId: String? = nil do { collections = try decoder.decode([UpdateMCPToolsStatusServerCollection].self, from: tools) + if let workspaceFolders = workspaceFolders { + folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders) + } + if let chatAgentMode = chatAgentMode { + mode = try? decoder.decode(ChatMode.self, from: chatAgentMode) + } + if let customChatModeId = customChatModeId { + modeId = try? decoder.decode(String.self, from: customChatModeId) + } if collections.isEmpty { return } } catch { - Logger.service.error("Failed to decode MCP server collections: \(error)") + Logger.service.error("Failed to decode MCP server collections or workspace folders: \(error)") return } Task { @MainActor in - await GitHubCopilotService.updateAllClsMCP(collections: collections) + // Only use auth service when ALL three parameters are provided. + if mode != nil, modeId != nil, folders != nil { + do { + if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) { + if let service = GitHubCopilotService.getProjectGithubCopilotService( + for: projectRootURL + ) { + let params = UpdateMCPToolsStatusParams( + chatModeKind: mode, + customChatModeId: modeId, + workspaceFolders: folders, + servers: collections + ) + try await service.updateMCPToolsStatus(params: params) + } + } + } catch { + Logger.service.error("Failed to update MCP tool status via auth service: \(error)") + } + } else { + // Fallback to legacy/global update when context not fully provided. + await GitHubCopilotService.updateAllClsMCP(collections: collections) + } } } @@ -329,7 +410,7 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(data, nil) } catch { Logger.service.error("Failed to list MCP Registry servers: \(error)") - reply(nil, error) + reply(nil, NSError.from(error)) } } } @@ -352,7 +433,21 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(data, nil) } catch { Logger.service.error("Failed to get MCP Registry servers: \(error)") - reply(nil, error) + reply(nil, NSError.from(error)) + } + } + } + + public func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let response = try await service.getMCPRegistryAllowlist() + let data = try? JSONEncoder().encode(response) + reply(data, nil) + } catch { + Logger.service.error("Failed to get MCP Registry allowlist: \(error)") + reply(nil, NSError.from(error)) } } } @@ -369,26 +464,81 @@ public class XPCService: NSObject, XPCServiceProtocol { } } - public func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void) { + public func refreshClientTools(withReply reply: @escaping (Data?) -> Void) { + Task { @MainActor in + await GitHubCopilotService.refreshClientTools() + let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() + if let availableLanguageModelTools = availableLanguageModelTools { + let data = try? JSONEncoder().encode(availableLanguageModelTools) + reply(data) + } else { + reply(nil) + } + } + } + + public func updateToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data?, + withReply reply: @escaping (Data?) -> Void + ) { // Decode the data let decoder = JSONDecoder() var toolStatusUpdates: [ToolStatusUpdate] = [] + var folders: [WorkspaceFolder]? = nil + var mode: ChatMode? = nil + var modeId: String? = nil do { toolStatusUpdates = try decoder.decode([ToolStatusUpdate].self, from: tools) + if let workspaceFolders = workspaceFolders { + folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders) + } + if let chatAgentMode = chatAgentMode { + mode = try? decoder.decode(ChatMode.self, from: chatAgentMode) + } + if let customChatModeId = customChatModeId { + modeId = try? decoder.decode(String.self, from: customChatModeId) + } if toolStatusUpdates.isEmpty { let emptyData = try JSONEncoder().encode([LanguageModelTool]()) reply(emptyData) return } } catch { - Logger.service.error("Failed to decode built-in tools: \(error)") + Logger.service.error("Failed to decode built-in tools or workspace folders: \(error)") reply(nil) return } Task { @MainActor in - let updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) - + var updatedTools: [LanguageModelTool] = [] + if mode != nil, modeId != nil, folders != nil { + // Use auth service path when all three context parameters are present. + do { + if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) { + if let service = GitHubCopilotService.getProjectGithubCopilotService( + for: projectRootURL + ) { + updatedTools = try await service.updateToolsStatus( + params: .init( + chatmodeKind: mode, + customChatModeId: modeId, + workspaceFolders: folders, + tools: toolStatusUpdates + ) + ) + } + } + } catch { + Logger.service.error("Failed contextual tools update: \(error)") + updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) + } + } else { + // Fallback without contextual parameters. + updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates) + } // Encode and return the updated tools do { let data = try JSONEncoder().encode(updatedTools) @@ -409,6 +559,33 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(data) } + public func getCopilotPolicy( + withReply reply: @escaping (Data?) -> Void + ) { + let copilotPolicy = CopilotPolicyNotifierImpl.shared.copilotPolicy + let data = try? JSONEncoder().encode(copilotPolicy) + reply(data) + } + + public func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + var folders: [WorkspaceFolder]? = nil + if let workspaceFolders = workspaceFolders { + folders = try JSONDecoder().decode([WorkspaceFolder].self, from: workspaceFolders) + } + + let modes = try await service.modes(workspaceFolders: folders) + let data = try JSONEncoder().encode(modes) + reply(data, nil) + } catch { + Logger.service.error("Failed to get modes: \(error.localizedDescription)") + reply(nil, NSError.from(error)) + } + } + } + // MARK: - Auth public func signOutAllGitHubCopilotService() { Task { @MainActor in @@ -430,6 +607,21 @@ public class XPCService: NSObject, XPCServiceProtocol { } } + public func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void) { + Task { @MainActor in + do { + let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() + let models = try await service.models() + CopilotModelManager.updateLLMs(models) + let data = try JSONEncoder().encode(models) + reply(data, nil) + } catch { + Logger.service.error("Failed to get models: \(error.localizedDescription)") + reply(nil, NSError.from(error)) + } + } + } + // MARK: - BYOK public func saveBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) { let decoder = JSONDecoder() diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index df78acf5..c2edff6c 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -20,7 +20,8 @@ public struct SuggestionInjector { cursorPosition: inout CursorPosition, completion: CodeSuggestion, extraInfo: inout ExtraInfo, - suggestionLineLimit: Int? = nil + suggestionLineLimit: Int? = nil, + isNES: Bool = false ) { extraInfo.didChangeContent = true extraInfo.didChangeCursorPosition = true @@ -77,6 +78,35 @@ public struct SuggestionInjector { at: toBeInserted[0].startIndex ) } + + // appending suffix text not in range if needed. + if isNES, + let lastRemovedLine, + !lastRemovedLine.isEmptyOrNewLine, + end.character >= 0, + end.character < lastRemovedLine.count, + !toBeInserted.isEmpty + { + let suffixStartIndex = lastRemovedLine.utf16.index( + lastRemovedLine.utf16.startIndex, + offsetBy: end.character, + limitedBy: lastRemovedLine.utf16.endIndex + ) ?? lastRemovedLine.utf16.endIndex + var suffix = String(lastRemovedLine[suffixStartIndex...]) + if suffix.last?.isNewline ?? false { + suffix.removeLast(1) + } + let lastIndex = toBeInserted.endIndex - 1 + var lastLine = toBeInserted[lastIndex] + if lastLine.last?.isNewline ?? false { + lastLine.removeLast(1) + lastLine.append(contentsOf: suffix) + lastLine.append(lineEnding) + } else { + lastLine.append(contentsOf: suffix) + } + toBeInserted[lastIndex] = lastLine + } let recoveredSuffixLength = recoverSuffixIfNeeded( endOfReplacedContent: end, diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 2802d787..1766001c 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -64,6 +64,28 @@ public extension SuggestionService { return try await getSuggestion(request, workspaceInfo) } + + func getNESSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo, + ) async throws -> [SuggestionBasic.CodeSuggestion] { + var getNESSuggestion = suggestionProvider.getNESSuggestions(_:workspaceInfo:) + let configuration = await configuration + + for middleware in middlewares.reversed() { + getNESSuggestion = { [getNESSuggestion] request, workspaceInfo in + try await middleware.getNESSuggestion( + request, + configuration: configuration, + next: { [getNESSuggestion] request in + try await getNESSuggestion(request, workspaceInfo) + } + ) + } + } + + return try await getNESSuggestion(request, workspaceInfo) + } func notifyAccepted( _ suggestion: SuggestionBasic.CodeSuggestion, diff --git a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift new file mode 100644 index 00000000..fd423990 --- /dev/null +++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift @@ -0,0 +1,1186 @@ +import AppKit +import ChatService +import ComposableArchitecture +import ConversationServiceProvider +import ConversationTab +import GitHubCopilotService +import LanguageServerProtocol +import Logger +import SharedUIComponents +import SuggestionBasic +import SwiftUI +import XcodeInspector + +struct SelectedAgentModel: Equatable { + let displayName: String + let modelName: String + let source: ModelSource + + enum ModelSource: Equatable { + case copilot + case byok(provider: String) + } +} + +struct AgentConfigurationWidgetView: View { + let store: StoreOf + + @State private var showPopover = false + @State private var isHovered = false + @State private var selectedToolStates: [String: [String: Bool]] = [:] + @State private var selectedModel: SelectedAgentModel? = nil + @State private var searchText = "" + @State private var isSearchFieldExpanded = false + @State private var generateHandoffExample: Bool = true + @Environment(\.colorScheme) var colorScheme + + var body: some View { + WithPerceptionTracking { + if store.isPanelDisplayed { + VStack { + buildAgentConfigurationButton() + .popover(isPresented: $showPopover) { + buildConfigView(currentMode: store.currentMode).padding(.horizontal, 4) + } + } + .animation(.easeInOut(duration: 0.2), value: store.isPanelDisplayed) + .onChange(of: showPopover) { newValue in + if newValue { + // Load state from agent file when popover is opened + loadToolStatesFromAgentFile(currentMode: store.currentMode) + // Refresh client tools to get any late-arriving server tools + Task { + await GitHubCopilotService.refreshClientTools() + } + } + } + } + } + } + + @ViewBuilder + private func buildAgentConfigurationButton() -> some View { + let fontSize = store.lineHeight * 0.7 + let lineHeight = store.lineHeight + + ZStack { + Button(action: { showPopover.toggle() }) { + HStack(spacing: 4) { + Image(systemName: "square.and.pencil") + .resizable() + .scaledToFit() + .frame(width: fontSize, height: fontSize) + Text("Customize Agent") + .font(.system(size: fontSize)) + .fixedSize() + } + .frame(height: lineHeight) + .foregroundColor(isHovered ? Color("ItemSelectedColor") : .secondary) + } + .buttonStyle(.plain) + .contentShape(Capsule()) + .help("Configure tools and model for custom agent") + .onHover { isHovered = $0 } + } + } + + @ViewBuilder + private func buildConfigView(currentMode: ConversationMode?) -> some View { + if let currentMode = currentMode { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("Configure Model") + .font(.system(size: 15, weight: .bold)) + + Text("The AI model to use when running the prompt. If not specified, the currently selected model in model picker is used.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.bottom, 8) + + AgentModelPickerSection( + selectedModel: $selectedModel + ) + + Divider() + + if currentMode.handOffs?.isEmpty ?? true { + Text("Configure Handoffs") + .font(.system(size: 15, weight: .bold)) + + Text("Suggested next actions or prompts to transition between custom agents. Handoff buttons appear as interactive suggestions after a chat response completes.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Toggle(isOn: $generateHandoffExample) { + Text("Generate Handoff Example") + .font(.system(size: 11, weight: .regular)) + } + .toggleStyle(.checkbox) + .help("Adds a starter handoff example to the agent file YAML frontmatter.") + + Divider() + } + + // Title with Search + HStack { + Text("Configure Tools") + .font(.system(size: 15, weight: .bold)) + + Spacer() + + CollapsibleSearchField( + searchText: $searchText, + isExpanded: $isSearchFieldExpanded, + placeholderString: "Search tools..." + ) + } + + Text("A list of built-in tools and MCP tools that are available for this agent. If a given tool is not available when running the agent, it is ignored.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.bottom, 8) + + // MCP Tools Section + AgentToolsSection( + title: "MCP Tools", + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + + // Built-In Tools Section + AgentBuiltInToolsSection( + title: "Built-In Tools", + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + } + .padding(12) + } + .frame(width: 500, height: 600) + + Divider() + + // Buttons + HStack(spacing: 12) { + Button(action: { showPopover = false }) { + Text("Cancel") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button(action: { + updateAgentTools(selectedToolStates: selectedToolStates, currentMode: currentMode) + applyAgentFileChanges( + selectedModel: selectedModel, + generateHandoffExample: generateHandoffExample, + currentMode: currentMode + ) + showPopover = false + }) { + Text("Apply") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } else { + // Should never be shown since widget only displays when mode exists + VStack { + Text("No agent mode available") + .foregroundColor(.secondary) + } + .frame(width: 500, height: 600) + } + } + + // MARK: - Helper functions + + // MARK: - Agent File Utilities + + private struct AgentFileAccess { + let documentURL: URL + let content: String + } + + private func validateAndReadAgentFile() -> AgentFileAccess? { + guard let documentURL = store.withState({ $0.focusedEditor?.realtimeDocumentURL }) else { + Logger.extension.error("Could not access agent file - documentURL is nil") + return nil + } + guard documentURL.pathExtension == "md" else { + Logger.extension.error("Could not access agent file - invalid extension") + return nil + } + guard documentURL.lastPathComponent.hasSuffix(".agent.md") else { + Logger.extension.error("Could not access agent file - filename does not end with .agent.md") + return nil + } + guard let content = try? String(contentsOf: documentURL) else { + Logger.extension.error("Could not access agent file - unable to read file") + return nil + } + return AgentFileAccess(documentURL: documentURL, content: content) + } + + private struct YAMLFrontmatterInfo { + var lines: [String] + let frontmatterEndIndex: Int? + let modelLineIndex: Int? + let toolsLineIndex: Int? + let handoffsLineIndex: Int? + } + + private func parseYAMLFrontmatter(content: String) -> YAMLFrontmatterInfo { + var lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + var frontmatterEndIndex: Int? + var modelLineIndex: Int? + var toolsLineIndex: Int? + var handoffsLineIndex: Int? + + for (idx, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "---" { + if !inFrontmatter { + inFrontmatter = true + } else { + inFrontmatter = false + frontmatterEndIndex = idx + break + } + } else if inFrontmatter { + if trimmed.hasPrefix("model:") { + modelLineIndex = idx + } else if trimmed.hasPrefix("tools:") { + toolsLineIndex = idx + } else if trimmed.hasPrefix("handoffs:") || trimmed.hasPrefix("handOffs:") { + handoffsLineIndex = idx + } + } + } + + return YAMLFrontmatterInfo( + lines: lines, + frontmatterEndIndex: frontmatterEndIndex, + modelLineIndex: modelLineIndex, + toolsLineIndex: toolsLineIndex, + handoffsLineIndex: handoffsLineIndex + ) + } + + private func writeToAgentFile(url: URL, content: String, successMessage: String) { + do { + try content.write(to: url, atomically: true, encoding: .utf8) + Logger.extension.info(successMessage) + } catch { + Logger.extension.error("Error writing agent file: \(error)") + } + } + + private func formatModelLine(_ selectedModel: SelectedAgentModel?) -> String? { + guard let model = selectedModel else { return nil } + let sourceLabel: String + switch model.source { + case .copilot: + sourceLabel = "copilot" + case let .byok(provider): + sourceLabel = provider + } + return "model: '\(model.displayName) (\(sourceLabel))'" + } + + private func loadMCPToolStates(enabledTools: Set) { + guard let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else { return } + for server in mcpServerTools { + for tool in server.tools { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: server.name, + toolName: tool.name + ) + selectedToolStates["mcp"]?[configurationKey] = enabledTools.contains(configurationKey) + } + } + } + + private func loadBuiltInToolStates(enabledTools: Set) { + guard let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else { return } + for tool in builtInTools { + selectedToolStates["builtin"]?[tool.name] = enabledTools.contains(tool.name) + } + } + + private func collectMCPToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [UpdateMCPToolsStatusServerCollection] { + guard let mcpStates = selectedToolStates["mcp"], + let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else { + return [] + } + + return mcpServerTools.map { server in + let toolUpdates = server.tools.map { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: server.name, + toolName: tool.name + ) + let isEnabled = mcpStates[configurationKey] ?? false + return UpdatedMCPToolsStatus( + name: tool.name, + status: isEnabled ? .enabled : .disabled + ) + } + return UpdateMCPToolsStatusServerCollection( + name: server.name, + tools: toolUpdates + ) + } + } + + private func collectBuiltInToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [ToolStatusUpdate] { + guard let builtInStates = selectedToolStates["builtin"], + let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else { + return [] + } + + return builtInTools.map { tool in + let isEnabled = builtInStates[tool.name] ?? false + return ToolStatusUpdate( + name: tool.name, + status: isEnabled ? .enabled : .disabled + ) + } + } + + private func updateMCPToolsViaAPI( + service: GitHubCopilotService, + mcpCollections: [UpdateMCPToolsStatusServerCollection], + chatModeKind: ChatMode?, + customChatModeId: String?, + workspaceFolders: [WorkspaceFolder] + ) async { + guard !mcpCollections.isEmpty else { return } + do { + let _ = try await service.updateMCPToolsStatus( + params: UpdateMCPToolsStatusParams( + chatModeKind: chatModeKind, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders, + servers: mcpCollections + ) + ) + Logger.extension.info("MCP tools updated via API") + + // Notify Settings app about custom agent tool changes + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotCustomAgentToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } catch { + Logger.extension.error("Error updating MCP tools via API: \(error)") + } + } + + private func updateBuiltInToolsViaAPI( + service: GitHubCopilotService, + builtInToolUpdates: [ToolStatusUpdate], + chatModeKind: ChatMode?, + customChatModeId: String?, + workspaceFolders: [WorkspaceFolder] + ) async { + guard !builtInToolUpdates.isEmpty else { return } + do { + let _ = try await service.updateToolsStatus( + params: UpdateToolsStatusParams( + chatmodeKind: chatModeKind, + customChatModeId: customChatModeId, + workspaceFolders: workspaceFolders, + tools: builtInToolUpdates + ) + ) + Logger.extension.info("Built-in tools updated via API") + + // Notify Settings app about custom agent tool changes + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotCustomAgentToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } catch { + Logger.extension.error("Error updating built-in tools via API: \(error)") + } + } + + private func parseModelFromMode(_ mode: ConversationMode?) -> SelectedAgentModel? { + guard let mode = mode, + let modelString = mode.model else { + return nil + } + + // Parse format: "displayName (copilot)" or "displayName (providerName)" + if let openParen = modelString.lastIndex(of: "("), + let closeParen = modelString.lastIndex(of: ")") { + let displayName = String(modelString[.. Int? { + let modelLine = formatModelLine(selectedModel) + + if let modelLine = modelLine { + if let modelIdx = yamlInfo.modelLineIndex { + yamlInfo.lines[modelIdx] = modelLine + return modelIdx + } else if let endIdx = yamlInfo.frontmatterEndIndex { + yamlInfo.lines.insert(modelLine, at: endIdx) + return endIdx + } + } else if let modelIdx = yamlInfo.modelLineIndex { + yamlInfo.lines.remove(at: modelIdx) + return nil + } + return yamlInfo.modelLineIndex + } + + private func applyHandoffsUpdate(to yamlInfo: inout YAMLFrontmatterInfo, afterModelIndex modelIndex: Int?) { + guard yamlInfo.handoffsLineIndex == nil else { return } + + let snippet = [ + "handoffs:", + " - label: Start Implementation", + " agent: implementation", + " prompt: Now implement the plan outlined above.", + " send: true", + ] + + if let mIdx = modelIndex { + yamlInfo.lines.insert(contentsOf: snippet, at: mIdx + 1) + } else if let endIdx = yamlInfo.frontmatterEndIndex { + yamlInfo.lines.insert(contentsOf: snippet, at: endIdx) + } + } + + // MARK: - MCP Tools Section + + private struct AgentToolsSection: View { + let title: String + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + + let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() ?? [] + + if mcpServerTools.isEmpty { + Text("No MCP tools available.") + .foregroundColor(.secondary) + .font(.system(size: 13)) + .padding(.vertical, 8) + } else { + ForEach(mcpServerTools, id: \.name) { server in + AgentMCPServerSection( + serverTools: server, + currentMode: currentMode, + selectedToolStates: $selectedToolStates, + searchText: searchText + ) + } + } + } + } + } + + // MARK: - MCP Server Section + + private struct AgentMCPServerSection: View { + let serverTools: MCPServerToolsCollection + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + @State private var isExpanded: Bool = false + @State private var checkboxState: CheckboxMixedState = .off + + private func matchesSearch(_ text: String, _ description: String?) -> Bool { + guard !searchText.isEmpty else { return true } + let lowercasedSearch = searchText.lowercased() + return text.lowercased().contains(lowercasedSearch) || + (description?.lowercased().contains(lowercasedSearch) ?? false) + } + + private var serverNameMatches: Bool { + matchesSearch(serverTools.name, nil) + } + + private var hasMatchingTools: Bool { + guard !searchText.isEmpty else { return false } + if serverNameMatches { return true } + return serverTools.tools.contains { tool in + matchesSearch(tool.name, tool.description) + } + } + + private var filteredTools: [MCPTool] { + guard !searchText.isEmpty else { return serverTools.tools } + if serverNameMatches { return serverTools.tools } + return serverTools.tools.filter { tool in + matchesSearch(tool.name, tool.description) + } + } + + var body: some View { + // Don't show this server if search is active and there are no matches + if searchText.isEmpty || hasMatchingTools { + VStack(alignment: .leading, spacing: 0) { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + Divider() + .padding(.vertical, 4) + + ForEach(filteredTools, id: \.name) { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + let isSelected = selectedToolStates["mcp"]?[configurationKey] ?? AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + AgentToolRow( + toolName: tool.name, + toolDescription: tool.description, + isSelected: isSelected, + isBlocked: serverTools.status == .blocked || serverTools.status == .error, + onToggle: { isSelected in + if selectedToolStates["mcp"] == nil { + selectedToolStates["mcp"] = [:] + } + selectedToolStates["mcp"]?[configurationKey] = isSelected + updateServerSelectionState() + } + ) + .padding(.leading, 20) + } + } + } label: { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxState, + action: { + // Toggle based on current state + switch checkboxState { + case .off, .mixed: + toggleAllTools(selected: true) + case .on: + toggleAllTools(selected: false) + } + } + ) + .disabled(serverTools.status == .blocked || serverTools.status == .error) + + HStack(spacing: 8) { + if serverTools.status == .blocked || serverTools.status == .error { + Text("MCP Server: \(serverTools.name)") + .font(.system(size: 13, weight: .medium)) + } else { + let selectedCount = serverTools.tools.filter { tool in + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + if let state = selectedToolStates["mcp"]?[configurationKey] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + }.count + Text("MCP Server: \(serverTools.name) ") + .font(.system(size: 13, weight: .medium)) + + Text("(\(selectedCount) of \(serverTools.tools.count) Selected)") + .font(.system(size: 13, weight: .regular)) + } + + if serverTools.status == .error { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .font(.system(size: 11)) + } else if serverTools.status == .blocked { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 11)) + } + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + + Spacer() + } + } + .padding(.vertical, 4) + } + .disabled(serverTools.status != .running) + .onAppear { + updateServerSelectionState() + } + .onChange(of: selectedToolStates) { _ in + updateServerSelectionState() + } + .onChange(of: searchText) { _ in + if hasMatchingTools && !isExpanded && serverTools.status == .running { + isExpanded = true + } + } + } + } + + private func toggleAllTools(selected: Bool) { + if selectedToolStates["mcp"] == nil { + selectedToolStates["mcp"] = [:] + } + for tool in serverTools.tools { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + selectedToolStates["mcp"]?[configurationKey] = selected + } + updateServerSelectionState() + } + + private func isToolSelected(_ tool: MCPTool) -> Bool { + let configurationKey = AgentModeToolHelpers.makeConfigurationKey( + serverName: serverTools.name, + toolName: tool.name + ) + if let state = selectedToolStates["mcp"]?[configurationKey] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: configurationKey, + currentStatus: .enabled, + selectedMode: currentMode + ) + } + + private func updateServerSelectionState() { + guard serverTools.status != .blocked && serverTools.status != .error && !serverTools.tools.isEmpty else { + checkboxState = .off + return + } + + let selectedCount = serverTools.tools.filter { isToolSelected($0) }.count + checkboxState = selectedCount == 0 ? .off : (selectedCount == serverTools.tools.count ? .on : .mixed) + } + } + + // MARK: - Built-In Tools Section + + private struct AgentBuiltInToolsSection: View { + let title: String + let currentMode: ConversationMode + @Binding var selectedToolStates: [String: [String: Bool]] + let searchText: String + + @State private var isExpanded: Bool = false + @State private var checkboxState: CheckboxMixedState = .off + + private func matchesBuiltInSearch(_ tool: LanguageModelTool) -> Bool { + guard !searchText.isEmpty else { return true } + let lowercasedSearch = searchText.lowercased() + return tool.name.lowercased().contains(lowercasedSearch) || + (tool.displayName?.lowercased().contains(lowercasedSearch) ?? false) || + (tool.description?.lowercased().contains(lowercasedSearch) ?? false) + } + + private var builtInNameMatches: Bool { + guard !searchText.isEmpty else { return false } + let lowercasedSearch = searchText.lowercased() + return "built-in".contains(lowercasedSearch) || "builtin".contains(lowercasedSearch) + } + + private func hasMatchingTools(builtInTools: [LanguageModelTool]) -> Bool { + guard !searchText.isEmpty else { return false } + if builtInNameMatches { return true } + return builtInTools.contains { matchesBuiltInSearch($0) } + } + + private func filteredTools(builtInTools: [LanguageModelTool]) -> [LanguageModelTool] { + guard !searchText.isEmpty else { return builtInTools } + if builtInNameMatches { return builtInTools } + return builtInTools.filter { matchesBuiltInSearch($0) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + + let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? [] + + if builtInTools.isEmpty { + Text("No built-in tools available.") + .foregroundColor(.secondary) + .font(.system(size: 13)) + .padding(.vertical, 8) + } else if searchText.isEmpty || hasMatchingTools(builtInTools: builtInTools) { + VStack(alignment: .leading, spacing: 0) { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + Divider() + .padding(.vertical, 4) + + ForEach(filteredTools(builtInTools: builtInTools), id: \.name) { tool in + let isSelected = selectedToolStates["builtin"]?[tool.name] ?? AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + AgentToolRow( + toolName: tool.displayName ?? tool.name, + toolDescription: tool.description, + isSelected: isSelected, + isBlocked: false, + onToggle: { isSelected in + if selectedToolStates["builtin"] == nil { + selectedToolStates["builtin"] = [:] + } + selectedToolStates["builtin"]?[tool.name] = isSelected + updateBuiltInSelectionState(builtInTools: builtInTools) + } + ) + .padding(.leading, 20) + } + } + } label: { + HStack(spacing: 8) { + MixedStateCheckbox( + title: "", + font: .systemFont(ofSize: 13), + state: $checkboxState, + action: { + // Toggle based on current state + switch checkboxState { + case .off, .mixed: + toggleAllBuiltInTools(selected: true, builtInTools: builtInTools) + case .on: + toggleAllBuiltInTools(selected: false, builtInTools: builtInTools) + } + } + ) + + let selectedCount = builtInTools.filter { tool in + if let state = selectedToolStates["builtin"]?[tool.name] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + }.count + (Text("Built-In ") + .font(.system(size: 13, weight: .medium)) + + Text("(\(selectedCount) of \(builtInTools.count) Selected)") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary)) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + + Spacer() + } + } + .padding(.vertical, 4) + } + .onAppear { + updateBuiltInSelectionState(builtInTools: builtInTools) + } + .onChange(of: selectedToolStates) { _ in + updateBuiltInSelectionState(builtInTools: builtInTools) + } + .onChange(of: searchText) { _ in + if hasMatchingTools(builtInTools: builtInTools) && !isExpanded { + isExpanded = true + } + } + } + } + } + + private func toggleAllBuiltInTools(selected: Bool, builtInTools: [LanguageModelTool]) { + if selectedToolStates["builtin"] == nil { + selectedToolStates["builtin"] = [:] + } + for tool in builtInTools { + selectedToolStates["builtin"]?[tool.name] = selected + } + updateBuiltInSelectionState(builtInTools: builtInTools) + } + + private func isBuiltInToolSelected(_ tool: LanguageModelTool) -> Bool { + if let state = selectedToolStates["builtin"]?[tool.name] { + return state + } + return AgentModeToolHelpers.isToolEnabledInMode( + configurationKey: tool.name, + currentStatus: tool.status, + selectedMode: currentMode + ) + } + + private func updateBuiltInSelectionState(builtInTools: [LanguageModelTool]) { + guard !builtInTools.isEmpty else { + checkboxState = .off + return + } + + let selectedCount = builtInTools.filter { isBuiltInToolSelected($0) }.count + checkboxState = selectedCount == 0 ? .off : (selectedCount == builtInTools.count ? .on : .mixed) + } + } + + // MARK: - Agent Tool Row + + private struct AgentToolRow: View { + let toolName: String + let toolDescription: String? + let isSelected: Bool + let isBlocked: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack(alignment: .center) { + Toggle(isOn: Binding( + get: { isSelected }, + set: { onToggle($0) } + )) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(toolName) + .font(.system(size: 12, weight: .medium)) + + if let description = toolDescription { + Text(description) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .help(description) + .lineLimit(1) + } + } + } + } + .toggleStyle(.checkbox) + .disabled(isBlocked) + } + .padding(.vertical, 4) + } + } + + // MARK: - Agent Model Picker Section + + private struct AgentModelPickerSection: View { + @Binding var selectedModel: SelectedAgentModel? + @State private var copilotModels: [LLMModel] = [] + @State private var byokModels: [LLMModel] = [] + @State private var modelCache: [String: String] = [:] + + // Target width for menu items (popover width minus padding and margins) + // Popover is 500pt wide, subtract horizontal padding (12pt * 2) and menu item padding (8pt * 2) + let targetMenuItemWidth: CGFloat = 460 + let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Menu { + // None option + Button(action: { + selectedModel = nil + }) { + Text(createModelMenuItemAttributedString( + modelName: "Not Specified", + isSelected: selectedModel == nil, + multiplierText: "" + )) + } + + Divider() + + if let model = copilotModels.first(where: { $0.isAutoModel }) { + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "Variable" + )) + } + + Divider() + } + + // Copilot models section + if !copilotModels.isEmpty { + Section(header: Text("Copilot Models")) { + ForEach(copilotModels.filter { !$0.isAutoModel }, id: \.modelName) { model in + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "" + )) + } + } + } + } + + // BYOK models section + if !byokModels.isEmpty { + Divider() + Section(header: Text("BYOK Models")) { + ForEach(byokModels, id: \.modelName) { model in + Button(action: { selectModel(model) }) { + Text(createModelMenuItemAttributedString( + modelName: model.displayName ?? model.modelName, + isSelected: isModelSelected(model), + multiplierText: modelCache[model.modelName] ?? "" + )) + } + } + } + } + } label: { + HStack { + Text(selectedModelDisplayText()) + .font(.system(size: 12)) + .foregroundColor(selectedModel == nil ? .secondary : .primary) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.05)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .onAppear { + loadModels() + } + } + } + + private func selectModel(_ model: LLMModel) { + selectedModel = SelectedAgentModel( + displayName: model.displayName ?? model.modelName, + modelName: model.modelName, + source: model.providerName == nil ? .copilot : .byok(provider: model.providerName!) + ) + } + + private func isModelSelected(_ model: LLMModel) -> Bool { + guard let selected = selectedModel else { return false } + if selected.modelName != model.modelName { return false } + + switch selected.source { + case .copilot: + return model.providerName == nil + case let .byok(provider): + return model.providerName?.lowercased() == provider.lowercased() + } + } + + private func loadModels() { + copilotModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel) + byokModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel) + + var newCache: [String: String] = [:] + let allModels = copilotModels + byokModels + for model in allModels { + newCache[model.modelName] = ModelMenuItemFormatter.getMultiplierText(for: model) + } + modelCache = newCache + } + + private func selectedModelDisplayText() -> String { + guard let model = selectedModel else { + return "Select a model..." + } + + let sourceLabel: String + switch model.source { + case .copilot: + sourceLabel = "copilot" + case let .byok(provider): + sourceLabel = provider + } + + return "\(model.displayName) (\(sourceLabel))" + } + + private func createModelMenuItemAttributedString( + modelName: String, + isSelected: Bool, + multiplierText: String + ) -> AttributedString { + return ModelMenuItemFormatter.createModelMenuItemAttributedString( + modelName: modelName, + isSelected: isSelected, + multiplierText: multiplierText, + targetWidth: targetMenuItemWidth, + ) + } + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift index 1c956781..bb3747c6 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift @@ -19,18 +19,20 @@ struct ChatHistoryView: View { VStack(alignment: .center, spacing: 0) { Header(isChatHistoryVisible: $isChatHistoryVisible) - .frame(height: 32) - .padding(.leading, 16) - .padding(.trailing, 12) + .scaledFrame(height: 32) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) Divider() ChatHistorySearchBarView(searchText: $searchText) - .padding(.horizontal, 16) - .padding(.vertical, 4) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) + .scaledPadding(.vertical, 8) ItemView(store: store, searchText: $searchText, isChatHistoryVisible: $isChatHistoryVisible) - .padding(.horizontal, 16) + .scaledPadding(.leading, 12) + .scaledPadding(.trailing, 8) } } } @@ -43,7 +45,8 @@ struct ChatHistoryView: View { HStack { Text("Chat History") .scaledFont(size: 13, weight: .bold) - .lineLimit(nil) + .scaledPadding(.leading, 4) + .scaledFrame(maxWidth: 192, alignment: .leading) Spacer() @@ -80,7 +83,7 @@ struct ChatHistoryView: View { refreshStoredChatTabInfos() } .id(previewInfo.id) - .frame(height: 61) + .scaledFrame(height: 61) } } } @@ -180,6 +183,7 @@ struct ChatHistoryItemView: View { if isTabSelected() { Text("Current") + .scaledFont(.footnote) .foregroundStyle(.secondary) } @@ -219,6 +223,7 @@ struct ChatHistoryItemView: View { .padding(.horizontal, 12) } .frame(maxHeight: .infinity) + .contentShape(Rectangle()) .onHover(perform: { isHovered = $0 }) diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift index 5ca14718..0a70e8ba 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift @@ -55,7 +55,7 @@ struct ChatLoginView: View { } } } - .padding(.top, 16) + .scaledPadding(.top, 16) Spacer() Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).") @@ -67,7 +67,7 @@ struct ChatLoginView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .alert( viewModel.signInResponse?.userCode ?? "", @@ -77,7 +77,7 @@ struct ChatLoginView: View { Button("Cancel", role: .cancel, action: {}) .scaledFont(.body) - Button("Copy Code and Open", action: viewModel.copyAndOpen) + Button("Copy Code and Open", action: { viewModel.copyAndOpen(fromHostApp: false) }) .scaledFont(.body) } message: { response in Text(""" diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift index 4a82fb61..f6674e4a 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift @@ -14,7 +14,7 @@ struct ChatNoAXPermissionView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 64.0, height: 64.0) + .scaledFrame(width: 64.0, height: 64.0) .foregroundColor(.primary) Text("Accessibility Permission Required") @@ -43,7 +43,7 @@ struct ChatNoAXPermissionView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift index a453d633..1ecbfc90 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift @@ -16,7 +16,7 @@ struct ChatNoSubscriptionView: View { .resizable() .renderingMode(.template) .scaledToFill() - .frame(width: 60.0, height: 60.0) + .scaledFrame(width: 60.0, height: 60.0) .foregroundColor(.primary) Text("No Copilot Subscription Found") @@ -57,7 +57,7 @@ struct ChatNoSubscriptionView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift index 172a477c..9e342bca 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift @@ -35,7 +35,7 @@ struct ChatNoWorkspaceView: View { maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index a7e05e88..665ccd70 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -59,24 +59,23 @@ struct ChatView: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(Material.bar).frame(height: 28) + Rectangle() + .fill(Color.chatWindowBackgroundColor) + .scaledFrame(height: 28) - Divider() - - ZStack { - VStack(spacing: 0) { - ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) - .frame(height: 32) - .background(.ultraThinMaterial) - - Divider() - - ChatTabContainer(store: store) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + VStack(spacing: 0) { + ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible) + .scaledFrame(height: 32) + .scaledPadding(.leading, 16) + .scaledPadding(.trailing, 8) + + Divider() + + ChatTabContainer(store: store) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) } } @@ -89,21 +88,21 @@ struct ChatHistoryViewWrapper: View { var body: some View { WithPerceptionTracking { VStack(spacing: 0) { - Rectangle().fill(Material.bar).frame(height: 28) - - Divider() + Rectangle() + .fill(Color.chatWindowBackgroundColor) + .scaledFrame(height: 28) ChatHistoryView( store: store, isChatHistoryVisible: $isChatHistoryVisible ) - .background(.ultraThinMaterial) + .background(Color.chatWindowBackgroundColor) .frame( maxWidth: .infinity, maxHeight: .infinity ) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .preferredColorScheme(store.colorScheme) .focusable() @@ -133,7 +132,7 @@ struct ChatLoadingView: View { Spacer() } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.ultraThinMaterial) @@ -252,7 +251,7 @@ struct ChatBar: View { var body: some View { WithPerceptionTracking { - HStack(spacing: 0) { + HStack(spacing: 8) { if store.chatHistory.selectedWorkspaceName != nil { ChatWindowHeader(store: store) } @@ -265,7 +264,6 @@ struct ChatBar: View { SettingsButton(store: store) } - .padding(.horizontal, 12) } } @@ -326,9 +324,9 @@ struct ChatBar: View { Text(store.chatHistory.selectedWorkspaceName!) .scaledFont(size: 13, weight: .bold) - .padding(.leading, 4) + .scaledPadding(.leading, 4) .truncationMode(.tail) - .frame(maxWidth: 192, alignment: .leading) + .scaledFrame(maxWidth: 192, alignment: .leading) .help(store.chatHistory.selectedWorkspacePath!) } } @@ -347,7 +345,6 @@ struct ChatBar: View { .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) - .padding(.horizontal, 4) .help("New Chat") .accessibilityLabel("New Chat") } @@ -372,7 +369,6 @@ struct ChatBar: View { } } .buttonStyle(HoverButtonStyle()) - .padding(.horizontal, 4) .help("Show Chats...") .accessibilityLabel("Show Chats...") } @@ -391,7 +387,6 @@ struct ChatBar: View { .scaledFont(.body) } .buttonStyle(HoverButtonStyle()) - .padding(.horizontal, 4) .help("Open Settings") .accessibilityLabel("Open Settings") } diff --git a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift index 1a64a7dc..f7359baa 100644 --- a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift +++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift @@ -53,7 +53,7 @@ struct CodeReviewPanelView: View { .padding(.horizontal, 20) .frame(maxWidth: .infinity, maxHeight: Style.codeReviewPanelHeight, alignment: .top) .fixedSize(horizontal: false, vertical: true) - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .onAppear { viewStore.send(.appear) } Spacer() @@ -233,9 +233,8 @@ private struct CommentDetailView: View { } var fileNameView: some View { - HStack(spacing: 8) { + HStack(alignment: .center, spacing: 8) { drawFileIcon(fileURL) - .resizable() .scaledToFit() .frame(width: 16, height: 16) diff --git a/Core/Sources/SuggestionWidget/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift index 06b55832..b59276f7 100644 --- a/Core/Sources/SuggestionWidget/Extensions/Helper.swift +++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift @@ -3,8 +3,16 @@ import AppKit struct LocationStrategyHelper { /// `lineNumber` is 0-based - static func getLineFrame(_ lineNumber: Int, in editor: AXUIElement, with lines: [String]) -> CGRect? { - guard editor.isSourceEditor, + /// + /// - Parameters: + /// - length: If specified, use this length instead of the actual line length. Useful when you want to get the exact line height and y that ignores the unwrappded lines. + static func getLineFrame( + _ lineNumber: Int, + in editor: AXUIElement, + with lines: [String], + length: Int? = nil + ) -> CGRect? { + guard editor.isNonNavigatorSourceEditor, lineNumber < lines.count && lineNumber >= 0 else { return nil @@ -16,7 +24,15 @@ struct LocationStrategyHelper { characterPosition += lines[i].count + 1 } - var range = CFRange(location: characterPosition, length: lines[lineNumber].count) + let rangeLength: Int = { + if let length { + return min(length, lines[lineNumber].count) + } else { + return lines[lineNumber].count + } + }() + + var range = CFRange(location: characterPosition, length: rangeLength) guard let rangeValue = AXValueCreate(AXValueType.cfRange, &range) else { return nil } diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift new file mode 100644 index 00000000..6493f842 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift @@ -0,0 +1,40 @@ +import AppKit +import XcodeInspector +import Preferences +import ChatService +import ConversationServiceProvider + +extension WidgetWindowsController { + @MainActor + func hideAgentConfigurationWidgetWindow() { + windows.agentConfigurationWidgetWindow.alphaValue = 0 + windows.agentConfigurationWidgetWindow.setIsVisible(false) + } + + @MainActor + func displayAgentConfigurationWidgetWindow() { + windows.agentConfigurationWidgetWindow.setIsVisible(true) + windows.agentConfigurationWidgetWindow.alphaValue = 1 + windows.agentConfigurationWidgetWindow.orderFrontRegardless() + } + + @MainActor + func applyOpacityForAgentConfigurationWidget(by noFocus: Bool? = nil) { + let state = store.withState { $0.panelState.agentConfigurationWidgetState } + guard let noFocus = noFocus, + !noFocus, + let focusedEditor = state.focusedEditor + else { + hideAgentConfigurationWidgetWindow() + return + } + + let currentMode = state.currentMode + + if currentMode != nil { + displayAgentConfigurationWidgetWindow() + } else { + hideAgentConfigurationWidgetWindow() + } + } +} diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FeatureFlags.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FeatureFlags.swift new file mode 100644 index 00000000..bb456805 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FeatureFlags.swift @@ -0,0 +1,27 @@ +import GitHubCopilotService + +extension WidgetWindowsController { + + @MainActor + var isNESFeatureFlagEnabled: Bool { + FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures + } + + func setupFeatureFlagObservers() { + Task { @MainActor in + let sinker = FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { [weak self] _ in + self?.onFeatureFlagChanged() + }) + + await self.storeCancellables([sinker]) + } + } + + @MainActor + func onFeatureFlagChanged() { + if !isNESFeatureFlagEnabled { + hideAllNESWindows() + } + } +} diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+NES.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+NES.swift new file mode 100644 index 00000000..984fe3c4 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+NES.swift @@ -0,0 +1,122 @@ +import AppKit +import GitHubCopilotService + +extension WidgetWindowsController { + func setupNESSuggestionPanelObservers() { + Task { @MainActor in + let nesContentPublisher = store.publisher + .map(\.panelState.nesSuggestionPanelState.nesContent) + .removeDuplicates() + .sink { [weak self] _ in + Task { [weak self] in + await self?.updateWindowLocation(animated: false, immediately: true) + } + } + + await self.storeCancellables([nesContentPublisher]) + } + } + + @MainActor + func applyOpacityForNESWindows(by noFocus: Bool) { + guard !noFocus, isNESFeatureFlagEnabled + else { + hideAllNESWindows() + return + } + + displayAllNESWindows() + } + + @MainActor + func hideAllNESWindows() { + windows.nesMenuWindow.alphaValue = 0 + windows.nesDiffWindow.setIsVisible(false) + + hideNESDiffWindow() + + windows.nesNotificationWindow.alphaValue = 0 + windows.nesNotificationWindow.setIsVisible(false) + } + + @MainActor + func displayAllNESWindows() { + windows.nesMenuWindow.alphaValue = 1 + windows.nesDiffWindow.setIsVisible(true) + + windows.nesDiffWindow.alphaValue = 1 + windows.nesDiffWindow.setIsVisible(true) + + windows.nesNotificationWindow.alphaValue = 1 + windows.nesNotificationWindow.setIsVisible(true) + } + + @MainActor + func hideNESDiffWindow() { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + windows.nesDiffWindow.animator().alphaValue = 0 + windows.nesDiffWindow.setIsVisible(false) + } + } + + @MainActor + func updateNESDiffWindowFrame( + _ location: WidgetLocation.NESPanelLocation, + animated: Bool, + trigger: WidgetLocation.LocationTrigger + ) async { + windows.nesDiffWindow.layoutIfNeeded() + guard let contentView = windows.nesDiffWindow.contentView + else { + return + } + + let effectiveSize: NSSize? = { + let fittingSize = contentView.fittingSize + if fittingSize.width > 0 && fittingSize.height > 0 { + return fittingSize + } + + let intrinsicSize = contentView.intrinsicContentSize + if intrinsicSize.width > 0 && intrinsicSize.height > 0 { + return intrinsicSize + } + + return nil + }() + + guard let contentSize = effectiveSize, + contentSize.width.isFinite, + contentSize.height.isFinite, + let frame = location.calcDiffViewFrame(contentSize: contentSize) + else { + return + } + + windows.nesDiffWindow.setFrame( + frame, + display: false, + animate: animated + ) + } + + @MainActor + func updateNESNotificationWindowFrame( + _ location: WidgetLocation.NESPanelLocation, + animated: Bool + ) async { + var notificationWindowFrame = windows.nesNotificationWindow.frame + let scrollViewFrame = location.scrollViewFrame + let screenFrame = location.screenFrame + + notificationWindowFrame.origin.x = scrollViewFrame.minX + scrollViewFrame.width / 2 - notificationWindowFrame.width / 2 + notificationWindowFrame.origin.y = screenFrame.height - scrollViewFrame.maxY + Style.nesSuggestionMenuLeadingPadding * 2 + + windows.nesNotificationWindow.setFrame( + notificationWindowFrame, + display: false, + animate: animated + ) + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift new file mode 100644 index 00000000..ef5ac74d --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift @@ -0,0 +1,65 @@ +import ComposableArchitecture +import Foundation +import SuggestionBasic +import XcodeInspector +import ChatTab +import ConversationTab +import ChatService +import ConversationServiceProvider + +@Reducer +public struct AgentConfigurationWidgetFeature { + @ObservableState + public struct State: Equatable { + public var focusedEditor: SourceEditor? = nil + public var isPanelDisplayed: Bool = false + public var currentMode: ConversationMode? = nil + + public var lineHeight: Double = 16.0 + } + + public enum Action: Equatable { + case setCurrentMode(ConversationMode?) + case onFocusedEditorChanged(SourceEditor?) + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onFocusedEditorChanged(let editor): + state.focusedEditor = editor + return .run { send in + let currentMode = await getCurrentMode(for: editor) + await send(.setCurrentMode(currentMode)) + } + case .setCurrentMode(let mode): + state.currentMode = mode + return .none + } + } + } +} + +private func getCurrentMode(for focusedEditor: SourceEditor?) async -> ConversationMode? { + guard let documentURL = focusedEditor?.realtimeDocumentURL, + documentURL.pathExtension == "md", + documentURL.lastPathComponent.hasSuffix(".agent.md") else { + return nil + } + + // Load all conversation modes + guard let modes = await SharedChatService.shared.loadConversationModes() else { + return nil + } + + // Find the mode that matches the current document URL + let documentURLString = documentURL.absoluteString + let mode = modes.first { mode in + guard let modeURI = mode.uri else { return false } + return modeURI == documentURLString || URL(string: modeURI)?.path == documentURL.path + } + + return mode +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index e6f274b7..c28f6a66 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -1,12 +1,13 @@ import ActiveApplicationMonitor import AppKit +import ChatService import ChatTab import ComposableArchitecture -import GitHubCopilotService -import SwiftUI -import PersistMiddleware import ConversationTab +import GitHubCopilotService import HostAppActivator +import PersistMiddleware +import SwiftUI public enum ChatTabBuilderCollection: Equatable { case folder(title: String, kinds: [ChatTabKind]) @@ -29,7 +30,7 @@ public struct ChatTabKind: Equatable { public struct WorkspaceIdentifier: Hashable, Codable { public let path: String public let username: String - + public init(path: String, username: String) { self.path = path self.username = username @@ -66,7 +67,7 @@ public struct ChatHistory: Equatable { workspaces[index] = workspace } } - + mutating func addWorkspace(_ workspace: ChatWorkspace) { guard !workspaces.contains(where: { $0.id == workspace.id }) else { return } workspaces[id: workspace.id] = workspace @@ -84,10 +85,10 @@ public struct ChatWorkspace: Identifiable, Equatable { guard let tabId = selectedTabId else { return tabInfo.first } return tabInfo[id: tabId] } - - public var workspacePath: String { get { id.path} } - public var username: String { get { id.username } } - + + public var workspacePath: String { id.path } + public var username: String { id.username } + private var onTabInfoDeleted: (String) -> Void public init( @@ -103,29 +104,29 @@ public struct ChatWorkspace: Identifiable, Equatable { self.selectedTabId = selectedTabId self.onTabInfoDeleted = onTabInfoDeleted } - + /// Walkaround `Equatable` error for `onTabInfoDeleted` public static func == (lhs: ChatWorkspace, rhs: ChatWorkspace) -> Bool { lhs.id == rhs.id && - lhs.tabInfo == rhs.tabInfo && - lhs.tabCollection == rhs.tabCollection && - lhs.selectedTabId == rhs.selectedTabId + lhs.tabInfo == rhs.tabInfo && + lhs.tabCollection == rhs.tabCollection && + lhs.selectedTabId == rhs.selectedTabId } - + public mutating func applyLRULimit(maxSize: Int = 5) { guard tabInfo.count > maxSize else { return } - + // Tabs not selected let nonSelectedTabs = Array(tabInfo.filter { $0.id != selectedTabId }) let sortedByUpdatedAt = nonSelectedTabs.sorted { $0.updatedAt < $1.updatedAt } - + let tabsToRemove = Array(sortedByUpdatedAt.prefix(tabInfo.count - maxSize)) - + // Remove Tabs for tab in tabsToRemove { // destroy tab onTabInfoDeleted(tab.id) - + // remove from workspace tabInfo.remove(id: tab.id) } @@ -175,19 +176,19 @@ public struct ChatPanelFeature { // case switchToPreviousTab // case moveChatTab(from: Int, to: Int) case focusActiveChatTab - + // Chat History case chatHistoryItemClicked(id: String) case chatHistoryDeleteButtonClicked(id: String) case chatTab(id: String, action: ChatTabItem.Action) - + // persist case saveChatTabInfo([ChatTabInfo?], ChatWorkspace) case deleteChatTabInfo(id: String, ChatWorkspace) case restoreWorkspace(ChatWorkspace) - + case syncChatTabInfo([ChatTabInfo?]) - + // ChatWorkspace cleanup case scheduleLRUCleanup(ChatWorkspace) case performLRUCleanup(ChatWorkspace) @@ -207,7 +208,8 @@ public struct ChatPanelFeature { } public var body: some ReducerOf { - Reduce { state, action in + Reduce { + state, action in switch action { case .hideButtonClicked: state.isPanelDisplayed = false @@ -234,12 +236,13 @@ public struct ChatPanelFeature { return .none case .toggleChatPanelDetachedButtonClicked: - if state.isFullScreen, state.isDetached { + if state.isFullScreen, + state.isDetached { return .run { send in await send(.attachChatPanel) } } - + state.isDetached.toggle() return .none @@ -251,7 +254,7 @@ public struct ChatPanelFeature { if state.isFullScreen { return .run { send in await MainActor.run { toggleFullScreen() } - try await Task.sleep(nanoseconds: 1_000_000_000) + try await Task.sleep(nanoseconds: 1000000000) await send(.attachChatPanel) } } @@ -336,18 +339,23 @@ public struct ChatPanelFeature { } state.chatHistory.updateHistory(currentChatWorkspace) return .none - + case let .chatHistoryDeleteButtonClicked(id): // the current chat should not be deleted - guard var currentChatWorkspace = state.currentChatWorkspace, id != currentChatWorkspace.selectedTabId else { + guard var currentChatWorkspace = state.currentChatWorkspace, + id != currentChatWorkspace.selectedTabId else { return .none } + let CLSConversationID = currentChatWorkspace.tabInfo.first { + $0.id == id + }?.CLSConversationID currentChatWorkspace.tabInfo.removeAll { $0.id == id } state.chatHistory.updateHistory(currentChatWorkspace) - + let chatWorkspace = currentChatWorkspace return .run { send in await send(.deleteChatTabInfo(id: id, chatWorkspace)) + await ToolAutoApprovalManager.shared.clearConversationData(conversationId: CLSConversationID) } // case .createNewTapButtonHovered: @@ -356,11 +364,11 @@ public struct ChatPanelFeature { case .createNewTapButtonClicked: return .none // handled in GUI Reducer - - case .restoreTabByInfo(_): + + case .restoreTabByInfo: return .none // handled in GUI Reducer - - case .createNewTabByID(_): + + case .createNewTabByID: return .none // handled in GUI Reducer case let .tabClicked(id): @@ -369,37 +377,37 @@ public struct ChatPanelFeature { // chatTabGroup.selectedTabId = nil return .none } - + let (originalTab, currentTab) = currentChatWorkspace.switchTab(to: &chatTabInfo) state.chatHistory.updateHistory(currentChatWorkspace) - + let workspace = currentChatWorkspace return .run { send in await send(.focusActiveChatTab) await send(.saveChatTabInfo([originalTab, currentTab], workspace)) await send(.syncChatTabInfo([originalTab, currentTab])) } - + case let .chatHistoryItemClicked(id): guard var chatWorkspace = state.currentChatWorkspace, // No Need to swicth selected Tab when already selected id != chatWorkspace.selectedTabId else { return .none } - + // Try to find the tab in three places: // 1. In current workspace's open tabs let existingTab = chatWorkspace.tabInfo.first(where: { $0.id == id }) - + // 2. In persistent storage let storedTab = existingTab == nil ? ChatTabInfoStore.getByID(id, with: .init(workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username)) : nil - + if var tabInfo = existingTab ?? storedTab { // Tab found in workspace or storage - switch to it let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tabInfo) state.chatHistory.updateHistory(chatWorkspace) - + let workspace = chatWorkspace let info = tabInfo return .run { send in @@ -407,20 +415,20 @@ public struct ChatPanelFeature { if storedTab != nil { await send(.restoreTabByInfo(info: info)) } - + // as converstaion tab is lazy restore // should restore tab when switching if let chatTab = chatTabPool.getTab(of: id), let conversationTab = chatTab as? ConversationTab { await conversationTab.restoreIfNeeded() } - + await send(.saveChatTabInfo([originalTab, currentTab], workspace)) - + await send(.syncChatTabInfo([originalTab, currentTab])) } } - + // 3. Tab not found - create a new one return .run { send in await send(.createNewTabByID(id: id)) @@ -428,13 +436,13 @@ public struct ChatPanelFeature { case var .appendAndSelectTab(tab): guard var chatWorkspace = state.currentChatWorkspace, - !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) + !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) else { return .none } - + chatWorkspace.tabInfo.append(tab) let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tab) state.chatHistory.updateHistory(chatWorkspace) - + let currentChatWorkspace = chatWorkspace return .run { send in await send(.focusActiveChatTab) @@ -449,7 +457,7 @@ public struct ChatPanelFeature { targetWorkspace.tabInfo.append(tab) let (originalTab, currentTab) = targetWorkspace.switchTab(to: &tab) state.chatHistory.updateHistory(targetWorkspace) - + let currentChatWorkspace = targetWorkspace return .run { send in await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) @@ -514,92 +522,92 @@ public struct ChatPanelFeature { // } // MARK: - ChatTabItem action - + case let .chatTab(id, .tabContentUpdated): guard var currentChatWorkspace = state.currentChatWorkspace, var info = state.currentChatWorkspace?.tabInfo[id: id] else { return .none } - + info.updatedAt = .now currentChatWorkspace.tabInfo[id: id] = info state.chatHistory.updateHistory(currentChatWorkspace) - + let chatTabInfo = info let chatWorkspace = currentChatWorkspace return .run { send in await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) } - + case let .chatTab(id, .setCLSConversationID(CID)): guard var currentChatWorkspace = state.currentChatWorkspace, var info = state.currentChatWorkspace?.tabInfo[id: id] else { return .none } - + info.CLSConversationID = CID currentChatWorkspace.tabInfo[id: id] = info state.chatHistory.updateHistory(currentChatWorkspace) - + let chatTabInfo = info let chatWorkspace = currentChatWorkspace return .run { send in await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) } - + case let .chatTab(id, .updateTitle(title)): guard var currentChatWorkspace = state.currentChatWorkspace, var info = state.currentChatWorkspace?.tabInfo[id: id], !info.isTitleSet else { return .none } - + info.title = title info.updatedAt = .now currentChatWorkspace.tabInfo[id: id] = info state.chatHistory.updateHistory(currentChatWorkspace) - + let chatTabInfo = info let chatWorkspace = currentChatWorkspace return .run { send in await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) } - + case .chatTab: return .none - + // MARK: - Persist + case let .saveChatTabInfo(chatTabInfos, chatWorkspace): let toSaveInfo = chatTabInfos.compactMap { $0 } guard toSaveInfo.count > 0 else { return .none } let workspacePath = chatWorkspace.workspacePath let username = chatWorkspace.username - + return .run { _ in Task(priority: .background) { ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username)) } } - + case let .deleteChatTabInfo(id, chatWorkspace): let workspacePath = chatWorkspace.workspacePath let username = chatWorkspace.username - + ChatTabInfoStore.delete(by: id, with: .init(workspacePath: workspacePath, username: username)) return .none case var .restoreWorkspace(chatWorkspace): // chat opened before finishing restoration if var existChatWorkspace = state.chatHistory.workspaces[id: chatWorkspace.id] { - if var selectedChatTabInfo = chatWorkspace.tabInfo.first(where: { $0.id == chatWorkspace.selectedTabId }) { // Keep the selection state when restoring selectedChatTabInfo.isSelected = true chatWorkspace.tabInfo[id: selectedChatTabInfo.id] = selectedChatTabInfo - + // Update the existing workspace's selected tab to match existChatWorkspace.selectedTabId = selectedChatTabInfo.id - + // merge tab info existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo) state.chatHistory.updateHistory(existChatWorkspace) - + let chatTabInfo = selectedChatTabInfo let workspace = existChatWorkspace return .run { send in @@ -608,21 +616,21 @@ public struct ChatPanelFeature { await send(.scheduleLRUCleanup(workspace)) } } - + // merge tab info existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo) state.chatHistory.updateHistory(existChatWorkspace) - + let workspace = existChatWorkspace return .run { send in await send(.scheduleLRUCleanup(workspace)) } } - + state.chatHistory.addWorkspace(chatWorkspace) return .none - - case .syncChatTabInfo(let tabInfos): + + case let .syncChatTabInfo(tabInfos): for tabInfo in tabInfos { guard let tabInfo = tabInfo else { continue } if let conversationTab = chatTabPool.getTab(of: tabInfo.id) as? ConversationTab { @@ -630,14 +638,15 @@ public struct ChatPanelFeature { } } return .none - + // MARK: - Clean up ChatWorkspace - case .scheduleLRUCleanup(let chatWorkspace): + + case let .scheduleLRUCleanup(chatWorkspace): return .run { send in await send(.performLRUCleanup(chatWorkspace)) }.cancellable(id: "lru-cleanup-\(chatWorkspace.id)", cancelInFlight: true) // apply built-in race condition prevention - - case .performLRUCleanup(var chatWorkspace): + + case var .performLRUCleanup(chatWorkspace): chatWorkspace.applyLRULimit() state.chatHistory.updateHistory(chatWorkspace) return .none @@ -650,7 +659,6 @@ public struct ChatPanelFeature { } extension ChatPanelFeature { - func restoreConversationTabIfNeeded(_ id: String) async { if let chatTab = chatTabPool.getTab(of: id), let conversationTab = chatTab as? ConversationTab { @@ -661,30 +669,30 @@ extension ChatPanelFeature { extension ChatWorkspace { public mutating func switchTab(to chatTabInfo: inout ChatTabInfo) -> (originalTab: ChatTabInfo?, currentTab: ChatTabInfo) { - guard self.selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) } - + guard selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) } + // get original selected tab info to update its isSelected - var originalTabInfo: ChatTabInfo? = nil - if self.selectedTabId != nil { - originalTabInfo = self.tabInfo[id: self.selectedTabId!] + var originalTabInfo: ChatTabInfo? + if selectedTabId != nil { + originalTabInfo = tabInfo[id: selectedTabId!] } // fresh selected info in chatWorksapce and tabInfo - self.selectedTabId = chatTabInfo.id + selectedTabId = chatTabInfo.id originalTabInfo?.isSelected = false chatTabInfo.isSelected = true - + // update tab back to chatWorkspace - let isNewTab = self.tabInfo[id: chatTabInfo.id] == nil - self.tabInfo[id: chatTabInfo.id] = chatTabInfo + let isNewTab = tabInfo[id: chatTabInfo.id] == nil + tabInfo[id: chatTabInfo.id] = chatTabInfo if isNewTab { applyLRULimit() } - + if let originalTabInfo { - self.tabInfo[id: originalTabInfo.id] = originalTabInfo + tabInfo[id: originalTabInfo.id] = originalTabInfo } - + return (originalTabInfo, chatTabInfo) } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift new file mode 100644 index 00000000..1bb7dc47 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift @@ -0,0 +1,62 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +@Reducer +public struct NESSuggestionPanelFeature { + @ObservableState + public struct State: Equatable { + static let baseFontSize: CGFloat = 13 + static let defaultLineHeight: Double = 18 + + var nesContent: NESCodeSuggestionProvider? { + didSet { closeNotificationByUser = false } + } + var colorScheme: ColorScheme = .light + var firstLineIndent: Double = 0 + var lineHeight: Double = Self.defaultLineHeight + var lineFontSize: Double { + Self.baseFontSize * fontSizeScale + } + var isPanelDisplayed: Bool = false + public var isPanelOutOfFrame: Bool = false + var closeNotificationByUser: Bool = false + // TODO: handle warnings + // var warningMessage: String? + // var warningURL: String? + var opacity: Double { + guard isPanelDisplayed else { return 0 } + if isPanelOutOfFrame { return 0 } + guard nesContent != nil else { return 0 } + return 1 + } + var menuViewOpacity: Double { + guard nesContent != nil else { return 0 } + guard isPanelDisplayed else { return 0 } + return isPanelOutOfFrame ? 0 : 1 + } + var diffViewOpacity: Double { menuViewOpacity } + var notificationViewOpacity: Double { + guard nesContent != nil else { return 0 } + guard isPanelDisplayed else { return 0 } + return isPanelOutOfFrame ? 1 : 0 + } + var fontSizeScale: Double { + (lineHeight / Self.defaultLineHeight * 100).rounded() / 100 + } + } + + public enum Action: Equatable { + case onUserCloseNotification + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onUserCloseNotification: + state.closeNotificationByUser = true + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index e76afbc0..525affb4 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -4,6 +4,10 @@ import Foundation @Reducer public struct PanelFeature { + public enum PanelType { + case suggestion, nes, agentConfiguration + } + @ObservableState public struct State: Equatable { public var content: SharedPanelFeature.Content { @@ -11,6 +15,13 @@ public struct PanelFeature { set { sharedPanelState.content = newValue suggestionPanelState.content = newValue.suggestion + } + } + + public var nesContent: NESCodeSuggestionProvider? { + get { nesSuggestionPanelState.nesContent } + set { + nesSuggestionPanelState.nesContent = newValue } } @@ -21,6 +32,14 @@ public struct PanelFeature { // MARK: SuggestionPanel var suggestionPanelState = SuggestionPanelFeature.State() + + // MARK: NESSuggestionPanel + + public var nesSuggestionPanelState = NESSuggestionPanelFeature.State() + + // MARK: SubAgent + + public var agentConfigurationWidgetState = AgentConfigurationWidgetFeature.State() var warningMessage: String? var warningURL: String? @@ -28,19 +47,26 @@ public struct PanelFeature { public enum Action: Equatable { case presentSuggestion + case presentNESSuggestion case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool) + case presentNESSuggestionProvider(NESCodeSuggestionProvider, displayContent: Bool) case presentError(String) case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) case displayPanelContent + case displayNESPanelContent case expandSuggestion case discardSuggestion + case discardNESSuggestion case removeDisplayedContent case switchToAnotherEditorAndUpdateContent - case hidePanel - case showPanel + case hidePanel(PanelType) + case showPanel(PanelType) + case onRealtimeNESToggleChanged(Bool) case sharedPanel(SharedPanelFeature.Action) case suggestionPanel(SuggestionPanelFeature.Action) + case nesSuggestionPanel(NESSuggestionPanelFeature.Action) + case agentConfigurationWidget(AgentConfigurationWidgetFeature.Action) case presentWarning(message: String, url: String?) case dismissWarning @@ -59,6 +85,14 @@ public struct PanelFeature { Scope(state: \.sharedPanelState, action: \.sharedPanel) { SharedPanelFeature() } + + Scope(state: \.nesSuggestionPanelState, action: \.nesSuggestionPanel) { + NESSuggestionPanelFeature() + } + + Scope(state: \.agentConfigurationWidgetState, action: \.agentConfigurationWidget) { + AgentConfigurationWidgetFeature() + } Reduce { state, action in switch action { @@ -69,6 +103,14 @@ public struct PanelFeature { else { return } await send(.presentSuggestionProvider(provider, displayContent: true)) } + + case .presentNESSuggestion: + return .run { send in + guard let fileURL = await xcodeInspector.safe.activeDocumentURL, + let provider = await fetchNESSuggestionProvider(fileURL: fileURL) + else { return } + await send(.presentNESSuggestionProvider(provider, displayContent: true)) + } case let .presentSuggestionProvider(provider, displayContent): state.content.suggestion = provider @@ -78,6 +120,15 @@ public struct PanelFeature { }.animation(.easeInOut(duration: 0.2)) } return .none + + case let .presentNESSuggestionProvider(provider, displayContent): + state.nesContent = provider + if displayContent { + return .run { send in + await send(.displayNESPanelContent) + }.animation(.easeInOut(duration: 0.2)) + } + return .none case let .presentError(errorDescription): state.content.error = errorDescription @@ -98,12 +149,22 @@ public struct PanelFeature { if state.suggestionPanelState.content != nil { state.suggestionPanelState.isPanelDisplayed = true } - + return .none + + case .displayNESPanelContent: + if state.nesSuggestionPanelState.nesContent != nil { + state.nesSuggestionPanelState.isPanelDisplayed = true + } return .none case .discardSuggestion: state.content.suggestion = nil return .none + + case .discardNESSuggestion: + state.nesContent = nil + return .none + case .expandSuggestion: state.content.isExpanded = true return .none @@ -118,15 +179,39 @@ public struct PanelFeature { ) )) } - case .hidePanel: - state.suggestionPanelState.isPanelDisplayed = false + case .hidePanel(let panelType): + switch panelType { + case .suggestion: + state.suggestionPanelState.isPanelDisplayed = false + case .nes: + state.nesSuggestionPanelState.isPanelDisplayed = false + case .agentConfiguration: + state.agentConfigurationWidgetState.isPanelDisplayed = false + } return .none - case .showPanel: - state.suggestionPanelState.isPanelDisplayed = true + case .showPanel(let panelType): + switch panelType { + case .suggestion: + state.suggestionPanelState.isPanelDisplayed = true + case .nes: + state.nesSuggestionPanelState.isPanelDisplayed = true + case .agentConfiguration: + state.agentConfigurationWidgetState.isPanelDisplayed = true + } return .none + case let .onRealtimeNESToggleChanged(isOn): + if !isOn { + return .run { send in + await send(.hidePanel(.nes)) + await send(.discardNESSuggestion) + } + } + return .none + case .removeDisplayedContent: state.content.error = nil state.content.suggestion = nil + state.nesContent = nil return .none case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), @@ -148,6 +233,12 @@ public struct PanelFeature { case .suggestionPanel: return .none + + case .nesSuggestionPanel: + return .none + + case .agentConfigurationWidget: + return .none case .presentWarning(let message, let url): state.warningMessage = message @@ -172,5 +263,12 @@ public struct PanelFeature { .suggestionForFile(at: fileURL) else { return nil } return provider } + + func fetchNESSuggestionProvider(fileURL: URL) async -> NESCodeSuggestionProvider? { + guard let provider = await suggestionWidgetControllerDependency + .suggestionWidgetDataSource? + .nesSuggestionForFile(at: fileURL) else { return nil } + return provider + } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 16d0041d..0bbda7e5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -111,6 +111,8 @@ public struct WidgetFeature { case updateColorScheme case updatePanelStateToMatch(WidgetLocation) + case updateNESSuggestionPanelStateToMatch(WidgetLocation) + case updateAgentConfigurationWidgetStateToMatch(WidgetLocation) case updateFocusingDocumentURL case setFocusingDocumentURL(to: URL?) case updateKeyWindow(WindowCanBecomeKey) @@ -391,6 +393,36 @@ public struct WidgetFeature { .alignPanelTop return .none + + case let .updateNESSuggestionPanelStateToMatch(widgetLocation): + + guard let nesSuggestionPanelLocation = widgetLocation.nesSuggestionPanelLocation else { + state.panelState.nesSuggestionPanelState.isPanelDisplayed = false + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false + return .none + } + + let lineFirstCharacterFrame = nesSuggestionPanelLocation.lineFirstCharacterFrame + let scrollViewFrame = nesSuggestionPanelLocation.scrollViewFrame + if scrollViewFrame.contains(lineFirstCharacterFrame) { + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false + } else { + state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = true + } + state.panelState.nesSuggestionPanelState.lineHeight = nesSuggestionPanelLocation.lineHeight + + return .none + + case let .updateAgentConfigurationWidgetStateToMatch(widgetLocation): + guard let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation else { + state.panelState.agentConfigurationWidgetState.isPanelDisplayed = false + return .none + } + + state.panelState.agentConfigurationWidgetState.isPanelDisplayed = true + state.panelState.agentConfigurationWidgetState.lineHeight = agentConfigurationWidgetLocation.lineHeight + + return .none case let .updateKeyWindow(window): return .run { _ in diff --git a/Core/Sources/SuggestionWidget/NES/NESDiffView.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift new file mode 100644 index 00000000..5d650596 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift @@ -0,0 +1,150 @@ +import SwiftUI +import ComposableArchitecture +import SuggestionBasic + +struct NESDiffView: View { + var store: StoreOf + + var body: some View { + WithPerceptionTracking { + if store.isPanelDisplayed, + !store.isPanelOutOfFrame, + let nesContent = store.nesContent, + let originalCodeSnippet = nesContent.getOriginalCodeSnippet() + { + let nesCode = nesContent.code + + ScrollView(showsIndicators: true) { + Group { + if nesContent.range.isOneLine && nesCode.components(separatedBy: .newlines).count <= 1 { + InlineDiffView( + store: store, + segments: DiffBuilder.inlineSegments( + oldLine: originalCodeSnippet, + newLine: nesCode + ) + ) + } else { + LineDiffView( + store: store, + segments: DiffBuilder.lineSegments( + oldContent: originalCodeSnippet, + newContent: nesCode + ) + ) + } + } + } + .padding(.leading, 12 * store.fontSizeScale) + .padding(.trailing, 10 * store.fontSizeScale) + .padding(.vertical, 4 * store.fontSizeScale) + .xcodeStyleFrame() + .opacity(store.diffViewOpacity) + } + } + } +} + + +private struct AccentStrip: View { + let store: StoreOf + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(.blue) + .frame(width: 4 * store.fontSizeScale) + } +} + +struct InlineDiffView: View { + let store: StoreOf + let segments: [DiffSegment] + + var body: some View { + HStack(spacing: 0) { + AccentStrip(store: store) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + buildSegmentView(segment) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + func buildSegmentView(_ segment: DiffSegment) -> some View { + Text(verbatim: segment.text.diffDisplayEscaped()) + .lineLimit(1) + .font(.system(size: store.lineFontSize, weight: .medium)) + .padding(.vertical, 4 * store.fontSizeScale) + .background( + Rectangle() + .fill(segment.backgroundColor) + ) + .alignmentGuide(.firstTextBaseline) { d in + d[.firstTextBaseline] + } + } +} + + +struct LineDiffView: View { + let store: StoreOf + let segments: [DiffSegment] + + var body: some View { + HStack(spacing: 0) { + AccentStrip(store: store) + + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + buildSegmentView(segment) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + @ViewBuilder + func buildSegmentView(_ segment: DiffSegment) -> some View { + Text(segment.text.diffDisplayEscaped()) + .font(.system(size: store.lineFontSize, weight: .medium)) + .multilineTextAlignment(.leading) + .padding(.vertical, 4 * store.fontSizeScale) + .background( + Rectangle() + .fill(segment.backgroundColor) + ) + } +} + + +extension DiffSegment { + var backgroundColor: Color { + switch change { + case .added: return Color("editor.focusedStackFrameHighlightBackground") + case .removed: return Color("editorOverviewRuler.inlineChatRemoved") + case .unchanged: return .clear + } + } +} + +private extension String { + func diffDisplayEscaped() -> String { + var escaped = "" + for scalar in unicodeScalars { + switch scalar { + case "\n": escaped.append("\\n") + case "\r": escaped.append("\\r") + case "\t": escaped.append("\\t") + default: escaped.append(Character(scalar)) + } + } + return escaped + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift new file mode 100644 index 00000000..54b9c6d6 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift @@ -0,0 +1,136 @@ +import Foundation + +struct DiffSegment { + enum Change { + case unchanged + case added + case removed + } + let text: String + let change: Change +} + +enum DiffBuilder { + static func inlineSegments(oldLine: String, newLine: String) -> [DiffSegment] { + let oldTokens = tokenizePreservingWhitespace(oldLine) + let newTokens = tokenizePreservingWhitespace(newLine) + let condensed = condensedSegments(oldTokens: oldTokens, newTokens: newTokens) + return mergeInlineWhitespaceSegments(condensed) + } + + static func lineSegments(oldContent: String, newContent: String) -> [DiffSegment] { + let oldLines = oldContent.components(separatedBy: .newlines) + let newLines = newContent.components(separatedBy: .newlines) + return diff(tokensInOld: oldLines, tokensInNew: newLines) + } + + private static func tokenizePreservingWhitespace(_ text: String) -> [String] { + guard !text.isEmpty else { return [] } + // This pattern matches either: + // - a sequence of non-whitespace characters (\\S+) followed by optional whitespace (\\s*), or + // - a sequence of whitespace characters (\\s+) + // This ensures that tokens preserve trailing whitespace, or capture standalone whitespace sequences. + let pattern = "\\S+\\s*|\\s+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [text] + } + let nsText = text as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let matches = regex.matches(in: text, range: fullRange) + if matches.isEmpty { + return [text] + } + return matches.map { nsText.substring(with: $0.range) } + } + + private static func condensedSegments(oldTokens: [String], newTokens: [String]) -> [DiffSegment] { + let raw = diff(tokensInOld: oldTokens, tokensInNew: newTokens) + guard var last = raw.first else { return [] } + var condensed: [DiffSegment] = [] + for segment in raw.dropFirst() { + if segment.change == last.change { + last = DiffSegment(text: last.text + segment.text, change: last.change) + } else { + condensed.append(last) + last = segment + } + } + condensed.append(last) + return condensed + } + + private static func diff(tokensInOld oldTokens: [String], tokensInNew newTokens: [String]) -> [DiffSegment] { + let m = oldTokens.count + let n = newTokens.count + if m == 0 { return newTokens.map { DiffSegment(text: $0, change: .added) } } + if n == 0 { return oldTokens.map { DiffSegment(text: $0, change: .removed) } } + var lcs = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) + for i in 1...m { + for j in 1...n { + if oldTokens[i - 1] == newTokens[j - 1] { + lcs[i][j] = lcs[i - 1][j - 1] + 1 + } else { + lcs[i][j] = max(lcs[i - 1][j], lcs[i][j - 1]) + } + } + } + var i = m + var j = n + var result: [DiffSegment] = [] + while i > 0 && j > 0 { + if oldTokens[i - 1] == newTokens[j - 1] { + result.append(DiffSegment(text: oldTokens[i - 1], change: .unchanged)) + i -= 1 + j -= 1 + } else if lcs[i - 1][j] > lcs[i][j - 1] { + result.append(DiffSegment(text: oldTokens[i - 1], change: .removed)) + i -= 1 + } else { + result.append(DiffSegment(text: newTokens[j - 1], change: .added)) + j -= 1 + } + } + while i > 0 { + result.append(DiffSegment(text: oldTokens[i - 1], change: .removed)) + i -= 1 + } + while j > 0 { + result.append(DiffSegment(text: newTokens[j - 1], change: .added)) + j -= 1 + } + return result.reversed() + } + + private static func mergeInlineWhitespaceSegments(_ segments: [DiffSegment]) -> [DiffSegment] { + guard !segments.isEmpty else { return segments } + var merged: [DiffSegment] = [] + var index = 0 + while index < segments.count { + let current = segments[index] + switch current.change { + case .added, .removed: + var combinedText = current.text + var lookahead = index + 1 + while lookahead + 1 < segments.count, + segments[lookahead].change == .unchanged, + segments[lookahead].text.isWhitespaceOnly, + segments[lookahead + 1].change == current.change { + combinedText += segments[lookahead].text + segments[lookahead + 1].text + lookahead += 2 + } + merged.append(DiffSegment(text: combinedText, change: current.change)) + index = lookahead + case .unchanged: + merged.append(current) + index += 1 + } + } + return merged + } +} + +private extension String { + var isWhitespaceOnly: Bool { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift new file mode 100644 index 00000000..e20ddbf8 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift @@ -0,0 +1,24 @@ +import Cocoa +import CGEventOverride +import Logger + +class NESCustomMenu: NSMenu { + weak var menuController: NESMenuController? + + override func awakeFromNib() { + super.awakeFromNib() + } + + override init(title: String) { + super.init(title: title) + } + + required init(coder: NSCoder) { + super.init(coder: coder) + } + + private func setupMenuAppearance() { + self.showsStateColumn = false + self.allowsContextMenuPlugIns = false + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift new file mode 100644 index 00000000..c9dd8c59 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift @@ -0,0 +1,94 @@ +import SwiftUI +import Cocoa +import Logger + +struct NESMenuButtonView: NSViewRepresentable { + let menuController: NESMenuController + var fontSize: CGFloat + + var buttonImage: NSImage? { + NSImage( + systemSymbolName: "arrow.right.to.line", + accessibilityDescription: "Next Edit Suggestion Menu" + ) + } + + var buttonFont: NSFont { + NSFont.systemFont(ofSize: fontSize, weight: .medium) + } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(frame: .zero) + button.title = "" + button.bezelStyle = .shadowlessSquare + button.isBordered = false + button.imageScaling = .scaleProportionallyDown + button.contentTintColor = .white + button.imagePosition = .imageOnly + button.focusRingType = .none + button.target = context.coordinator + button.action = #selector(Coordinator.buttonClicked) + button.font = buttonFont + + let baseConfig = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular) + let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: NSColor.white) + button.image = buttonImage? + .withSymbolConfiguration(baseConfig)? + .withSymbolConfiguration(colorConfig) + + context.coordinator.setupMenu(for: button) + + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + nsView.font = buttonFont + if let image = buttonImage { + let base = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular) + let tinted = NSImage.SymbolConfiguration(hierarchicalColor: .white) + nsView.image = image.withSymbolConfiguration(base)?.withSymbolConfiguration(tinted) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(menuController: menuController) + } + + class Coordinator: NSObject { + let menuController: NESMenuController + private weak var button: NSButton? + + init(menuController: NESMenuController) { + self.menuController = menuController + super.init() + } + + func setupMenu(for button: NSButton) { + self.button = button + } + + @objc func buttonClicked(_ sender: NSButton) { + let menu = menuController.createMenu() + showMenu(menu, for: sender) + } + + private func showMenu(_ menu: NSMenu, for button: NSButton) { + // Ensure the button is still in a window before showing the menu + guard let window = button.window else { + return + } + + // Ensure menu is properly positioned and shown + let location = NSPoint(x: 0, y: button.bounds.height + 5) + let originalLevel = window.level + window.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1) + defer { window.level = originalLevel } + + menu.popUp(positioning: nil, at: location, in: button) + } + + @objc func menuDidClose(_ menu: NSMenu) { } + + @objc func menuWillOpen(_ menu: NSMenu) { } + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift new file mode 100644 index 00000000..071b1dd2 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift @@ -0,0 +1,230 @@ +import Cocoa +import ComposableArchitecture +import SwiftUI +import HostAppActivator + +class NESMenuController: ObservableObject { + private static let defaultParagraphTabStopLocation: CGFloat = 180.0 + private static let titleColor: NSColor = NSColor(Color.secondary) + private static let shortcutIconColor: NSColor = NSColor.tertiaryLabelColor + static let baseFontSize: CGFloat = 13 + + private var menu: NSMenu? + var fontSize: CGFloat { + didSet { menu = nil } + } + var fontSizeScale: Double { + didSet { menu = nil } + } + var store: StoreOf + + private var imageSize: NSSize { + NSSize(width: self.fontSize, height: self.fontSize) + } + private var paragraphStyle: NSMutableParagraphStyle { + let style = NSMutableParagraphStyle() + style.tabStops = [ + NSTextTab( + textAlignment: .right, + location: Self.defaultParagraphTabStopLocation * fontSizeScale + ) + ] + return style + } + + init(fontSize: CGFloat, fontSizeScale: Double, store: StoreOf) { + self.fontSize = fontSize + self.fontSizeScale = fontSizeScale + self.store = store + } + + func createMenu() -> NSMenu { + let menu = NESCustomMenu(title: "") + menu.menuController = self + + menu.font = NSFont.systemFont(ofSize: fontSize, weight: .regular) + + let titleItem = createTitleItem() + let settingsItem = createSettingItem() + let goToAcceptItem = createGoToAcceptItem() + let rejectItem = createRejectItem() + let moreInfoItem = createGetMoreInfoItem() + + menu.addItem(titleItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(settingsItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(goToAcceptItem) + menu.addItem(rejectItem) +// menu.addItem(NSMenuItem.separator()) +// menu.addItem(moreInfoItem) + + self.menu = menu + return menu + } + + private func createImage(_ name: String, description accessibilityDescription: String) -> NSImage? { + guard let image = NSImage( + systemSymbolName: name, accessibilityDescription: accessibilityDescription + ) else { return nil } + + image.size = self.imageSize + return image + } + + private func createParagraphAttributedTitle(_ text: String, helpText: String) -> NSAttributedString { + let attributedTitle = NSMutableAttributedString(string: text) + attributedTitle.append(NSAttributedString( + string: "\t\(helpText)", + attributes: [ + .foregroundColor: Self.shortcutIconColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular), + .paragraphStyle: paragraphStyle + ] + )) + + attributedTitle.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: attributedTitle.length) + ) + + return attributedTitle + + } + + private func createParagraphAttributedTitle(_ text: String, systemSymbolName: String) -> NSAttributedString { + let attributedTitle = NSMutableAttributedString(string: text) + attributedTitle.append(NSAttributedString(string: "\t")) + + if let image = createImage(systemSymbolName, description: "\(systemSymbolName) key") { + let attachment = NSTextAttachment() + attachment.image = image + + let attachmentString = NSMutableAttributedString(attachment: attachment) + attachmentString.addAttributes([ + .foregroundColor: Self.shortcutIconColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular), + .paragraphStyle: paragraphStyle + ], range: NSRange(location: 0, length: attachmentString.length)) + + attributedTitle.append(attachmentString) + } + + attributedTitle.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: attributedTitle.length) + ) + + return attributedTitle + + } + + @objc func handleSettingsAction() { + try? launchHostAppAdvancedSettings() + } + + @objc func handleGoToAcceptAction() { + let state = store.withState { $0 } + state.nesContent?.acceptNESSuggestion() + } + + @objc func handleRejectAction() { + let state = store.withState { $0 } + state.nesContent?.rejectNESSuggestion() + } + + @objc func handleGetMoreInfoAction() { } + + private func createTitleItem() -> NSMenuItem { + let titleItem = NSMenuItem() + + titleItem.isEnabled = false + + let attributedTitle = NSMutableAttributedString(string: "Copilot Next Edit Suggestion") + attributedTitle.addAttributes([ + .foregroundColor: Self.titleColor, + .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .medium) + ], range: NSRange(location: 0, length: attributedTitle.length)) + + titleItem.attributedTitle = attributedTitle + return titleItem + } + + private func createSettingItem() -> NSMenuItem { + let settingsItem = NSMenuItem( + title: "Settings", + action: #selector(handleSettingsAction), + keyEquivalent: "" + ) + settingsItem.target = self + + if let gearImage = NSImage( + systemSymbolName: "gearshape", + accessibilityDescription: "Settings" + ) { + gearImage.size = self.imageSize + settingsItem.image = gearImage + } + + return settingsItem + } + + private func createGoToAcceptItem() -> NSMenuItem { + let goToAcceptItem = NSMenuItem( + title: "Go To / Accept", + action: #selector(handleGoToAcceptAction), + keyEquivalent: "" + ) + goToAcceptItem.target = self + + let imageSymbolName = "arrow.right.to.line" + + if let arrowImage = createImage(imageSymbolName, description: "Go To or Accept") { + goToAcceptItem.image = arrowImage + } + + let attributedTitle = createParagraphAttributedTitle("Go To / Accept", systemSymbolName: imageSymbolName) + goToAcceptItem.attributedTitle = attributedTitle + + return goToAcceptItem + } + + private func createRejectItem() -> NSMenuItem { + let rejectItem = NSMenuItem( + title: "Reject", + action: #selector(handleRejectAction), + keyEquivalent: "" + ) + rejectItem.target = self + + if let xImage = createImage("xmark", description: "Reject") { + rejectItem.image = xImage + } + + let attributedTitle = createParagraphAttributedTitle("Reject", helpText: "Esc") + rejectItem.attributedTitle = attributedTitle + + return rejectItem + } + + private func createGetMoreInfoItem() -> NSMenuItem { + let moreInfoItem = NSMenuItem( + title: "Get More Info", + action: #selector(handleGetMoreInfoAction), + keyEquivalent: "" + ) + moreInfoItem.target = self + + let attributedTitle = NSMutableAttributedString(string: "Get More Info") + attributedTitle.addAttributes([ + .foregroundColor: NSColor.linkColor, + .font: NSFont.systemFont(ofSize: fontSize, weight: .medium) + ], range: NSRange(location: 0, length: attributedTitle.length)) + + moreInfoItem.attributedTitle = attributedTitle + + return moreInfoItem + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESMenuView.swift b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift new file mode 100644 index 00000000..b6ca96f7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift @@ -0,0 +1,57 @@ +import ComposableArchitecture +import SwiftUI +import Foundation +import SharedUIComponents +import XcodeInspector +import Logger + +struct NESMenuView: View { + let store: StoreOf + + @State private var menuController: NESMenuController + + init(store: StoreOf) { + self.store = store + self._menuController = State( + initialValue: NESMenuController( + fontSize: store.lineFontSize, + fontSizeScale: store.fontSizeScale, + store: store + ) + ) + } + + var body: some View { + WithPerceptionTracking { + let lineHeight = store.lineHeight + let fontSizeScale = store.fontSizeScale + let fontSize = store.lineFontSize + if store.isPanelDisplayed && !store.isPanelOutOfFrame && store.nesContent != nil { + NESMenuButtonView( + menuController: menuController, + fontSize: fontSize + ) + .id("nes-menu-button") + .frame(width: lineHeight, height: calcMenuHeight(by: lineHeight)) + .padding(.horizontal, 3 * fontSizeScale) + .padding(.leading, 1 * fontSizeScale) + .padding(.vertical, 3 * fontSizeScale) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color("LightBluePrimary")) + ) + .opacity(store.menuViewOpacity) + .onChange(of: store.lineFontSize) { + menuController.fontSize = $0 + } + .onChange(of: store.fontSizeScale) { + menuController.fontSizeScale = $0 + } + } + } + } + + private func calcMenuHeight(by lineHeight: Double) -> Double { + return (lineHeight * 2 / 3 * 100).rounded() / 100 + } +} diff --git a/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift new file mode 100644 index 00000000..a73ebaae --- /dev/null +++ b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift @@ -0,0 +1,66 @@ +import SwiftUI +import ComposableArchitecture +import Logger + +struct NESNotificationView: View { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + WithPerceptionTracking { + if store.isPanelOutOfFrame, + !store.closeNotificationByUser, + store.nesContent != nil { + + let fontSize = store.lineFontSize + let scale = store.fontSizeScale + + HStack(spacing: 8) { + Image("EditSparkle") + .resizable() + .scaledToFit() + .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium)) + + HStack(spacing: 4 * scale) { + Text("Press") + + Text("Tab") + .foregroundStyle(.secondary) + + Text("to jump to Next Edit Suggestion") + } + .font(.system(size: fontSize, weight: .medium)) + + Button(action: { + store.send(.onUserCloseNotification) + }) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium)) + } + .foregroundStyle(Color(NSColor.controlBackgroundColor)) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.primary) + ) + .shadow( + color: Color("NESShadowColor"), + radius: 12, + x: 0, + y: 3 + ) + .opacity(store.notificationViewOpacity) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + + func calcImageFontSize(_ baseFontSize: CGFloat, _ scale: Double) -> CGFloat { + return baseFontSize + 2 * scale + } +} diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift index dd50233f..0ec9bb1f 100644 --- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift @@ -4,6 +4,8 @@ import Perception import SharedUIComponents import SwiftUI import XcodeInspector +import SuggestionBasic +import WorkspaceSuggestionService @Perceptible public final class CodeSuggestionProvider: Equatable { @@ -58,3 +60,95 @@ public final class CodeSuggestionProvider: Equatable { } +@Perceptible +public final class NESCodeSuggestionProvider: Equatable { + public static func == (lhs: NESCodeSuggestionProvider, rhs: NESCodeSuggestionProvider) -> Bool { + lhs.code == rhs.code && lhs.language == rhs.language + } + + public let fileURL: URL + public let code: String + public let sourceSnapshot: FilespaceSuggestionSnapshot + public let range: CursorRange + public let language: String + + @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void + @PerceptionIgnored public var onAcceptNESSuggestionTapped: () -> Void + @PerceptionIgnored public var onDismissNESSuggestionTapped: () -> Void + + public init( + fileURL: URL, + code: String, + sourceSnapshot: FilespaceSuggestionSnapshot, + range: CursorRange, + language: String = "", + onRejectSuggestionTapped: @escaping () -> Void = {}, + onAcceptNESSuggestionTapped: @escaping () -> Void = {}, + onDismissNESSuggestionTapped: @escaping () -> Void = {} + ) { + self.fileURL = fileURL + self.code = code + self.sourceSnapshot = sourceSnapshot + self.range = range + self.language = language + self.onRejectSuggestionTapped = onRejectSuggestionTapped + self.onAcceptNESSuggestionTapped = onAcceptNESSuggestionTapped + self.onDismissNESSuggestionTapped = onDismissNESSuggestionTapped + } + + func rejectNESSuggestion() { onRejectSuggestionTapped() } + func acceptNESSuggestion() { onAcceptNESSuggestionTapped() } + func dismissNESSuggestion() { onDismissNESSuggestionTapped() } + + func getOriginalCodeSnippet() -> String? { + /// The lines is from `EditorContent`, the "\n" is kept there. + let lines = sourceSnapshot.lines.joined(separator: "").components(separatedBy: .newlines) + guard range.start.line >= 0, + range.end.line >= range.start.line, + range.end.line < lines.count + else { return nil } + + // Single line case + if range.start.line == range.end.line { + let line = lines[range.start.line] + let startIndex = calcStartIndex(of: line, by: range) + let endIndex = calcEndIndex(of: line, by: range) + return String(line[startIndex.. 0) + let endIndex = calcEndIndex(of: line, by: range) + result.append(String(line[.. String.Index { + return line.index(line.startIndex, offsetBy: range.start.character, limitedBy: line.endIndex) ?? line.endIndex + } + + private func calcEndIndex(of line: String, by range: CursorRange) -> String.Index { + return line.index(line.startIndex, offsetBy: range.end.character, limitedBy: line.endIndex) ?? line.endIndex + } +} + diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index ee6d05ca..6f063016 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -17,6 +17,8 @@ enum Style { static let codeReviewPanelWidth: Double = 550 static let codeReviewPanelHeight: Double = 450 static let fixPanelToAnnotationSpacing: Double = 1 + static let nesSuggestionMenuLeadingPadding: Double = 4 + static let agentConfigurationWidgetLeadingSpacing: Double = 4 } extension Color { @@ -58,7 +60,7 @@ struct XcodeLikeFrame: View { content.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Material.bar) + .fill(Color.chatWindowBackgroundColor) ) .overlay( RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) @@ -73,8 +75,9 @@ struct XcodeLikeFrame: View { } extension View { - func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { - XcodeLikeFrame(content: self, cornerRadius: cornerRadius ?? 10) + var xcodeStyleCornerRadius: Double { 16 } + func xcodeStyleFrame() -> some View { + XcodeLikeFrame(content: self, cornerRadius: xcodeStyleCornerRadius) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 06adce2f..366d98d3 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -8,6 +8,7 @@ import Preferences import SwiftUI import UserDefaultsObserver import XcodeInspector +import SuggestionBasic @MainActor public final class SuggestionWidgetController: NSObject { @@ -48,6 +49,11 @@ public extension SuggestionWidgetController { store.send(.panel(.presentSuggestion)) } + + func suggestNESCode() { + store.send(.panel(.presentNESSuggestion)) + } + func expandSuggestion() { store.withState { state in if state.panelState.content.suggestion != nil { @@ -63,6 +69,14 @@ public extension SuggestionWidgetController { } } } + + func discardNESSuggestion() { + store.withState { state in + if state.panelState.nesContent != nil { + store.send(.panel(.discardNESSuggestion)) + } + } + } #warning("TODO: Make a progress controller that doesn't use TCA.") func markAsProcessing(_ isProcessing: Bool) { diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index f7ad662a..9c691e14 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -2,6 +2,7 @@ import Foundation public protocol SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? } struct MockWidgetDataSource: SuggestionWidgetDataSource { @@ -20,5 +21,24 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { currentSuggestionIndex: 0 ) } + + func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? { + return NESCodeSuggestionProvider( + fileURL: URL(fileURLWithPath: "the/file/path.swift"), + code: """ + func test() { + let x = 1 + let y = 2 + let z = x + y + } + """, + sourceSnapshot: .init( + lines: [""], + cursorPosition: .init(line: 0, character: 0) + ), + range: .init(startPair: (1, 0), endPair: (2, 0)), + language: "swift" + ) + } } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 45ef1aeb..6f681218 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -4,17 +4,123 @@ import XcodeInspector import ConversationServiceProvider public struct WidgetLocation: Equatable { + // Indicates from where the widget location generation was triggered + enum LocationTrigger { + case sourceEditor, xcodeWorkspaceWindow, unknown, otherApp + + var isSourceEditor: Bool { self == .sourceEditor } + var isOtherApp: Bool { self == .otherApp } + var isFromXcode: Bool { self == .sourceEditor || self == .xcodeWorkspaceWindow} + } + + struct NESPanelLocation: Equatable { + struct DiffViewConstraints: Equatable { + var maxX: CGFloat + var y: CGFloat + var maxWidth: CGFloat + var maxHeight: CGFloat + } + + var scrollViewFrame: CGRect + var screenFrame: CGRect + var lineFirstCharacterFrame: CGRect + + var lineHeight: Double { + lineFirstCharacterFrame.height + } + var menuFrame: CGRect { + .init( + x: scrollViewFrame.minX + Style.nesSuggestionMenuLeadingPadding, + y: screenFrame.height - lineFirstCharacterFrame.maxY, + width: lineFirstCharacterFrame.width, + height: lineHeight + ) + } + + var availableHeight: CGFloat? { + guard scrollViewFrame.contains(lineFirstCharacterFrame) else { + return nil + } + return scrollViewFrame.maxY - lineFirstCharacterFrame.minY + } + + var availableWidth: CGFloat { + return scrollViewFrame.width / 2 + } + + func calcDiffViewFrame(contentSize: CGSize) -> CGRect? { + guard scrollViewFrame.contains(lineFirstCharacterFrame) else { + return nil + } + + let availableWidth = max(0, scrollViewFrame.width / 2) + let availableHeight = max(0, scrollViewFrame.maxY - lineFirstCharacterFrame.minY) + let preferredWidth = max(contentSize.width, 1) + let preferredHeight = max(contentSize.height, lineHeight) + + let width = availableWidth > 0 ? min(preferredWidth, availableWidth) : preferredWidth + let height = availableHeight > 0 ? min(preferredHeight, availableHeight) : preferredHeight + + return .init( + x: scrollViewFrame.maxX - width - Style.nesSuggestionMenuLeadingPadding, + y: screenFrame.height - lineFirstCharacterFrame.minY - height, + width: width, + height: height + ) + } + } + + struct AgentConfigurationWidgetLocation: Equatable { + var firstLineFrame: CGRect + var scrollViewRect: CGRect + var screenFrame: CGRect + var textEndX: CGFloat + + var lineHeight: CGFloat { + firstLineFrame.height + } + + func getWidgetFrame(_ originalFrame: NSRect) -> NSRect { + let width = originalFrame.width + let height = originalFrame.height + let lineCenter = firstLineFrame.minY + firstLineFrame.height / 2 + let panelHalfHeight = originalFrame.height / 2 + + return .init( + x: textEndX + Style.agentConfigurationWidgetLeadingSpacing, + y: screenFrame.maxY - lineCenter - panelHalfHeight + screenFrame.minY, + width: width, + height: height + ) + } + } + struct PanelLocation: Equatable { var frame: CGRect var alignPanelTop: Bool var firstLineIndent: Double? var lineHeight: Double? } - + var widgetFrame: CGRect var tabFrame: CGRect var defaultPanelLocation: PanelLocation var suggestionPanelLocation: PanelLocation? + var nesSuggestionPanelLocation: NESPanelLocation? + var locationTrigger: LocationTrigger = .unknown + var agentConfigurationWidgetLocation: AgentConfigurationWidgetLocation? + + mutating func setNESSuggestionPanelLocation(_ location: NESPanelLocation?) { + self.nesSuggestionPanelLocation = location + } + + mutating func setLocationTrigger(_ trigger: LocationTrigger) { + self.locationTrigger = trigger + } + + mutating func setAgentConfigurationWidgetLocation(_ location: AgentConfigurationWidgetLocation?) { + self.agentConfigurationWidgetLocation = location + } } enum UpdateLocationStrategy { @@ -30,10 +136,10 @@ enum UpdateLocationStrategy { ) -> WidgetLocation { guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return FixedToBottom().framesForWindows( editorFrame: editorFrame, @@ -63,7 +169,7 @@ enum UpdateLocationStrategy { ) } } - + struct FixedToBottom { func framesForWindows( editorFrame: CGRect, @@ -86,7 +192,7 @@ enum UpdateLocationStrategy { ) } } - + struct HorizontalMovable { func framesForWindows( y: CGFloat, @@ -109,34 +215,34 @@ enum UpdateLocationStrategy { mainScreen.frame.height - editorFrame.minY - Style.widgetHeight - Style .widgetPadding ) - + var proposedAnchorFrameOnTheRightSide = CGRect( x: editorFrame.maxX - Style.widgetPadding, y: y, width: 0, height: 0 ) - + let widgetFrameOnTheRightSide = CGRect( x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, y: y, width: Style.widgetWidth, height: Style.widgetHeight ) - + if !hideCircularWidget { proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide } - + let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX - + Style.widgetPadding * 2 - - editorFrameExpendedSize.width + + Style.widgetPadding * 2 + - editorFrameExpendedSize.width let putPanelToTheRight = { if editorFrame.size.width >= preferredInsideEditorMinWidth { return false } return activeScreen.frame.maxX > proposedPanelX + Style.panelWidth }() let alignPanelTopToAnchor = fixedAlignment ?? (y > activeScreen.frame.midY) - + let chatPanelFrame = getChatPanelFrame(mainScreen) if putPanelToTheRight { @@ -144,12 +250,12 @@ enum UpdateLocationStrategy { let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor - ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding, + ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding + : anchorFrame.maxY + Style.widgetPadding, width: Style.widgetWidth, height: Style.widgetHeight ) - + return .init( widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, @@ -166,22 +272,22 @@ enum UpdateLocationStrategy { width: 0, height: 0 ) - + let widgetFrameOnTheLeftSide = CGRect( x: editorFrame.minX + Style.widgetPadding, y: proposedAnchorFrameOnTheRightSide.origin.y, width: Style.widgetWidth, height: Style.widgetHeight ) - + if !hideCircularWidget { proposedAnchorFrameOnTheLeftSide = widgetFrameOnTheLeftSide } - + let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX - - Style.widgetPadding * 2 - - Style.panelWidth - + editorFrameExpendedSize.width + - Style.widgetPadding * 2 + - Style.panelWidth + + editorFrameExpendedSize.width let putAnchorToTheLeft = { if editorFrame.size.width >= preferredInsideEditorMinWidth { if editorFrame.maxX <= activeScreen.frame.maxX { @@ -190,14 +296,14 @@ enum UpdateLocationStrategy { } return proposedPanelX > activeScreen.frame.minX }() - + if putAnchorToTheLeft { let anchorFrame = proposedAnchorFrameOnTheLeftSide let tabFrame = CGRect( x: anchorFrame.origin.x, y: alignPanelTopToAnchor - ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding, + ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding + : anchorFrame.maxY + Style.widgetPadding, width: Style.widgetWidth, height: Style.widgetHeight ) @@ -231,7 +337,7 @@ enum UpdateLocationStrategy { } } } - + struct NearbyTextCursor { func framesForSuggestionWindow( editorFrame: CGRect, @@ -242,35 +348,37 @@ enum UpdateLocationStrategy { ) -> WidgetLocation.PanelLocation? { guard let selectionFrame = UpdateLocationStrategy .getSelectionFirstLineFrame(editor: editor) else { return nil } - + // hide it when the line of code is outside of the editor visible rect if selectionFrame.maxY < editorFrame.minY || selectionFrame.minY > editorFrame.maxY { return nil } - + + let lineHeight: Double = selectionFrame.height + let selectionMinY = selectionFrame.minY // Always place suggestion window at cursor position. return .init( frame: .init( x: editorFrame.minX, - y: mainScreen.frame.height - selectionFrame.minY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding, + y: mainScreen.frame.height - selectionMinY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding, width: editorFrame.width, height: Style.inlineSuggestionMaxHeight ), alignPanelTop: true, firstLineIndent: selectionFrame.maxX - editorFrame.minX - Style.inlineSuggestionPadding, - lineHeight: selectionFrame.height + lineHeight: lineHeight ) } } - + /// Get the frame of the selection. static func getSelectionFrame(editor: AXUIElement) -> CGRect? { guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return nil } @@ -279,36 +387,36 @@ enum UpdateLocationStrategy { guard found else { return nil } return selectionFrame } - + /// Get the frame of the first line of the selection. static func getSelectionFirstLineFrame(editor: AXUIElement) -> CGRect? { // Find selection range rect guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? editor.copyParameterizedValue( + let rect: AXValue = try? editor.copyParameterizedValue( key: kAXBoundsForRangeParameterizedAttribute, parameters: selectedRange - ) + ) else { return nil } var selectionFrame: CGRect = .zero let found = AXValueGetValue(rect, .cgRect, &selectionFrame) guard found else { return nil } - + var firstLineRange: CFRange = .init() let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange) firstLineRange.length = 0 - - #warning( - "FIXME: When selection is too low and out of the screen, the selection range becomes something else." + +#warning( + "FIXME: When selection is too low and out of the screen, the selection range becomes something else." ) - + if foundFirstLine, let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), let firstLineRect: AXValue = try? editor.copyParameterizedValue( - key: kAXBoundsForRangeParameterizedAttribute, - parameters: firstLineSelectionRange + key: kAXBoundsForRangeParameterizedAttribute, + parameters: firstLineSelectionRange ) { var firstLineFrame: CGRect = .zero @@ -317,7 +425,7 @@ enum UpdateLocationStrategy { selectionFrame = firstLineFrame } } - + return selectionFrame } @@ -365,10 +473,10 @@ public struct CodeReviewLocationStrategy { currentLines: [String] ) -> Int { let difference = currentLines.difference(from: originalLines) - + let targetIndex = originalLineNumber var adjustment = 0 - + for change in difference { switch change { case .insert(let offset, _, _): @@ -383,19 +491,19 @@ public struct CodeReviewLocationStrategy { } } } - + return targetIndex + adjustment } - + static func getCurrentLineFrame( - editor: AXUIElement, + editor: AXUIElement, currentContent: String, - comment: ReviewComment, + comment: ReviewComment, originalContent: String ) -> (lineNumber: Int?, lineFrame: CGRect?) { let originalLines = originalContent.components(separatedBy: .newlines) let currentLines = currentContent.components(separatedBy: .newlines) - + let originalLineNumber = comment.range.end.line let currentLineNumber = calculateCurrentLineNumber( for: originalLineNumber, @@ -410,3 +518,108 @@ public struct CodeReviewLocationStrategy { return (currentLineNumber, rect) } } + +public struct NESPanelLocationStrategy { + static func getNESPanelLocation( + maybeEditor: AXUIElement, + state: WidgetFeature.State + ) -> WidgetLocation.NESPanelLocation? { + guard let sourceEditor = maybeEditor.findSourceEditorElement(shouldRetry: false), + let editorContent: String = try? sourceEditor.copyValue(key: kAXValueAttribute), + let nesContent = state.panelState.nesContent, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }) + else { + return nil + } + + let startLine = nesContent.range.start.line + guard let lineFirstCharacterFrame = LocationStrategyHelper.getLineFrame( + startLine, + in: sourceEditor, + with: editorContent.components(separatedBy: .newlines), + length: 1 + ) else { + return nil + } + + guard let scrollViewFrame = sourceEditor.parent?.rect else { + return nil + } + + return .init( + scrollViewFrame: scrollViewFrame, + screenFrame: screen.frame, + lineFirstCharacterFrame: lineFirstCharacterFrame + ) + } +} + +public struct AgentConfigurationWidgetLocationStrategy { + static func getAgentConfigurationWidgetLocation( + maybeEditor: AXUIElement, + screen: NSScreen + ) -> WidgetLocation.AgentConfigurationWidgetLocation? { + guard let sourceEditorElement = maybeEditor.findSourceEditorElement(shouldRetry: false), + let editorContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute), + let scrollViewRect = sourceEditorElement.parent?.rect + else { + return nil + } + + // Get the editor content to access lines + let lines = editorContent.components(separatedBy: .newlines) + guard !lines.isEmpty else { + return nil + } + + // Get the frame of the first line (line 0) + guard let firstLineFrame = LocationStrategyHelper.getLineFrame( + 0, + in: sourceEditorElement, + with: [lines[0]] + ) else { + return nil + } + + // Check if the first line is visible within the scroll view + guard firstLineFrame.width > 0, firstLineFrame.height > 0, + scrollViewRect.contains(firstLineFrame) + else { + return nil + } + + // Get the actual text content width (excluding trailing whitespace) + let firstLineText = lines[0].trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let textEndX: CGFloat + + if !firstLineText.isEmpty { + // Calculate character position for the end of the trimmed text + let textLength = firstLineText.count + var range = CFRange(location: 0, length: textLength) + + if let rangeValue = AXValueCreate(AXValueType.cfRange, &range), + let boundsValue: AXValue = try? sourceEditorElement.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: rangeValue + ) { + var textRect = CGRect.zero + if AXValueGetValue(boundsValue, .cgRect, &textRect) { + textEndX = textRect.maxX + } else { + textEndX = firstLineFrame.minX + } + } else { + textEndX = firstLineFrame.minX + } + } else { + textEndX = firstLineFrame.minX + } + + return .init( + firstLineFrame: firstLineFrame, + scrollViewRect: scrollViewRect, + screenFrame: screen.frame, + textEndX: textEndX + ) + } +} diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index cb75004c..e790da75 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -61,8 +61,9 @@ actor WidgetWindowsController: NSObject { }.store(in: &cancellable) xcodeInspector.$focusedEditor.sink { [weak self] editor in - Task { @MainActor [weak self] in + Task { @MainActor [weak self] in self?.store.send(.fixErrorPanel(.onFocusedEditorChanged(editor))) + self?.store.send(.panel(.agentConfigurationWidget(.onFocusedEditorChanged(editor)))) } guard let editor else { return } @@ -76,7 +77,7 @@ actor WidgetWindowsController: NSObject { }.store(in: &cancellable) xcodeInspector.$activeDocumentURL.sink { [weak self] url in - Task { [weak self] in + Task { [weak self] in await self?.updateCodeReviewWindowLocation(.onActiveDocumentURLChanged) _ = await MainActor.run { [weak self] in self?.store.send(.codeReviewPanel(.onActiveDocumentURLChanged(url))) @@ -96,6 +97,12 @@ actor WidgetWindowsController: NSObject { // Observe state change of fix error setupFixErrorPanelObservers() + + // Observer state change for NES + setupNESSuggestionPanelObservers() + + // Observe feature flags + setupFeatureFlagObservers() } private func setupCodeReviewPanelObservers() { @@ -302,7 +309,7 @@ extension WidgetWindowsController { @MainActor func hideSuggestionPanelWindow() { windows.suggestionPanelWindow.alphaValue = 0 - send(.panel(.hidePanel)) + send(.panel(.hidePanel(.suggestion))) } @MainActor @@ -318,9 +325,9 @@ extension WidgetWindowsController { windows.codeReviewPanelWindow.orderFrontRegardless() } - func generateWidgetLocation() -> WidgetLocation? { + func generateWidgetLocation(_ state: WidgetFeature.State) -> WidgetLocation { // Default location when no active application/window - let defaultLocation = generateDefaultLocation() + var defaultLocation = generateDefaultLocation() if let application = xcodeInspector.latestActiveXcode?.appElement { if let focusElement = xcodeInspector.focusedEditor?.element, @@ -333,6 +340,12 @@ extension WidgetWindowsController { .value(for: \.suggestionWidgetPositionMode) let suggestionMode = UserDefaults.shared .value(for: \.suggestionPresentationMode) + + let nesPanelLocation: WidgetLocation.NESPanelLocation? = NESPanelLocationStrategy.getNESPanelLocation(maybeEditor: parent, state: state) + let locationTrigger: WidgetLocation.LocationTrigger = .sourceEditor + let agentConfigurationWidgetLocation = AgentConfigurationWidgetLocationStrategy.getAgentConfigurationWidgetLocation( + maybeEditor: parent, screen: screen + ) switch positionMode { case .fixedToBottom: @@ -341,6 +354,9 @@ extension WidgetWindowsController { mainScreen: screen, activeScreen: firstScreen ) + result.setNESSuggestionPanelLocation(nesPanelLocation) + result.setLocationTrigger(locationTrigger) + result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy @@ -362,6 +378,9 @@ extension WidgetWindowsController { activeScreen: firstScreen, editor: focusElement ) + result.setNESSuggestionPanelLocation(nesPanelLocation) + result.setLocationTrigger(locationTrigger) + result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy @@ -379,19 +398,20 @@ extension WidgetWindowsController { } } else if var window = application.focusedWindow, var frame = application.focusedWindow?.rect, - !["menu bar", "menu bar item"].contains(window.description), + !window.isXcodeMenuBar, frame.size.height > 300, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), let firstScreen = NSScreen.main { - if ["open_quickly"].contains(window.identifier) - || ["alert"].contains(window.label) + if window.isXcodeOpenQuickly + || window.isXcodeAlert { // fallback to use workspace window guard let workspaceWindow = application.windows - .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), + .first(where: { $0.isXcodeWorkspaceWindow }), let rect = workspaceWindow.rect else { + defaultLocation.setLocationTrigger(.otherApp) return defaultLocation } @@ -400,7 +420,7 @@ extension WidgetWindowsController { } var expendedSize = CGSize.zero - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { + if window.isXcodeWorkspaceWindow { // extra padding to bottom so buttons won't be covered frame.size.height -= 40 } else { @@ -411,13 +431,16 @@ extension WidgetWindowsController { expendedSize.height += Style.widgetPadding } - return UpdateLocationStrategy.FixedToBottom().framesForWindows( + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( editorFrame: frame, mainScreen: screen, activeScreen: firstScreen, preferredInsideEditorMinWidth: 9_999_999_999, // never editorFrameExpendedSize: expendedSize ) + result.setLocationTrigger(.xcodeWorkspaceWindow) + + return result } } return defaultLocation @@ -434,12 +457,15 @@ extension WidgetWindowsController { frame: chatPanelFrame, alignPanelTop: false ), - suggestionPanelLocation: nil + suggestionPanelLocation: nil, + nesSuggestionPanelLocation: nil ) } func updatePanelState(_ location: WidgetLocation) async { await send(.updatePanelStateToMatch(location)) + await send(.updateNESSuggestionPanelStateToMatch(location)) + await send(.updateAgentConfigurationWidgetStateToMatch(location)) } func updateWindowOpacity(immediately: Bool) { @@ -475,8 +501,13 @@ extension WidgetWindowsController { /// We need this to hide the windows when Xcode is minimized. let noFocus = application.focusedWindow == nil windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 - send(.panel(noFocus ? .hidePanel : .showPanel)) + send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion))) windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes))) + applyOpacityForNESWindows(by: noFocus) + send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration))) + applyOpacityForAgentConfigurationWidget(by: noFocus) + windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = noFocus ? 0 : 1 windows.toastWindow.alphaValue = noFocus ? 0 : 1 @@ -498,8 +529,13 @@ extension WidgetWindowsController { let previousAppIsXcode = previousActiveApplication?.isXcode ?? false - send(.panel(noFocus ? .hidePanel : .showPanel)) + send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion))) windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes))) + applyOpacityForNESWindows(by: noFocus) + send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration))) + applyOpacityForAgentConfigurationWidget(by: noFocus) + windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1 windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = if noFocus { 0 @@ -518,6 +554,10 @@ extension WidgetWindowsController { } else { windows.sharedPanelWindow.alphaValue = 0 windows.suggestionPanelWindow.alphaValue = 0 + windows.nesMenuWindow.alphaValue = 0 + windows.nesDiffWindow.alphaValue = 0 + applyOpacityForAgentConfigurationWidget() + windows.nesNotificationWindow.alphaValue = 0 windows.widgetWindow.alphaValue = 0 windows.toastWindow.alphaValue = 0 if !isChatPanelDetached { @@ -595,7 +635,7 @@ extension WidgetWindowsController { func update() async { let state = store.withState { $0 } let isChatPanelDetached = state.chatPanelState.isDetached - guard let widgetLocation = await generateWidgetLocation() else { return } + let widgetLocation = await generateWidgetLocation(state) await updatePanelState(widgetLocation) windows.widgetWindow.setFrame( @@ -622,6 +662,29 @@ extension WidgetWindowsController { ) } + if let nesPanelLocation = widgetLocation.nesSuggestionPanelLocation { + windows.nesMenuWindow.setFrame( + nesPanelLocation.menuFrame, + display: false, + animate: animated + ) + await updateNESDiffWindowFrame( + nesPanelLocation, + animated: animated, + trigger: widgetLocation.locationTrigger + ) + + await updateNESNotificationWindowFrame(nesPanelLocation, animated: animated) + } + + if let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation { + windows.agentConfigurationWidgetWindow.setFrame( + agentConfigurationWidgetLocation.getWidgetFrame(windows.agentConfigurationWidgetWindow.frame), + display: false, + animate: animated + ) + } + let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode) if isAttachedToXcodeEnabled { // update in `updateAttachedChatWindowLocation` @@ -1009,6 +1072,92 @@ public final class WidgetWindows { it.setIsVisible(true) return it }() + + @MainActor + lazy var nesMenuWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = false + it.contentView = NSHostingView( + rootView: NESMenuView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() + + @MainActor + lazy var nesDiffWindow = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.contentView = NSHostingView( + rootView: NESDiffView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + it.hasShadow = true + return it + }() + + @MainActor + lazy var nesNotificationWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isOpaque = false + it.backgroundColor = .clear + it.level = widgetLevel(2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.contentView = NSHostingView( + rootView: NESNotificationView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.nesSuggestionPanelState, + action: \.nesSuggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() @MainActor lazy var codeReviewPanelWindow = { @@ -1070,6 +1219,42 @@ public final class WidgetWindows { ) ).environment(cursorPositionTracker) ) + it.canBecomeKeyChecker = { false } + it.alphaValue = 0 + it.setIsVisible(false) + return it + }() + + @MainActor + lazy var agentConfigurationWidgetWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init( + x: 0, + y: 0, + width: Style.panelWidth, + height: Style.panelHeight + ), + styleMask: .borderless, + backing: .buffered, + defer: true + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] + it.hasShadow = false + it.level = widgetLevel(2) + it.contentView = NSHostingView( + rootView: AgentConfigurationWidgetView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.agentConfigurationWidgetState, + action: \.agentConfigurationWidget + ) + ).environment(cursorPositionTracker) + ) it.canBecomeKeyChecker = { true } it.alphaValue = 0 it.setIsVisible(false) @@ -1134,7 +1319,11 @@ public final class WidgetWindows { toastWindow.orderFrontRegardless() sharedPanelWindow.orderFrontRegardless() suggestionPanelWindow.orderFrontRegardless() + nesMenuWindow.orderFrontRegardless() fixErrorPanelWindow.orderFrontRegardless() + nesDiffWindow.orderFrontRegardless() + nesNotificationWindow.orderFrontRegardless() + agentConfigurationWidgetWindow.orderFrontRegardless() if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue { chatPanelWindow.orderFrontRegardless() } diff --git a/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift b/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift new file mode 100644 index 00000000..486b018f --- /dev/null +++ b/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import ChatService + +class ToolAutoApprovalParsingHelpersTests: XCTestCase { + func testExtractSubCommandsWithTreeSitter() { + // Simple command + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("git status"), ["git status"]) + + // Chained commands + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("cd Core && swift test"), ["cd Core", "swift test"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("make build; make install"), ["make build", "make install"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("make build || echo 'fail'"), ["make build", "echo 'fail'"]) + + // Pipes + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("ls -la | grep swift"), ["ls -la", "grep swift"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("ls &> out.txt"), ["ls &> out.txt"]) + + // Complex with quotes (content inside quotes shouldn't be split) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("echo 'hello && world'"), ["echo 'hello && world'"]) + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("echo $(date +%Y) && ls"), ["echo $", "date +%Y", "ls"]) + + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("git commit -m \"fix: update && clean\""), ["git commit -m \"fix: update && clean\""]) + + // Nested / Subshells (might depend on how detailed the query is) + // (command) query usually picks up the command nodes. + // For `(cd Core; ls)`, the inner commands are commands too. + XCTAssertEqual(Set(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("(cd Core; ls)")), Set(["cd Core", "ls"])) + + // Empty or whitespace + XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter(" "), []) + } + + func testExtractTerminalCommandNames() { + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "git status"), ["git"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "run_tests.sh --verbose"), ["run_tests.sh"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "sudo apt-get install"), ["apt-get"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "env VAR=1 command run"), ["command"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "cd Core && swift test"), ["cd", "swift"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls | grep match"), ["ls", "grep"]) + XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls &> out.txt"), ["ls"]) + } +} diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift index 70469700..966f047f 100644 --- a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift +++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift @@ -1,10 +1,12 @@ import Foundation import XCTest +import SuggestionBasic @testable import Workspace @testable import KeyBindingManager class TabToAcceptSuggestionTests: XCTestCase { + @WorkspaceActor func test_should_accept() { let fileURL = URL(string: "file:///test")! @@ -20,7 +22,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (true, nil) + ), (true, nil, .codeCompletion) ) } @@ -39,7 +41,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No suggestion") + ), (false, "No suggestion", nil) ) } @@ -57,7 +59,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No filespace") + ), (false, "No filespace", nil) ) } @@ -76,7 +78,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No focused editor") + ), (false, "No focused editor", nil) ) } @@ -95,7 +97,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No active Xcode") + ), (false, "No active Xcode", nil) ) } @@ -114,7 +116,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, "No active document") + ), (false, "No active document", nil) ) } @@ -133,7 +135,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskShift), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -152,7 +154,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskCommand), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -171,7 +173,7 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskControl), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } @@ -190,33 +192,14 @@ class TabToAcceptSuggestionTests: XCTestCase { event: createEvent(48, flags: .maskHelp), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ), (false, nil) - ) - } - - @WorkspaceActor - func test_should_not_accept_without_tab() { - let fileURL = URL(string: "file:///test")! - let workspacePool = FakeWorkspacePool() - workspacePool.setTestFile(fileURL: fileURL) - let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( - activeDocumentURL: fileURL, - hasActiveXcode: true, - hasFocusedEditor: true - ) - assertEqual( - TabToAcceptSuggestion.shouldAcceptSuggestion( - event: createEvent(50), - workspacePool: workspacePool, - xcodeInspector: xcodeInspector - ), (false, nil) + ), (false, nil, nil) ) } } private func assertEqual( - _ result: (Bool, String?), - _ expected: (Bool, String?) + _ result: (Bool, String?, CodeSuggestionType?), + _ expected: (Bool, String?, CodeSuggestionType?), ) { if result != expected { XCTFail("Expected \(expected), got \(result)") @@ -242,7 +225,7 @@ private class FakeWorkspacePool: WorkspacePool { @WorkspaceActor func setTestFile(fileURL: URL, skipSuggestion: Bool = false) { self.fileURL = fileURL - self.filespace = Filespace(fileURL: fileURL, onSave: {_ in }, onClose: {_ in }) + self.filespace = Filespace(fileURL: fileURL, content: "", onSave: {_ in }, onClose: {_ in }) if skipSuggestion { return } guard let filespace = self.filespace else { return } filespace.setSuggestions([.init(id: "id", text: "test", position: .zero, range: .zero)]) diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index 191bf6aa..a018b294 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -6,6 +6,7 @@ import SuggestionBasic import Workspace import XCTest import XPCShared +import LanguageServerProtocol @testable import Service @@ -26,7 +27,7 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { fatalError() } - func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, contentChanges: [LanguageServerProtocol.TextDocumentContentChangeEvent]?) async throws { fatalError() } @@ -59,13 +60,29 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType { completions } + func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: SuggestionBasic.CursorPosition + ) async throws -> [SuggestionBasic.CodeSuggestion] { + completions + } + func notifyShown(_ completion: SuggestionBasic.CodeSuggestion) async { shown = completion.id } + + func notifyCopilotInlineEditShown(_ completion: SuggestionBasic.CodeSuggestion) async { + shown = completion.id + } func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async { accepted = completion.id } + + func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async { + accepted = completion.id + } func notifyRejected(_ completions: [CodeSuggestion]) async { rejected = completions.map(\.id) diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift index 941a6c84..83990303 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -21,6 +21,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let pool = WorkspacePool() let filespace = Filespace( fileURL: URL(fileURLWithPath: "file/path/to.swift"), + content: "", onSave: { _ in }, onClose: { _ in } ) diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index dfdb4b3e..653492fc 100644 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -856,6 +856,57 @@ final class AcceptSuggestionTests: XCTestCase { """) } + + func test_accept_multi_lines_suggestion_with_overlay() async throws { + let content = """ + struct Cat { + var name: String + var age: String + } + """ + let text = """ + newName: String + var newAge + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 12), + range: .init( + start: .init(line: 1, character: 8), + end: .init(line: 2, character: 11) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo, + isNES: true + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + lines, + content.breakIntoEditorStyleLines().applying(extraInfo.modifications) + ) + XCTAssertEqual(cursor, .init(line: 2, character: 22)) + XCTAssertEqual( + lines.joined(separator: ""), + """ + struct Cat { + var newName: String + var newAge: String + } + + """ + ) + } } extension String { diff --git a/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift new file mode 100644 index 00000000..fb52105b --- /dev/null +++ b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift @@ -0,0 +1,55 @@ +import XCTest + +@testable import SuggestionWidget + +final class NESDiffBuilderTests: XCTestCase { + func testInlineSegmentsIdentifiesChangesWithinLine() { + let oldLine = " let foo = 1" + let newLine = " let bar = 2" + + let segments = DiffBuilder.inlineSegments(oldLine: oldLine, newLine: newLine) + + XCTAssertEqual(segments.count, 6) + XCTAssertEqual( + segments.map(\.change), + [.unchanged, .removed, .added, .unchanged, .removed, .added] + ) + XCTAssertEqual( + segments.map(\.text), + [" let ", "foo ", "bar ", "= ", "1", "2"] + ) + } + + func testInlineSegmentsWhenOldLineIsEmptyTreatsNewContentAsAdded() { + let segments = DiffBuilder.inlineSegments(oldLine: "", newLine: "value") + + XCTAssertEqual(segments.count, 1) + XCTAssertEqual(segments.first?.change, .added) + XCTAssertEqual(segments.first?.text, "value") + } + + func testLineSegmentsReturnsDiffAcrossLineBoundaries() { + let oldContent = [ + "line1", + "line2", + "line3" + ].joined(separator: "\n") + let newContent = [ + "line1", + "line3" + ].joined(separator: "\n") + + let segments = DiffBuilder.lineSegments(oldContent: oldContent, newContent: newContent) + + XCTAssertEqual(segments.count, 3) + XCTAssertEqual( + segments.map(\.change), + [.unchanged, .removed, .unchanged] + ) + XCTAssertEqual( + segments.map(\.text), + ["line1", "line2", "line3"] + ) + } +} + diff --git a/Docs/Images/chat_agent.gif b/Docs/Images/chat_agent.gif new file mode 100644 index 00000000..a6a684d1 Binary files /dev/null and b/Docs/Images/chat_agent.gif differ diff --git a/Docs/Images/chat_dark.gif b/Docs/Images/chat_dark.gif deleted file mode 100644 index abd5cc20..00000000 Binary files a/Docs/Images/chat_dark.gif and /dev/null differ diff --git a/Docs/Images/document-folder-permission-request.png b/Docs/Images/document-folder-permission-request.png new file mode 100644 index 00000000..1d512ae4 Binary files /dev/null and b/Docs/Images/document-folder-permission-request.png differ diff --git a/Docs/Images/screen-record-permission-request.png b/Docs/Images/screen-record-permission-request.png new file mode 100644 index 00000000..0b3eeb25 Binary files /dev/null and b/Docs/Images/screen-record-permission-request.png differ diff --git a/EditorExtension/AcceptNESSuggestionCommand.swift b/EditorExtension/AcceptNESSuggestionCommand.swift new file mode 100644 index 00000000..6c015030 --- /dev/null +++ b/EditorExtension/AcceptNESSuggestionCommand.swift @@ -0,0 +1,32 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit +import XPCShared + +class AcceptNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Next Edit Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.getNESSuggestionAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} diff --git a/EditorExtension/RejectNESSuggestionCommand.swift b/EditorExtension/RejectNESSuggestionCommand.swift new file mode 100644 index 00000000..43183779 --- /dev/null +++ b/EditorExtension/RejectNESSuggestionCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class RejectNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Decline Next Edit Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getNESSuggestionRejectedCode(editorContent: .init(invocation)) + } + } +} + diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index a9d252f9..a0ca6579 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -12,7 +12,9 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { var builtin: [[XCSourceEditorCommandDefinitionKey: Any]] { [ AcceptSuggestionCommand(), + AcceptNESSuggestionCommand(), RejectSuggestionCommand(), + RejectNESSuggestionCommand(), GetSuggestionsCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 4dfc0da1..a40b2137 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -88,6 +88,12 @@ extension AppDelegate { action: nil, keyEquivalent: "" ) + + toggleNES = NSMenuItem( + title: "Enable/Disable Next Edit Suggestions (NES)", + action: #selector(toggleNESEnabled), + keyEquivalent: "" + ) // Auth menu item with custom view accountItem = NSMenuItem() @@ -163,6 +169,7 @@ extension AppDelegate { statusBarMenu.addItem(openChat) statusBarMenu.addItem(toggleCompletions) statusBarMenu.addItem(toggleIgnoreLanguage) + statusBarMenu.addItem(toggleNES) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(openCopilotForXcodeItem) statusBarMenu.addItem(openDocs) @@ -204,6 +211,10 @@ extension AppDelegate: NSMenuDelegate { toggleIgnoreLanguage.action = nil } } + + if toggleNES != nil { + toggleNES.title = "\(UserDefaults.shared.value(for: \.realtimeNESToggle) ? "Disable" : "Enable") Next Edit Suggestions (NES)" + } Task { await forceAuthStatusCheck() @@ -322,6 +333,19 @@ private extension AppDelegate { } } + @objc func toggleNESEnabled() { + Task { + let initialSetting = UserDefaults.shared.value(for: \.realtimeNESToggle) + do { + let service = getXPCExtensionService() + try await service.toggleRealtimeNES() + } catch { + Logger.service.error("Failed to toggle NES enabled via XPC: \(error)") + UserDefaults.shared.set(!initialSetting, for: \.realtimeNESToggle) + } + } + } + @objc func toggleIgnoreLanguageEnabled() { guard let lang = DisabledLanguageList.shared.activeDocumentLanguage else { return } diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 7f89e6cf..4995aa31 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -42,6 +42,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var quotaItem: NSMenuItem! var toggleCompletions: NSMenuItem! var toggleIgnoreLanguage: NSMenuItem! + var toggleNES: NSMenuItem! var openChat: NSMenuItem! var signOutItem: NSMenuItem! var xpcController: XPCController? @@ -67,6 +68,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() + _ = FeatureFlagNotifierImpl.shared + observeFeatureFlags() watchServiceStatus() watchAXStatus() watchAuthStatus() @@ -234,6 +237,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } } + + + func observeFeatureFlags() { + Task { @MainActor in + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .sink(receiveValue: { [weak self] featureFlags in + self?.toggleNES.isHidden = !featureFlags.editorPreviewFeatures + }) + } + } func watchAuthStatus() { let notifications = DistributedNotificationCenter.default().notifications(named: .authStatusDidChange) @@ -286,6 +299,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true + self.toggleNES.isHidden = true self.signOutItem.isHidden = true } @@ -300,26 +314,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // If the quota is nil, keep the original auth status item // Else only log the CLS error other than quota limit reached error if CLSMessageSummary.summary == CLSMessageType.other.summary || status.quotaInfo == nil { - self.authStatusItem.isHidden = false - self.authStatusItem.title = CLSMessageSummary.summary - - let submenu = NSMenu() - let attributedCLSErrorItem = NSMenuItem() - attributedCLSErrorItem.view = ErrorMessageView( - errorMessage: CLSMessageSummary.detail + configureCLSAuthStatusItem( + summary: CLSMessageSummary, + actionTitle: "View Details on GitHub", + action: #selector(openGitHubDetailsLink) ) - submenu.addItem(attributedCLSErrorItem) - submenu.addItem(.separator()) - submenu.addItem( - NSMenuItem( - title: "View Details on GitHub", - action: #selector(openGitHubDetailsLink), - keyEquivalent: "" - ) + } else if CLSMessageSummary.summary == CLSMessageType.byokLimitedReached.summary { + configureCLSAuthStatusItem( + summary: CLSMessageSummary, + actionTitle: "Dismiss", + action: #selector(dismissBYOKMessage) ) - - self.authStatusItem.submenu = submenu - self.authStatusItem.isEnabled = true + } else { + // Explicitly hide to avoid leaving stale content if another CLS state was previously shown. + self.authStatusItem.isHidden = true } } else { self.authStatusItem.isHidden = true @@ -352,9 +360,36 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false + self.toggleNES.isHidden = false self.signOutItem.isHidden = false } + func configureCLSAuthStatusItem( + summary: CLSMessage, + actionTitle: String, + action: Selector + ) { + self.authStatusItem.isHidden = false + self.authStatusItem.title = summary.summary + let submenu = NSMenu() + + let attributedCLSErrorItem = NSMenuItem() + attributedCLSErrorItem.view = ErrorMessageView( + errorMessage: summary.detail + ) + submenu.addItem(attributedCLSErrorItem) + submenu.addItem(.separator()) + submenu.addItem( + NSMenuItem( + title: actionTitle, + action: action, + keyEquivalent: "" + ) + ) + self.authStatusItem.submenu = submenu + self.authStatusItem.isEnabled = true + } + private func configureNotAuthorized(status: StatusResponse) { self.accountItem.view = AccountItemView( target: self, @@ -378,6 +413,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.quotaItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true + self.toggleNES.isHidden = true self.signOutItem.isHidden = false } @@ -391,6 +427,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.quotaItem.isHidden = true self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false + self.toggleNES.isHidden = false self.signOutItem.isHidden = false } @@ -477,6 +514,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } } + + @objc func dismissBYOKMessage() { + Task { + await Status.shared.updateCLSStatus(.normal, busy: false, message: "") + } + } } extension NSRunningApplication { @@ -491,6 +534,7 @@ extension NSRunningApplication { enum CLSMessageType { case chatLimitReached case completionLimitReached + case byokLimitedReached case other var summary: String { @@ -499,6 +543,8 @@ enum CLSMessageType { return "Monthly Chat Limit Reached" case .completionLimitReached: return "Monthly Completion Limit Reached" + case .byokLimitedReached: + return "BYOK Limit Reached" case .other: return "CLS Error" } @@ -526,6 +572,8 @@ func getCLSMessageSummary(_ message: String) -> CLSMessage { messageType = .chatLimitReached } else if message.contains("Completions limit reached") { messageType = .completionLimitReached + } else if message.contains("BYOK") { + messageType = .byokLimitedReached } else { messageType = .other } diff --git a/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg new file mode 100644 index 00000000..0d699645 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json new file mode 100644 index 00000000..7154a326 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Agent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..1ff6f10e --- /dev/null +++ b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xFB", + "red" : "0xFB" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x24", + "green" : "0x24", + "red" : "0x24" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..a68c9465 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x30", + "green" : "0x2A", + "red" : "0x29" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json new file mode 100644 index 00000000..8ea26959 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0xEC", + "red" : "0xEB" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "secondaryLabelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json new file mode 100644 index 00000000..58029746 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE5", + "green" : "0xE1", + "red" : "0xDF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "secondaryLabelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/Colors/Contents.json b/ExtensionService/Assets.xcassets/Colors/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Colors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json new file mode 100644 index 00000000..9658cfff --- /dev/null +++ b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "248", + "green" : "189", + "red" : "160" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "85", + "green" : "45", + "red" : "25" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json new file mode 100644 index 00000000..c66c7fae --- /dev/null +++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "edit-sparkle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg new file mode 100644 index 00000000..9cff22ff --- /dev/null +++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json new file mode 100644 index 00000000..dbc3a4e3 --- /dev/null +++ b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x7E", + "green" : "0x70", + "red" : "0x6C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xD6", + "green" : "0xD0", + "red" : "0xCE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json new file mode 100644 index 00000000..552c7769 --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xE2", + "red" : "0xD4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "110", + "green" : "67", + "red" : "46" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json new file mode 100644 index 00000000..1c8b1e91 --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0x74", + "red" : "0x35" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json new file mode 100644 index 00000000..ef4b486e --- /dev/null +++ b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "60", + "green" : "138", + "red" : "32" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x8A", + "red" : "0x20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json new file mode 100644 index 00000000..f606b54c --- /dev/null +++ b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.180", + "blue" : "0x26", + "green" : "0x1F", + "red" : "0x1B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json new file mode 100644 index 00000000..3bd5adce --- /dev/null +++ b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.940", + "green" : "0.930", + "red" : "0.920" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.260", + "green" : "0.230", + "red" : "0.220" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json index ddb0a503..a1548aa0 100644 --- a/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "codeReview.svg", + "filename" : "code-review-light.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "codeReview 1.svg", + "filename" : "code-review-dark.svg", "idiom" : "universal" } ], @@ -20,6 +20,7 @@ "version" : 1 }, "properties" : { - "preserves-vector-representation" : true + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" } } diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg new file mode 100644 index 00000000..39eea110 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg new file mode 100644 index 00000000..b60a0982 --- /dev/null +++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg deleted file mode 100644 index 44ce60ee..00000000 --- a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg deleted file mode 100644 index 6084e72c..00000000 --- a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/README.md b/README.md index c6b9553a..eea0b39a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ # GitHub Copilot for Xcode -[GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer -tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions. +[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Swift, Objective-C and iOS/macOS development. It delivers intelligent Completions, Chat, and Code Reviewโ€”plus advanced features like Agent Mode, Next Edit Suggestions, MCP Registry, and Copilot Vision to make Xcode development faster and smarter. ## Chat GitHub Copilot Chat provides suggestions to your specific coding tasks via chat. -Chat of GitHub Copilot for Xcode +Chat of GitHub Copilot for Xcode ## Agent Mode @@ -29,7 +28,7 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta - macOS 12+ - Xcode 8+ -- A GitHub Copilot subscription. To learn more, visit [https://github.com/features/copilot](https://github.com/features/copilot). +- A GitHub account ## Getting Started @@ -146,11 +145,11 @@ Copilot for Xcode. Weโ€™d love to get your help in making GitHub Copilot better! If you have feedback or encounter any problems, please reach out on our [Feedback -forum](https://github.com/orgs/community/discussions/categories/copilot). +forum](https://github.com/github/CopilotForXcode/discussions). ## Acknowledgements Thank you to @intitni for creating the original project that this is based on. Attributions can be found under About when running the app or in -[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf). \ No newline at end of file +[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf). diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 3a34e1c1..54e04ee5 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,20 +1,18 @@ -### GitHub Copilot for Xcode 0.42.0 +### GitHub Copilot for Xcode 0.47.0 **๐Ÿš€ Highlights** -* Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md). -* Support for custom instruction files at `.github/instructions/*.instructions.md`. See [CustomInstructions.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/CustomInstructions.md). -* Support for prompt files at `.github/prompts/*.prompt.md`. See [PromptFiles.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/PromptFiles.md). -* Default chat mode is now set to โ€œAgentโ€. - +- **Toolcall Auto Approval**: Streamlined workflow with auto-approval support for MCP tools, sensitive files, and terminal commands. +- **MCP Registry**: The MCP registry and allowlist features are now available (requires editor preview feature flag). **๐Ÿ’ช Improvements** -* Use the current selection as chat context. -* Add folders as chat context. -* Shortcut to quickly fix errors in Xcode. -* Use โ†‘/โ†“ keys to reuse previous chat context in the chat view. +- Refined the working set header. +- Improved the details view for MCP tool calls. **๐Ÿ› ๏ธ Bug Fixes** -* Cannot copy url from Safari browser to chat view. +- Fixed layout issues with tool calls. +- Resolved display issues for Next Edit Suggestions (NES). +- Improved error messaging for SSL certificate failures. +- Addressed various performance issues. diff --git a/SUPPORT.md b/SUPPORT.md index 33762051..fe426a0a 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -4,7 +4,7 @@ Weโ€™d love to get your help in making GitHub Copilot better! If you have feedback or encounter any problems, please reach out on our [Feedback -forum](https://github.com/orgs/community/discussions/categories/copilot). +forum](https://github.com/github/CopilotForXcode/discussions). GitHub Copilot for Xcode is under active development and maintained by GitHub staff. We will do our best to respond to support, feature requests, and diff --git a/Server/package-lock.json b/Server/package-lock.json index 691f9447..4ff704de 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,20 +8,20 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.373.0", - "@github/copilot-language-server-darwin-arm64": "^1.373.0", - "@github/copilot-language-server-darwin-x64": "^1.373.0", + "@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", "@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.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.3.14", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", "webpack-cli": "^6.0.1" @@ -38,9 +38,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.373.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.373.0.tgz", - "integrity": "sha512-tcRyxEvm36M30x5v3u/OuPnPENZJsmbMkcY+6A45Fsr0ZtUJF7BtAS/Si/2QTCVJndA2Oi7taicIuqSDucAR/Q==", + "version": "1.411.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.411.0.tgz", + "integrity": "sha512-KxuvWq3DT4qTujxtgDQTHmynWawDiwqsRC9BmuBVi5PyzdyejJEj6rgcuwH7WcOMgNQJlHVQmSxN6uYurqp26w==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -49,17 +49,17 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.373.0", - "@github/copilot-language-server-darwin-x64": "1.373.0", - "@github/copilot-language-server-linux-arm64": "1.373.0", - "@github/copilot-language-server-linux-x64": "1.373.0", - "@github/copilot-language-server-win32-x64": "1.373.0" + "@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" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.373.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.373.0.tgz", - "integrity": "sha512-pzZZnQX3jIYmQ0/LgcB54xfnbFTmCmymSL1v5OemH9qpG3Xi4ekTnRy/YRGStxHAbM5mvPX9QDJJ+/CFTvSBGg==", + "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==", "cpu": [ "arm64" ], @@ -69,9 +69,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.373.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.373.0.tgz", - "integrity": "sha512-1yfXy5cum7it3jUJ43ruymtj9StERUPEEY2nM9lCGgtv+Wn7ip0k2IFQvzfp/ql0FCivH0O954pqkrHO7GUYZg==", + "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==", "cpu": [ "x64" ], @@ -81,9 +81,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.373.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.373.0.tgz", - "integrity": "sha512-dijhk5AlP3SQuECFXEHyNlzGxV0HClWM3yP54pod8Wu3Yb6Xo5Ek9ClEiNPc1f0FOiVT3DJ0ldmtm6Tb2/2xTA==", + "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==", "cpu": [ "arm64" ], @@ -94,9 +94,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.373.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.373.0.tgz", - "integrity": "sha512-YCjhxglxPEneJUAycT90GWpNpswWsl1/RCYe7hG7lxKN6At0haE9XF/i/bisvwyqSBB9vUOFp2TB/XhwD9dQWg==", + "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==", "cpu": [ "x64" ], @@ -107,9 +107,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.373.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.373.0.tgz", - "integrity": "sha512-lxMIjKwVbpg2JAgo11Ddwv7i0FSgCxjC+2XG6f/3ItG8M0dRkGzJzVNl9sQaTbZPria8T4vNB9nRM0Lpe92LUA==", + "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==", "cpu": [ "x64" ], @@ -707,9 +707,9 @@ "license": "MIT" }, "node_modules/copy-webpack-plugin": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz", - "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==", + "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==", "dev": true, "license": "MIT", "dependencies": { @@ -1899,9 +1899,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", - "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", + "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/Server/package.json b/Server/package.json index a67ea506..62a20e1d 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,20 +7,20 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "^1.373.0", - "@github/copilot-language-server-darwin-arm64": "^1.373.0", - "@github/copilot-language-server-darwin-x64": "^1.373.0", + "@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", "@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.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.3.14", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", "webpack-cli": "^6.0.1" diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 68be3768..eee16478 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -12,6 +12,8 @@ common issues: - [Extension Permission](#extension-permission) - Allows GitHub Copilot to integrate with Xcode - [Accessibility Permission](#accessibility-permission) - Enables real-time code suggestions - [Background Permission](#background-permission) - Allows extension to connect with host app + - [Files & Folders Permission](#files--folders-permission) - Allows GitHub Copilot for Xcode to access files and folders + - [Screen & System Audio Recording Permission](#screen--system-audio-recording-permission-optional) (Optional) - Allow GitHub Copilot for Xcode to capture screen when using Copilot Vision Please note that GitHub Copilot for Xcode may not work properly if any necessary permissions are missing. @@ -30,7 +32,8 @@ Or you can navigate to the permission manually depending on your OS version: | macOS | Location | | :--- | :--- | -| 15 | System Settings > General > Login Items > Extensions > Xcode Source Editor | +| 26 | System Settings > General > Login Items & Extensions > Extensions > By Category > Xcode Source Editor | +| 15 | System Settings > General > Login Items & Extensions > Extensions > Xcode Source Editor | | 13 & 14 | System Settings > Privacy & Security > Extensions > Xcode Source Editor | | 12 | System Preferences > Extensions | @@ -74,15 +77,57 @@ Please ensure that this permission is enabled. You can manually navigate to the | macOS | Location | | :--- | :--- | +| 26 | System Settings > General > Login Items & Extensions > App Background Activity | | 15 | System Settings > General > Login Items & Extensions > Allow in the Background | | 13 & 14 | System Settings > General > Login Items > Allow in the Background | Ensure that "GitHub Copilot for Xcode" is enabled in the list of allowed background items. Without this permission, the extension may not be able to properly communicate with the host app, which can result in inconsistent behavior or reduced functionality. +## Files & Folders Permission + +GitHub Copilot for Xcode needs permission to read your projectโ€™s files so it can: + +- Use actual file contents as contextual grounding when you ask questions in Ask and Agent mode (instead of generic language-only answers) +- Safely apply or preview multi-file edits in Agent modes (e.g. refactors, adding tests, updating related types) +- Improve precision by leveraging nearby code, patterns, and naming conventions + +

+ Files & Folders Permission +

+ +When first prompted macOS shows a dialog asking to allow access to folders. Click "Allow". +If you clicked "Don't Allow" or nothing appears: + +| macOS | Location | +| :--- | :--- | +| 13 & 14 & 15 & 26 | System Settings > Privacy & Security > Files and Folders | +| 12 | System Preferences > Security & Privacy > Privacy > Files and Folders | + +In the list, expand `GitHub Copilot for Xcode` and enable the toggles for any relevant locations (e.g. โ€œDocumentsโ€ if your repositories live there). If your code is elsewhere (e.g. `~/Developer`), macOS may instead prompt dynamically the next time Copilot tries to read those pathsโ€”accept when prompted. + +## Screen & System Audio Recording Permission (Optional) + +This permission is only needed if you choose to use Copilot Vision (screen-based context capture). + +Copilot does NOT require screen recording for standard inline suggestions, chat, or agent operations. + +

+ Screen & System Audio Recording Permission +

+ +This permission is typically granted automatically when you first use Copilot Vision and try to capture screen in GitHub Copilot for Xcode. You can also manually navigate to the background permission setting based on your macOS version: + +| macOS | Location | +| :--- | :--- | +| 14 & 15 & 26 | System Settings > Privacy & Security > Screen & System Audio Recording | +| 13 | System Settings > Privacy & Security > Screen Recording | +| 12 | System Preferences > Security & Privacy > Privacy > Screen Recording | + +Check `GitHub Copilot for Xcode` and restart the app. ## Logs -Logs can be found in `~/Library/Logs/GitHubCopilot/` the most recent log file +Logs can be found in `~/Library/Logs/GitHubCopilot/`. The most recent log file is: ``` diff --git a/Tool/Package.swift b/Tool/Package.swift index 541990d8..b54bd789 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -22,6 +22,7 @@ let package = Package( .library(name: "Persist", targets: ["Persist"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), + .library(name: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]), .library(name: "WebContentExtractor", targets: ["WebContentExtractor"]), .library( name: "SuggestionProvider", @@ -196,6 +197,7 @@ let package = Package( .target( name: "SharedUIComponents", dependencies: [ + "AppKitExtension", "Highlightr", "Preferences", "SuggestionBasic", @@ -279,6 +281,7 @@ let package = Package( .target(name: "ConversationServiceProvider", dependencies: [ "GitHelper", + "SuggestionBasic", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), ]), @@ -316,6 +319,7 @@ let package = Package( "SystemUtils", "Workspace", "Persist", + "SuggestionProvider", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] diff --git a/Tool/Sources/AXExtension/AXUIElement+Xcode.swift b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift new file mode 100644 index 00000000..ff5948cc --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift @@ -0,0 +1,43 @@ +import AppKit +import Foundation + +// Extension for xcode specifically +public extension AXUIElement { + private static let XcodeWorkspaceWindowIdentifier = "Xcode.WorkspaceWindow" + + var isSourceEditor: Bool { + description == "Source Editor" + } + + var isEditorArea: Bool { + description == "editor area" + } + + var isXcodeWorkspaceWindow: Bool { + self.description == Self.XcodeWorkspaceWindowIdentifier || self.identifier == Self.XcodeWorkspaceWindowIdentifier + } + + var isXcodeOpenQuickly: Bool { + ["open_quickly"].contains(self.identifier) + } + + var isXcodeAlert: Bool { + ["alert"].contains(self.label) + } + + var isXcodeMenuBar: Bool { + ["menu bar", "menu bar item"].contains(self.description) + } + + var isNavigator: Bool { + description == "navigator" + } + + var isDescendantOfNavigator: Bool { + self.firstParent(where: \.isNavigator) != nil + } + + var isNonNavigatorSourceEditor: Bool { + isSourceEditor && !isDescendantOfNavigator + } +} diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index b7366398..677a8264 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -56,18 +56,6 @@ public extension AXUIElement { (try? copyValue(key: kAXLabelValueAttribute)) ?? "" } - var isSourceEditor: Bool { - description == "Source Editor" - } - - var isEditorArea: Bool { - description == "editor area" - } - - var isXcodeWorkspaceWindow: Bool { - description == "Xcode.WorkspaceWindow" || identifier == "Xcode.WorkspaceWindow" - } - var selectedTextRange: ClosedRange? { guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) else { return nil } @@ -247,16 +235,16 @@ public extension AXUIElement { } func retrieveSourceEditor() -> AXUIElement? { - if self.isSourceEditor { return self } + if isNonNavigatorSourceEditor { return self } if self.isXcodeWorkspaceWindow { - return self.firstChild(where: \.isSourceEditor) + return self.firstChild(where: \.isNonNavigatorSourceEditor) } guard let xcodeWorkspaceWindowElement = self.firstParent(where: \.isXcodeWorkspaceWindow) else { return nil } - return xcodeWorkspaceWindowElement.firstChild(where: \.isSourceEditor) + return xcodeWorkspaceWindowElement.firstChild(where: \.isNonNavigatorSourceEditor) } } @@ -339,24 +327,24 @@ public extension AXUIElement { func findSourceEditorElement(shouldRetry: Bool = true) -> AXUIElement? { // 1. Check if the current element is a source editor - if isSourceEditor { + if isNonNavigatorSourceEditor { return self } // 2. Search for child that is a source editor - if let sourceEditorChild = firstChild(where: \.isSourceEditor) { + if let sourceEditorChild = firstChild(where: \.isNonNavigatorSourceEditor) { return sourceEditorChild } // 3. Search for parent that is a source editor (XcodeInspector's approach) - if let sourceEditorParent = firstParent(where: \.isSourceEditor) { + if let sourceEditorParent = firstParent(where: \.isNonNavigatorSourceEditor) { return sourceEditorParent } // 4. Search for parent that is an editor area if let editorAreaParent = firstParent(where: \.isEditorArea) { // 3.1 Search for child that is a source editor - if let sourceEditorChild = editorAreaParent.firstChild(where: \.isSourceEditor) { + if let sourceEditorChild = editorAreaParent.firstChild(where: \.isNonNavigatorSourceEditor) { return sourceEditorChild } } @@ -366,7 +354,7 @@ public extension AXUIElement { // 4.1 Search for child that is an editor area if let editorAreaChild = xcodeWorkspaceWindowParent.firstChild(where: \.isEditorArea) { // 4.2 Search for child that is a source editor - if let sourceEditorChild = editorAreaChild.firstChild(where: \.isSourceEditor) { + if let sourceEditorChild = editorAreaChild.firstChild(where: \.isNonNavigatorSourceEditor) { return sourceEditorChild } } diff --git a/Tool/Sources/AXHelper/AXHelper.swift b/Tool/Sources/AXHelper/AXHelper.swift index 5af9a206..533a3c1e 100644 --- a/Tool/Sources/AXHelper/AXHelper.swift +++ b/Tool/Sources/AXHelper/AXHelper.swift @@ -2,6 +2,17 @@ import XPCShared import XcodeInspector import AppKit +public enum AXHelperError: LocalizedError { + case failedToSetValue(AXError) + + public var errorDescription: String? { + switch self { + case .failedToSetValue(let axError): + return "Failed to set focus element value by AccessibilityAPI: \(axError.rawValue)" + } + } +} + public struct AXHelper { public init() {} @@ -26,6 +37,7 @@ public struct AXHelper { if let onError = onError { onError() } + throw AXHelperError.failedToSetValue(error) } // recover selection range @@ -88,7 +100,7 @@ public struct AXHelper { } public static func scrollSourceEditorToLine(_ lineNumber: Int, content: String, focusedElement: AXUIElement) { - guard focusedElement.isSourceEditor, + guard focusedElement.isNonNavigatorSourceEditor, let scrollBar = focusedElement.parent?.verticalScrollBar, let linePosition = Self.getScrollPositionForLine(lineNumber, content: content) else { return } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift index 525b5c54..c4c9b8c7 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -3,6 +3,122 @@ import Foundation import Preferences import ConversationServiceProvider import TelemetryServiceProvider +import LanguageServerProtocol + +// Exported from `CopilotForXcodeKit`, as we need to modify the protocol for document change +public protocol CopilotForXcodeExtensionCapability { + associatedtype TheSuggestionService: SuggestionServiceType + associatedtype TheChatService: ChatServiceType + associatedtype ThePromptToCodeService: PromptToCodeServiceType + + /// The suggestion service. + /// + /// Provide a non nil value if the extension provides a suggestion service, even if + /// the extension is not yet ready to provide suggestions. + /// + /// If you don't have a suggestion service in this extension, simply ignore this property. + var suggestionService: TheSuggestionService? { get } + /// Not implemented yet. + var chatService: TheChatService? { get } + /// Not implemented yet. + var promptToCodeService: ThePromptToCodeService? { get } + + // MARK: Optional Methods + + /// Called when a workspace is opened. + /// + /// A workspace may have already been opened when the extension is activated. + /// Use ``HostServer/getExistedWorkspaces()`` to get all ``WorkspaceInfo`` instead. + func workspaceDidOpen(_ workspace: WorkspaceInfo) + + /// Called when a workspace is closed. + func workspaceDidClose(_ workspace: WorkspaceInfo) + + /// Called when a document is saved. + func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) + + /// Called when a document is closed. + /// + /// - note: Copilot for Xcode doesn't know that a document is closed. It use + /// some mechanism to detect if the document is closed which is inaccurate and could be delayed. + func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) + + /// Called when a document is opened. + /// + /// - note: Copilot for Xcode doesn't know that a document is opened. It use + /// some mechanism to detect if the document is opened which is inaccurate and could be delayed. + func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) async + + /// Called when a document is changed. + /// + /// - attention: `content` could be nil if \ + /// โ€ข the document is too large \ + /// โ€ข the document is binary \ + /// โ€ข the document is git ignored \ + /// โ€ข the extension is not considered in-use by the host app \ + /// โ€ข the extension has no permission to access the file \ + /// \ + /// If you still want to access the file content in these cases, + /// you will have to access the file by yourself, or call ``HostServer/getDocument(at:)``. + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? + ) async + + /// Called occasionally to inform the extension how it is used in the app. + /// + /// The `usage` contains information like the current user-picked suggestion service, etc. + /// You can use this to determine if you would like to startup or dispose some resources. + /// + /// For example, if you are running a language server to provide suggestions, you may want to + /// kill the process when the suggestion service is no longer in use. + func extensionUsageDidChange(_ usage: ExtensionUsage) +} + +public extension CopilotForXcodeExtensionCapability { + func xcodeDidBecomeActive() {} + + func xcodeDidBecomeInactive() {} + + func xcodeDidSwitchEditor() {} + + func workspaceDidOpen(_: WorkspaceInfo) {} + + func workspaceDidClose(_: WorkspaceInfo) {} + + func workspace(_: WorkspaceInfo, didSaveDocumentAt _: URL) {} + + func workspace(_: WorkspaceInfo, didCloseDocumentAt _: URL) {} + + func workspace(_: WorkspaceInfo, didOpenDocumentAt _: URL) async {} + + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async {} + + func extensionUsageDidChange(_: ExtensionUsage) {} +} + +public extension CopilotForXcodeExtensionCapability +where TheSuggestionService == NoSuggestionService +{ + var suggestionService: TheSuggestionService? { nil } +} + +public extension CopilotForXcodeExtensionCapability +where ThePromptToCodeService == NoPromptToCodeService +{ + var promptToCodeService: ThePromptToCodeService? { nil } +} + +public extension CopilotForXcodeExtensionCapability where TheChatService == NoChatService { + var chatService: TheChatService? { nil } +} public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability & CopilotForXcodeTelemetryCapability diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index 0b62e141..14625052 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -56,36 +56,53 @@ public final class BuiltinExtensionConversationServiceProvider< } } - public func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws { + public func createConversation( + _ request: ConversationRequest, workspaceURL: URL? + ) async throws -> ConversationCreateResponse? { guard let conversationService else { Logger.service.error("Builtin chat service not found.") - return + return nil } guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") - return + return nil } - try await conversationService.createConversation(request, workspace: workspaceInfo) + return try await conversationService.createConversation(request, workspace: workspaceInfo) } - public func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws { + public func createTurn( + with conversationId: String, request: ConversationRequest, workspaceURL: URL? + ) async throws -> ConversationCreateResponse? { guard let conversationService else { Logger.service.error("Builtin chat service not found.") - return + return nil } guard let workspaceInfo = await activeWorkspace(workspaceURL) else { Logger.service.error("Could not get active workspace info") - return + return nil } - try await conversationService + return try await conversationService .createTurn( with: conversationId, request: request, workspace: workspaceInfo ) } + + public func deleteTurn(with conversationId: String, turnId: String, workspaceURL: URL?) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + guard let workspaceInfo = await activeWorkspace(workspaceURL) else { + Logger.service.error("Could not get active workspace info") + return + } + + try await conversationService.deleteTurn(with: conversationId, turnId: turnId, workspace: workspaceInfo) + } public func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws { guard let conversationService else { @@ -136,6 +153,19 @@ public final class BuiltinExtensionConversationServiceProvider< return (try? await conversationService.templates(workspace: workspaceInfo)) } + + public func modes() async throws -> [ConversationMode]? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return (try? await conversationService.modes(workspace: workspaceInfo)) + } public func models() async throws -> [CopilotModel]? { guard let conversationService else { @@ -172,7 +202,7 @@ public final class BuiltinExtensionConversationServiceProvider< return (try? await conversationService.agents(workspace: workspaceInfo)) } - public func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? { + public func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? { guard let conversationService else { Logger.service.error("Builtin chat service not found.") return nil @@ -182,6 +212,6 @@ public final class BuiltinExtensionConversationServiceProvider< return nil } - return (try? await conversationService.reviewChanges(workspace: workspaceInfo, params: params)) + return (try? await conversationService.reviewChanges(workspace: workspaceInfo, changes: changes)) } } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift index f6234ddf..4b09aeef 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -29,8 +29,8 @@ public final class BuiltinExtensionSuggestionServiceProvider< self.extensionManager = extensionManager } - var service: CopilotForXcodeKit.SuggestionServiceType? { - extensionManager.extensions.first { $0 is T }?.suggestionService + var service: (SuggestionServiceType & NESSuggestionServiceType)? { + extensionManager.extensions.first { $0 is T }?.suggestionService as? (SuggestionServiceType & NESSuggestionServiceType) } struct BuiltinExtensionSuggestionServiceNotFoundError: Error, LocalizedError { @@ -47,25 +47,22 @@ public final class BuiltinExtensionSuggestionServiceProvider< Logger.service.error("Builtin suggestion service not found.") throw BuiltinExtensionSuggestionServiceNotFoundError() } + return try await service.getSuggestions( - .init( - fileURL: request.fileURL, - relativePath: request.relativePath, - language: .init( - rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue - ) ?? .plaintext, - content: request.content, - originalContent: request.originalContent, - cursorPosition: .init( - line: request.cursorPosition.line, - character: request.cursorPosition.character - ), - tabSize: request.tabSize, - indentSize: request.indentSize, - usesTabsForIndentation: request.usesTabsForIndentation, - relevantCodeSnippets: request.relevantCodeSnippets.map { $0.converted } - ), - workspace: workspaceInfo + request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo + ).map { $0.converted } + } + + public func getNESSuggestions( + _ request: SuggestionProvider.SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionBasic.CodeSuggestion] { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + throw BuiltinExtensionSuggestionServiceNotFoundError() + } + return try await service.getNESSuggestions( + request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo ).map { $0.converted } } @@ -121,6 +118,26 @@ extension SuggestionProvider.SuggestionRequest { relevantCodeSnippets: relevantCodeSnippets.map(\.converted) ) } + + func toCopilotForXcodeKitSuggestionRequest() -> CopilotForXcodeKit.SuggestionRequest { + .init( + fileURL: self.fileURL, + relativePath: self.relativePath, + language: .init( + rawValue: languageIdentifierFromFileURL(self.fileURL).rawValue + ) ?? .plaintext, + content: self.content, + originalContent: self.originalContent, + cursorPosition: .init( + line: self.cursorPosition.line, + character: self.cursorPosition.character + ), + tabSize: self.tabSize, + indentSize: self.indentSize, + usesTabsForIndentation: self.usesTabsForIndentation, + relevantCodeSnippets: self.relevantCodeSnippets.map { $0.converted } + ) + } } extension SuggestionBasic.CodeSuggestion { diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift index a03c34d1..4599f3b6 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -1,5 +1,6 @@ import Foundation import Workspace +import LanguageServerProtocol public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { let extensionManager: BuiltinExtensionManager @@ -9,16 +10,20 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { super.init(workspace: workspace) } - override public func didOpenFilespace(_ filespace: Filespace) { - notifyOpenFile(filespace: filespace) + override public func didOpenFilespace(_ filespace: Filespace) async { + await notifyOpenFile(filespace: filespace) } override public func didSaveFilespace(_ filespace: Filespace) { notifySaveFile(filespace: filespace) } - override public func didUpdateFilespace(_ filespace: Filespace, content: String) { - notifyUpdateFile(filespace: filespace, content: content) + override public func didUpdateFilespace( + _ filespace: Filespace, + content: String, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async { + await notifyUpdateFile(filespace: filespace, content: content, contentChanges: contentChanges) } override public func didCloseFilespace(_ fileURL: URL) { @@ -32,28 +37,29 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { } } - public func notifyOpenFile(filespace: Filespace) { - Task { - guard filespace.isTextReadable else { return } - for ext in extensionManager.extensions { - ext.workspace( - .init(workspaceURL: workspaceURL, projectURL: projectRootURL), - didOpenDocumentAt: filespace.fileURL - ) - } + public func notifyOpenFile(filespace: Filespace) async { + guard filespace.isTextReadable else { return } + for ext in extensionManager.extensions { + await ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didOpenDocumentAt: filespace.fileURL + ) } } - public func notifyUpdateFile(filespace: Filespace, content: String) { - Task { - guard filespace.isTextReadable else { return } - for ext in extensionManager.extensions { - ext.workspace( - .init(workspaceURL: workspaceURL, projectURL: projectRootURL), - didUpdateDocumentAt: filespace.fileURL, - content: content - ) - } + public func notifyUpdateFile( + filespace: Filespace, + content: String, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async { + guard filespace.isTextReadable else { return } + for ext in extensionManager.extensions { + await ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didUpdateDocumentAt: filespace.fileURL, + content: content, + contentChanges: contentChanges + ) } } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index 9bcbcf97..d988a91e 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -9,10 +9,77 @@ public protocol ChatMemory { } public extension ChatMemory { - /// Append a message to the history. func appendMessage(_ message: ChatMessage) async { await mutateHistory { history in - if let index = history.firstIndex(where: { $0.id == message.id }) { + if let parentTurnId = message.parentTurnId { + history.removeAll { $0.id == message.id } + + guard let parentIndex = history.firstIndex(where: { $0.id == parentTurnId }) else { + return + } + + var parentMessage = history[parentIndex] + + if !message.editAgentRounds.isEmpty { + var parentRounds = parentMessage.editAgentRounds + + if let lastParentRoundIndex = parentRounds.indices.last { + var existingSubRounds = parentRounds[lastParentRoundIndex].subAgentRounds ?? [] + + for messageRound in message.editAgentRounds { + if let subIndex = existingSubRounds.firstIndex(where: { $0.roundId == messageRound.roundId }) { + existingSubRounds[subIndex].reply = existingSubRounds[subIndex].reply + messageRound.reply + + if let messageToolCalls = messageRound.toolCalls, !messageToolCalls.isEmpty { + var mergedToolCalls = existingSubRounds[subIndex].toolCalls ?? [] + for newToolCall in messageToolCalls { + if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { + mergedToolCalls[toolCallIndex].status = newToolCall.status + if let toolType = newToolCall.toolType { + mergedToolCalls[toolCallIndex].toolType = toolType + } + if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { + mergedToolCalls[toolCallIndex].progressMessage = progressMessage + } + if let input = newToolCall.input, !input.isEmpty { + mergedToolCalls[toolCallIndex].input = input + } + if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty { + mergedToolCalls[toolCallIndex].inputMessage = inputMessage + } + if let result = newToolCall.result, !result.isEmpty { + mergedToolCalls[toolCallIndex].result = result + } + if let resultDetails = newToolCall.resultDetails, !resultDetails.isEmpty { + mergedToolCalls[toolCallIndex].resultDetails = resultDetails + } + if let error = newToolCall.error, !error.isEmpty { + mergedToolCalls[toolCallIndex].error = error + } + if let invokeParams = newToolCall.invokeParams { + mergedToolCalls[toolCallIndex].invokeParams = invokeParams + } + if let title = newToolCall.title { + mergedToolCalls[toolCallIndex].title = title + } + } else { + mergedToolCalls.append(newToolCall) + } + } + existingSubRounds[subIndex].toolCalls = mergedToolCalls + } + } else { + existingSubRounds.append(messageRound) + } + } + + parentRounds[lastParentRoundIndex].subAgentRounds = existingSubRounds + parentMessage.editAgentRounds = parentRounds + } + } + + history[parentIndex] = parentMessage + } else if let index = history.firstIndex(where: { $0.id == message.id }) { history[index].mergeMessage(with: message) } else { history.append(message) @@ -26,6 +93,15 @@ public extension ChatMemory { $0.removeAll { $0.id == id } } } + + /// Remove multiple messages from the history by their IDs. + func removeMessages(_ ids: [String]) async { + await mutateHistory { history in + history.removeAll { message in + ids.contains(message.id) + } + } + } /// Clear the history. func clearHistory() async { @@ -35,26 +111,19 @@ public extension ChatMemory { extension ChatMessage { mutating func mergeMessage(with message: ChatMessage) { - // merge content self.content = self.content + message.content - // merge references var seen = Set() - // without duplicated and keep order self.references = (self.references + message.references).filter { seen.insert($0).inserted } - // merge followUp self.followUp = message.followUp ?? self.followUp - // merge suggested title self.suggestedTitle = message.suggestedTitle ?? self.suggestedTitle - // merge error message self.errorMessages = self.errorMessages + message.errorMessages self.panelMessages = self.panelMessages + message.panelMessages - // merge steps if !message.steps.isEmpty { var mergedSteps = self.steps @@ -69,7 +138,6 @@ extension ChatMessage { self.steps = mergedSteps } - // merge agent steps if !message.editAgentRounds.isEmpty { let mergedAgentRounds = mergeEditAgentRounds( oldRounds: self.editAgentRounds, @@ -79,7 +147,17 @@ extension ChatMessage { self.editAgentRounds = mergedAgentRounds } + self.parentTurnId = message.parentTurnId ?? self.parentTurnId + self.codeReviewRound = message.codeReviewRound + + self.fileEdits = mergeFileEdits(oldEdits: self.fileEdits, newEdits: message.fileEdits) + + self.turnStatus = message.turnStatus ?? self.turnStatus + + // merge modelName and billingMultiplier + self.modelName = message.modelName ?? self.modelName + self.billingMultiplier = message.billingMultiplier ?? self.billingMultiplier } private func mergeEditAgentRounds(oldRounds: [AgentRound], newRounds: [AgentRound]) -> [AgentRound] { @@ -94,9 +172,24 @@ extension ChatMessage { for newToolCall in newRound.toolCalls! { if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { mergedToolCalls[toolCallIndex].status = newToolCall.status + if let toolType = newToolCall.toolType { + mergedToolCalls[toolCallIndex].toolType = toolType + } if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage } + if let input = newToolCall.input, !input.isEmpty { + mergedToolCalls[toolCallIndex].input = input + } + if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty { + mergedToolCalls[toolCallIndex].inputMessage = inputMessage + } + if let result = newToolCall.result, !result.isEmpty { + mergedToolCalls[toolCallIndex].result = result + } + if let resultDetails = newToolCall.resultDetails, !resultDetails.isEmpty { + mergedToolCalls[toolCallIndex].resultDetails = resultDetails + } if let error = newToolCall.error, !error.isEmpty { mergedToolCalls[toolCallIndex].error = newToolCall.error } @@ -109,6 +202,45 @@ extension ChatMessage { } mergedAgentRounds[index].toolCalls = mergedToolCalls } + + if let newSubAgentRounds = newRound.subAgentRounds, !newSubAgentRounds.isEmpty { + var mergedSubRounds = mergedAgentRounds[index].subAgentRounds ?? [] + for newSubRound in newSubAgentRounds { + if let subIndex = mergedSubRounds.firstIndex(where: { $0.roundId == newSubRound.roundId }) { + mergedSubRounds[subIndex].reply = mergedSubRounds[subIndex].reply + newSubRound.reply + + if let subToolCalls = newSubRound.toolCalls, !subToolCalls.isEmpty { + var mergedSubToolCalls = mergedSubRounds[subIndex].toolCalls ?? [] + for newSubToolCall in subToolCalls { + if let toolCallIndex = mergedSubToolCalls.firstIndex(where: { $0.id == newSubToolCall.id }) { + mergedSubToolCalls[toolCallIndex].status = newSubToolCall.status + if let progressMessage = newSubToolCall.progressMessage, !progressMessage.isEmpty { + mergedSubToolCalls[toolCallIndex].progressMessage = newSubToolCall.progressMessage + } + if let error = newSubToolCall.error, !error.isEmpty { + mergedSubToolCalls[toolCallIndex].error = newSubToolCall.error + } + if let result = newSubToolCall.result, !result.isEmpty { + mergedSubToolCalls[toolCallIndex].result = result + } + if let resultDetails = newSubToolCall.resultDetails, !resultDetails.isEmpty { + mergedSubToolCalls[toolCallIndex].resultDetails = resultDetails + } + if let invokeParams = newSubToolCall.invokeParams { + mergedSubToolCalls[toolCallIndex].invokeParams = invokeParams + } + } else { + mergedSubToolCalls.append(newSubToolCall) + } + } + mergedSubRounds[subIndex].toolCalls = mergedSubToolCalls + } + } else { + mergedSubRounds.append(newSubRound) + } + } + mergedAgentRounds[index].subAgentRounds = mergedSubRounds + } } else { mergedAgentRounds.append(newRound) } @@ -116,4 +248,21 @@ extension ChatMessage { return mergedAgentRounds } + + private func mergeFileEdits(oldEdits: [FileEdit], newEdits: [FileEdit]) -> [FileEdit] { + var edits = oldEdits + + for newEdit in newEdits { + if let index = edits.firstIndex( + where: { $0.fileURL == newEdit.fileURL && $0.toolName == newEdit.toolName } + ) { + edits[index].modifiedContent = newEdit.modifiedContent + edits[index].status = newEdit.status + } else { + edits.append(newEdit) + } + } + + return edits + } } diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 0da9335c..82337095 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -3,6 +3,37 @@ import Foundation import ConversationServiceProvider import GitHubCopilotService +public struct FileEdit: Equatable, Codable { + + public enum Status: String, Codable { + case none = "none" + case kept = "kept" + case undone = "undone" + } + + public let fileURL: URL + public let originalContent: String + public var modifiedContent: String + public var status: Status + + /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file` + public var toolName: ToolName + + public init( + fileURL: URL, + originalContent: String, + modifiedContent: String, + status: Status = .none, + toolName: ToolName + ) { + self.fileURL = fileURL + self.originalContent = originalContent + self.modifiedContent = modifiedContent + self.status = status + self.toolName = toolName + } +} + // move here avoid circular reference public struct ConversationReference: Codable, Equatable, Hashable { public enum Kind: Codable, Equatable, Hashable { @@ -71,6 +102,13 @@ public struct ConversationReference: Codable, Equatable, Hashable { } +public enum RequestType: String, Equatable, Codable { + case conversation, codeReview +} + +public let HardCodedToolRoundExceedErrorMessage: String = "Oops, maximum tool attempts reached. You can update the max tool requests in settings." +public let SSLCertificateErrorMessage: String = "Unable to verify the SSL certificate. This often happens in enterprise environments with custom certificates. Try disabling **Proxy strict SSL** in the Proxy Settings." + public struct ChatMessage: Equatable, Codable { public typealias ID = String @@ -78,6 +116,12 @@ public struct ChatMessage: Equatable, Codable { case user case assistant case system + + public var isAssistant: Bool { self == .assistant } + } + + public enum TurnStatus: String, Codable, Equatable { + case inProgress, success, cancelled, error, waitForConfirmation } /// The role of a message. @@ -117,10 +161,25 @@ public struct ChatMessage: Equatable, Codable { public var editAgentRounds: [AgentRound] + public var parentTurnId: String? + public var panelMessages: [CopilotShowMessageParams] public var codeReviewRound: CodeReviewRound? + /// File edits performed during the current conversation turn. + /// Used as a checkpoint to track file modifications made by tools. + /// Note: Status changes (kept/undone) are tracked separately and not updated here. + public var fileEdits: [FileEdit] + + public var turnStatus: TurnStatus? + + public let requestType: RequestType + + // The model name used for the turn. + public var modelName: String? + public var billingMultiplier: Float? + /// The timestamp of the message. public var createdAt: Date public var updatedAt: Date @@ -139,8 +198,14 @@ public struct ChatMessage: Equatable, Codable { rating: ConversationRating = .unrated, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil, createdAt: Date? = nil, updatedAt: Date? = nil ) { @@ -157,8 +222,14 @@ public struct ChatMessage: Equatable, Codable { self.rating = rating self.steps = steps self.editAgentRounds = editAgentRounds + self.parentTurnId = parentTurnId self.panelMessages = panelMessages self.codeReviewRound = codeReviewRound + self.fileEdits = fileEdits + self.turnStatus = turnStatus + self.requestType = requestType + self.modelName = modelName + self.billingMultiplier = billingMultiplier let now = Date.now self.createdAt = createdAt ?? now @@ -170,7 +241,8 @@ public struct ChatMessage: Equatable, Codable { chatTabId: String, content: String, contentImageReferences: [ImageReference] = [], - references: [ConversationReference] = [] + references: [ConversationReference] = [], + requestType: RequestType = .conversation ) { self.init( id: id, @@ -178,7 +250,8 @@ public struct ChatMessage: Equatable, Codable { role: .user, content: content, contentImageReferences: contentImageReferences, - references: references + references: references, + requestType: requestType ) } @@ -191,7 +264,13 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: String? = nil, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], - codeReviewRound: CodeReviewRound? = nil + parentTurnId: String? = nil, + codeReviewRound: CodeReviewRound? = nil, + fileEdits: [FileEdit] = [], + turnStatus: TurnStatus? = nil, + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.init( id: id, @@ -204,7 +283,13 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: suggestedTitle, steps: steps, editAgentRounds: editAgentRounds, - codeReviewRound: codeReviewRound + parentTurnId: parentTurnId, + codeReviewRound: codeReviewRound, + fileEdits: fileEdits, + turnStatus: turnStatus, + requestType: requestType, + modelName: modelName, + billingMultiplier: billingMultiplier ) } diff --git a/Tool/Sources/Configs/Configurations.swift b/Tool/Sources/Configs/Configurations.swift index 5c6acec3..3cc68a8c 100644 --- a/Tool/Sources/Configs/Configurations.swift +++ b/Tool/Sources/Configs/Configurations.swift @@ -11,3 +11,11 @@ private var bundleIdentifierBase: String { public var userDefaultSuiteName: String { "\(teamIDPrefix)group.\(bundleIdentifierBase).prefs" } + +/// Dedicated preference domain for workspace-level auto-approval. +/// +/// This is intentionally separate from `userDefaultSuiteName` so we can keep +/// auto-approval state isolated from general preferences. +public var autoApprovalUserDefaultSuiteName: String { + "\(teamIDPrefix)group.\(bundleIdentifierBase).autoApproval.prefs" +} diff --git a/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift new file mode 100644 index 00000000..4819cc8f --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Helper class for determining tool enabled state and interaction permissions based on agent mode +public final class AgentModeToolHelpers { + public static func makeConfigurationKey(serverName: String, toolName: String) -> String { + return "\(serverName)/\(toolName)" + } + + /// Determines if a tool should be enabled based on the selected agent mode + public static func isToolEnabledInMode( + configurationKey: String, + currentStatus: ToolStatus, + selectedMode: ConversationMode + ) -> Bool { + // For modes other than default agent mode, check if tool is in customTools list + if !selectedMode.isDefaultAgent { + guard let customTools = selectedMode.customTools else { + // If customTools is nil, no tools are enabled + return false + } + + // If customTools is empty, no tools are enabled + if customTools.isEmpty { + return false + } + + return customTools.contains(configurationKey) + } + + // For built-in modes (Agent, Plan, etc.), use tool's current status + return currentStatus == .enabled + } + + /// Determines if users should be allowed to interact with tool checkboxes + public static func isInteractionAllowed(selectedMode: ConversationMode) -> Bool { + // Allow interaction for built-in "Agent" mode and custom modes + if selectedMode.isDefaultAgent || !selectedMode.isBuiltIn { + return true + } + + // Disable interaction for other built-in modes (like Plan) + return false + } + + private init() {} +} diff --git a/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift index d9e2c7e9..945733b0 100644 --- a/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift +++ b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift @@ -1,6 +1,14 @@ import Foundation import LanguageServerProtocol import GitHelper +import CopilotForXcodeKit + +extension WorkspaceInfo: @retroactive Equatable { + public static func ==(lhs: WorkspaceInfo, rhs: WorkspaceInfo) -> Bool { + return lhs.projectURL == rhs.projectURL + && lhs.workspaceURL == rhs.workspaceURL + } +} public struct CodeReviewRequest: Equatable, Codable { public struct FileChange: Equatable, Codable { @@ -14,6 +22,7 @@ public struct CodeReviewRequest: Equatable, Codable { } public var fileChange: FileChange + public var workspaceInfo: WorkspaceInfo? public var changedFileUris: [DocumentUri] { fileChange.changes.map { $0.uri } } public var selectedFileUris: [DocumentUri] { fileChange.selectedChanges.map { $0.uri } } diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index f97260c4..d16369a7 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -4,31 +4,38 @@ import CodableWrappers import LanguageServerProtocol public protocol ConversationServiceType { - func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws - func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws + func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? + func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? + func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? + func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws - func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult? + func reviewChanges( + workspace: WorkspaceInfo, + changes: [ReviewChangesParams.Change] + ) async throws -> CodeReviewResult? } public protocol ConversationServiceProvider { - func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws - func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws + func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse? + func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse? + func deleteTurn(with conversationId: String, turnId: String, 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 modes() async throws -> [ConversationMode]? func models() async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents() async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws - func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? + func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? } public struct ConversationFileReference: Hashable, Codable, Equatable { @@ -339,6 +346,7 @@ public struct ConversationRequest { public var modelProviderName: String? public var turns: [TurnSchema] public var agentMode: Bool = false + public var customChatModeId: String? = nil public var userLanguage: String? = nil public var turnId: String? = nil @@ -355,6 +363,7 @@ public struct ConversationRequest { modelProviderName: String? = nil, turns: [TurnSchema] = [], agentMode: Bool = false, + customChatModeId: String? = nil, userLanguage: String?, turnId: String? = nil ) { @@ -370,6 +379,7 @@ public struct ConversationRequest { self.modelProviderName = modelProviderName self.turns = turns self.agentMode = agentMode + self.customChatModeId = customChatModeId self.userLanguage = userLanguage self.turnId = turnId } @@ -450,37 +460,3 @@ public struct DidChangeWatchedFilesEvent: Codable { self.changes = changes } } - -public struct AgentRound: Codable, Equatable { - public let roundId: Int - public var reply: String - public var toolCalls: [AgentToolCall]? - - public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = []) { - self.roundId = roundId - self.reply = reply - self.toolCalls = toolCalls - } -} - -public struct AgentToolCall: Codable, Equatable, Identifiable { - public let id: String - public let name: String - public var progressMessage: String? - public var status: ToolCallStatus - public var error: String? - public var invokeParams: InvokeClientToolParams? - - public enum ToolCallStatus: String, Codable { - case waitForConfirmation, accepted, running, completed, error, cancelled - } - - public init(id: String, name: String, progressMessage: String? = nil, status: ToolCallStatus, error: String? = nil, invokeParams: InvokeClientToolParams? = nil) { - self.id = id - self.name = name - self.progressMessage = progressMessage - self.status = status - self.error = error - self.invokeParams = invokeParams - } -} diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift new file mode 100644 index 00000000..69124626 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift @@ -0,0 +1,163 @@ +import CopilotForXcodeKit +import Foundation +import LanguageServerProtocol + +public struct AgentRound: Codable, Equatable { + public let roundId: Int + public var reply: String + public var toolCalls: [AgentToolCall]? + public var subAgentRounds: [AgentRound]? + + public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = [], subAgentRounds: [AgentRound]? = []) { + self.roundId = roundId + self.reply = reply + self.toolCalls = toolCalls + self.subAgentRounds = subAgentRounds + } +} + +public struct AgentToolCall: Codable, Equatable, Identifiable { + public let id: String + public let name: String + public var toolType: ToolType? + public var progressMessage: String? + public var status: ToolCallStatus + public var input: [String: AnyCodable]? + public var inputMessage: String? + public var error: String? + public var result: [ToolCallResultData]? + public var resultDetails: [ToolResultItem]? + public var invokeParams: InvokeClientToolParams? + public var title: String? + + public enum ToolCallStatus: String, Codable { + case waitForConfirmation, accepted, running, completed, error, cancelled + } + + public init( + id: String, + name: String, + toolType: ToolType? = nil, + progressMessage: String? = nil, + status: ToolCallStatus, + input: [String: AnyCodable]? = nil, + inputMessage: String? = nil, + error: String? = nil, + result: [ToolCallResultData]? = nil, + resultDetails: [ToolResultItem]? = nil, + invokeParams: InvokeClientToolParams? = nil, + title: String? = nil + ) { + self.id = id + self.name = name + self.toolType = toolType + self.progressMessage = progressMessage + self.status = status + self.input = input + self.inputMessage = inputMessage + self.error = error + self.result = result + self.resultDetails = resultDetails + self.invokeParams = invokeParams + self.title = title + } + + public var isToolcallingLoopContinueTool: Bool { + self.name == "internal.tool_calling_loop_continue_confirmation" + } +} + +public enum ToolCallResultData: Codable, Equatable { + case text(String) + case data(mimeType: String, data: String) + + private enum CodingKeys: String, CodingKey { + case type, value + } + + private enum ItemType: String, Codable { + case text, data + } + + private struct DataValue: Codable { + let mimeType: String + let data: String + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ItemType.self, forKey: .type) + + switch type { + case .text: + let value = try container.decode(String.self, forKey: .value) + self = .text(value) + case .data: + let value = try container.decode(DataValue.self, forKey: .value) + self = .data(mimeType: value.mimeType, data: value.data) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let string): + try container.encode(ItemType.text, forKey: .type) + try container.encode(string, forKey: .value) + case .data(let mimeType, let data): + try container.encode(ItemType.data, forKey: .type) + try container.encode(DataValue(mimeType: mimeType, data: data), forKey: .value) + } + } +} + +public enum ToolResultItem: Codable, Equatable { + case text(String) + case fileLocation(FileLocation) + + public struct FileLocation: Codable, Equatable { + public let uri: String + public let range: LSPRange + + public init(uri: String, range: LSPRange) { + self.uri = uri + self.range = range + } + } + + private enum CodingKeys: String, CodingKey { + case type, value + } + + private enum ItemType: String, Codable { + case text, fileLocation + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ItemType.self, forKey: .type) + + switch type { + case .text: + let value = try container.decode(String.self, forKey: .value) + self = .text(value) + case .fileLocation: + let value = try container.decode(FileLocation.self, forKey: .value) + self = .fileLocation(value) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let string): + try container.encode(ItemType.text, forKey: .type) + try container.encode(string, forKey: .value) + case .fileLocation(let location): + try container.encode(ItemType.fileLocation, forKey: .type) + try container.encode(location, forKey: .value) + } + } +} diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 125dfd3d..289fcdbd 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -1,6 +1,7 @@ import Foundation import JSONRPC import LanguageServerProtocol +import SuggestionBasic // MARK: Conversation template public struct ChatTemplate: Codable, Equatable { @@ -70,6 +71,71 @@ public struct CopilotModelBilling: Codable, Equatable, Hashable { } } +// MARK: ChatModes +public enum ChatMode: String, Codable { + case Ask = "Ask" + case Edit = "Edit" + case Agent = "Agent" +} + +public struct ConversationMode: Codable, Equatable { + public let id: String + public let name: String + public let kind: ChatMode + public let isBuiltIn: Bool + public let uri: String? + public let description: String? + public let customTools: [String]? + public let model: String? + public let handOffs: [HandOff]? + + public var isDefaultAgent: Bool { id == "Agent" } + + public static let `defaultAgent` = ConversationMode( + id: "Agent", + name: "Agent", + kind: .Agent, + isBuiltIn: true, + description: "Advanced agent mode with access to tools and capabilities" + ) + + public init( + id: String, + name: String, + kind: ChatMode, + isBuiltIn: Bool, + uri: String? = nil, + description: String? = nil, + customTools: [String]? = nil, + model: String? = nil, + handOffs: [HandOff]? = nil + ) { + self.id = id + self.name = name + self.kind = kind + self.isBuiltIn = isBuiltIn + self.uri = uri + self.description = description + self.customTools = customTools + self.model = model + self.handOffs = handOffs + } +} + +public struct HandOff: Codable, Equatable { + public let agent: String + public let label: String + public let prompt: String + public let send: Bool? + + public init(agent: String, label: String, prompt: String, send: Bool?) { + self.agent = agent + self.label = label + self.prompt = prompt + self.send = send + } +} + // MARK: Conversation Agents public struct ChatAgent: Codable, Equatable { public let slug: String @@ -96,9 +162,20 @@ public struct RegisterToolsParams: Codable, Equatable { } public struct UpdateToolsStatusParams: Codable, Equatable { + public let chatModeKind: ChatMode? + public let customChatModeId: String? + public let workspaceFolders: [WorkspaceFolder]? public let tools: [ToolStatusUpdate] - public init(tools: [ToolStatusUpdate]) { + public init( + chatmodeKind: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + tools: [ToolStatusUpdate] + ) { + self.chatModeKind = chatmodeKind + self.customChatModeId = customChatModeId + self.workspaceFolders = workspaceFolders self.tools = tools } } @@ -453,9 +530,11 @@ public struct ReviewChangesParams: Codable, Equatable { } public let changes: [Change] + public let workspaceFolders: [WorkspaceFolder]? - public init(changes: [Change]) { + public init(changes: [Change], workspaceFolders: [WorkspaceFolder]? = nil) { self.changes = changes + self.workspaceFolders = workspaceFolders } } @@ -495,3 +574,165 @@ public struct CodeReviewResult: Codable, Equatable { self.comments = comments } } + + +// MARK: - Conversation / Turn + +public enum ConversationSource: String, Codable { + case panel, inline +} + +public struct FileReference: Codable, Equatable, Hashable { + public var type: String = "file" + public let uri: String + public let position: Position? + public let visibleRange: SuggestionBasic.CursorRange? + public let selection: SuggestionBasic.CursorRange? + public let openedAt: String? + public let activeAt: String? +} + +public struct DirectoryReference: Codable, Equatable, Hashable { + public var type: String = "directory" + public let uri: String +} + +public enum Reference: Codable, Equatable, Hashable { + case file(FileReference) + case directory(DirectoryReference) + + public func encode(to encoder: Encoder) throws { + switch self { + case .file(let fileRef): + try fileRef.encode(to: encoder) + case .directory(let directoryRef): + try directoryRef.encode(to: encoder) + } + } + + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "file": + let fileRef = try FileReference(from: decoder) + self = .file(fileRef) + case "directory": + let directoryRef = try DirectoryReference(from: decoder) + self = .directory(directoryRef) + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown reference type: \(type)" + ) + ) + } + } + + public static func from(_ ref: ConversationAttachedReference) -> Reference { + switch ref { + case .file(let fileRef): + return .file( + .init( + uri: fileRef.url.absoluteString, + position: nil, + visibleRange: nil, + selection: nil, + openedAt: nil, + activeAt: nil + ) + ) + case .directory(let directoryRef): + return .directory(.init(uri: directoryRef.url.absoluteString)) + } + } +} + +public struct ConversationCreateResponse: Codable { + public let conversationId: String + public let turnId: String + public let agentSlug: String? + public let modelName: String? + public let modelProviderName: String? + public let billingMultiplier: Float? +} + +public struct ConversationCreateParams: Codable { + public var workDoneToken: String + public var turns: [TurnSchema] + public var capabilities: Capabilities + public var textDocument: Doc? + public var references: [Reference]? + public var computeSuggestions: Bool? + public var source: ConversationSource? + public var workspaceFolder: String? + public var workspaceFolders: [WorkspaceFolder]? + public var ignoredSkills: [String]? + public var model: String? + public var modelProviderName: String? + public var chatMode: String? + public var customChatModeId: String? + public var needToolCallConfirmation: Bool? + public var userLanguage: String? + + public struct Capabilities: Codable { + public var skills: [String] + public var allSkills: Bool? + + public init(skills: [String], allSkills: Bool? = nil) { + self.skills = skills + self.allSkills = allSkills + } + } + + public init( + workDoneToken: String, + turns: [TurnSchema], + capabilities: Capabilities, + textDocument: Doc? = nil, + references: [Reference]? = nil, + computeSuggestions: Bool? = nil, + source: ConversationSource? = nil, + workspaceFolder: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + ignoredSkills: [String]? = nil, + model: String? = nil, + modelProviderName: String? = nil, + chatMode: String? = nil, + customChatModeId: String? = nil, + needToolCallConfirmation: Bool? = nil, + userLanguage: String? = nil + ) { + self.workDoneToken = workDoneToken + self.turns = turns + self.capabilities = capabilities + self.textDocument = textDocument + self.references = references + self.computeSuggestions = computeSuggestions + self.source = source + self.workspaceFolder = workspaceFolder + self.workspaceFolders = workspaceFolders + self.ignoredSkills = ignoredSkills + self.model = model + self.modelProviderName = modelProviderName + self.chatMode = chatMode + self.customChatModeId = customChatModeId + self.needToolCallConfirmation = needToolCallConfirmation + self.userLanguage = userLanguage + } +} + +// MARK: - ConversationErrorCode +public enum ConversationErrorCode: Int { + // -1: Unknown error, used when the error may not be user friendly. + case unknown = -1 + // 0: Default error code, for backward compatibility with Copilot Chat. + case `default` = 0 + case toolRoundExceedError = 10000 +} diff --git a/Tool/Sources/ConversationServiceProvider/PromptType.swift b/Tool/Sources/ConversationServiceProvider/PromptType.swift new file mode 100644 index 00000000..6a896746 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/PromptType.swift @@ -0,0 +1,123 @@ +import Foundation + +public enum PromptType: String, CaseIterable, Equatable { + case instructions = "instructions" + case prompt = "prompt" + case agent = "agent" + + /// The directory name under .github where files of this type are stored + public var directoryName: String { + switch self { + case .instructions: + return "instructions" + case .prompt: + return "prompts" + case .agent: + return "agents" + } + } + + /// The file extension for this prompt type + public var fileExtension: String { + switch self { + case .instructions: + return ".instructions.md" + case .prompt: + return ".prompt.md" + case .agent: + return ".agent.md" + } + } + + /// Human-readable name for display purposes + public var displayName: String { + switch self { + case .instructions: + return "Instruction File" + case .prompt: + return "Prompt File" + case .agent: + return "Agent File" + } + } + + /// Human-readable name for settings + public var settingTitle: String { + switch self { + case .instructions: + return "Custom Instructions" + case .prompt: + return "Prompt Files" + case .agent: + return "Agent Files" + } + } + + /// Description for the prompt type + public var description: String { + switch self { + case .instructions: + return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks." + case .prompt: + return "Configure `.github/prompts/*.prompt.md` files for reusable prompts. Trigger with '/' commands in the Chat view." + case .agent: + return "Configure `.github/agents/*.agent.md` files for autonomous agent tasks. Agents can perform multi-step operations." + } + } + + /// Default template content for new files + public var defaultTemplate: String { + switch self { + case .instructions: + return """ + --- + applyTo: '**' + --- + Provide project context and coding guidelines that AI should follow when generating code, or answering questions. + + """ + case .prompt: + return """ + --- + description: Prompt Description + --- + Define the task to achieve, including specific requirements, constraints, and success criteria. + + """ + case .agent: + return """ + --- + description: 'Describe what this custom agent does and when to use it.' + tools: [] + --- + Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help. + + """ + } + } + + /// Get the help link for this prompt type. Requires the editor plugin version string. + public func helpLink(editorPluginVersion: String) -> String { + let version = editorPluginVersion == "0.0.0" ? "main" : editorPluginVersion + + switch self { + case .instructions: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/CustomInstructions.md" + case .prompt: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/PromptFiles.md" + case .agent: + return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/AgentFiles.md" + } + } + + /// Get the full file path for a given name and project URL + public func getFilePath(fileName: String, projectURL: URL) -> URL { + let directory = getDirectoryPath(projectURL: projectURL) + return directory.appendingPathComponent("\(fileName)\(fileExtension)") + } + + /// Get the directory path for this prompt type + public func getDirectoryPath(projectURL: URL) -> URL { + return projectURL.appendingPathComponent(".github/\(directoryName)") + } +} diff --git a/Tool/Sources/ConversationServiceProvider/ToolNames.swift b/Tool/Sources/ConversationServiceProvider/ToolNames.swift index 7b9d12c9..8ee3e077 100644 --- a/Tool/Sources/ConversationServiceProvider/ToolNames.swift +++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift @@ -1,5 +1,5 @@ -public enum ToolName: String { +public enum ToolName: String, Codable { case runInTerminal = "run_in_terminal" case getTerminalOutput = "get_terminal_output" case getErrors = "get_errors" @@ -7,3 +7,16 @@ public enum ToolName: String { case createFile = "create_file" case fetchWebPage = "fetch_webpage" } + +public enum ServerToolName: String, Codable { + case readFile = "read_file" + case findFiles = "file_search" + case findTextInFiles = "grep_search" + case listDir = "list_dir" + case replaceString = "replace_string_in_file" + case codebase = "semantic_search" +} + +public enum CopilotToolName: String, Codable { + case readFile = "copilot.read_file" +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift new file mode 100644 index 00000000..977396c4 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift @@ -0,0 +1,293 @@ +import AppKit +import Combine +import Foundation +import JSONRPC +import LanguageServerProtocol +import Logger + +public protocol DynamicOAuthRequestHandler { + func handleDynamicOAuthRequest( + _ request: DynamicOAuthRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void + ) +} + +public final class DynamicOAuthRequestHandlerImpl: NSObject, DynamicOAuthRequestHandler { + public static let shared = DynamicOAuthRequestHandlerImpl() + + // MARK: - Constants + + private enum LayoutConstants { + static let containerWidth: CGFloat = 450 + static let fieldWidth: CGFloat = 330 + static let labelWidth: CGFloat = 100 + static let labelX: CGFloat = 4 + static let fieldX: CGFloat = 100 + + static let spacing: CGFloat = 8 + static let hintSpacing: CGFloat = 4 + static let labelHeight: CGFloat = 17 + static let fieldHeight: CGFloat = 28 + static let labelVerticalOffset: CGFloat = 6 + + static let hintFontSize: CGFloat = 11 + static let regularFontSize: CGFloat = 13 + } + + private enum Strings { + static let clientIdLabel = "Client ID *" + static let clientSecretLabel = "Client Secret" + static let clientIdPlaceholder = "OAuth client ID (azye39d...)" + static let clientSecretPlaceholder = "OAuth client secret (wer32o50f...) or leave it blank" + static let okButton = "OK" + static let cancelButton = "Cancel" + } + + public func handleDynamicOAuthRequest( + _ request: DynamicOAuthRequest, + completion: @escaping (AnyJSONRPCResponse) -> Void + ) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received Dynamic OAuth Request: \(params)") + Task { @MainActor in + let response = self.dynamicOAuthRequestAlert(params) + let jsonResult = try? JSONEncoder().encode(response) + let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null + completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) + } + } + + @MainActor + func dynamicOAuthRequestAlert(_ params: DynamicOAuthParams) -> DynamicOAuthResponse? { + let alert = configureAlert(with: params) + let (clientIdField, clientSecretField) = createAccessoryView(for: alert, params: params) + + let modalResponse = alert.runModal() + + return handleAlertResponse( + modalResponse, + clientIdField: clientIdField, + clientSecretField: clientSecretField + ) + } + + // MARK: - Alert Configuration + + @MainActor + private func configureAlert(with params: DynamicOAuthParams) -> NSAlert { + let alert = NSAlert() + alert.messageText = params.header ?? params.title + alert.informativeText = params.detail + alert.alertStyle = .warning + alert.addButton(withTitle: Strings.okButton) + alert.addButton(withTitle: Strings.cancelButton) + return alert + } + + // MARK: - Accessory View Creation + + @MainActor + private func createAccessoryView( + for alert: NSAlert, + params: DynamicOAuthParams + ) -> (clientIdField: NSTextField, clientSecretField: NSSecureTextField) { + let (clientIdHint, clientIdHintHeight) = createHintLabel( + text: params.inputs.first(where: { $0.value == "clientId" })?.description ?? "" + ) + + let (clientSecretHint, clientSecretHintHeight) = createHintLabel( + text: params.inputs.first(where: { $0.value == "clientSecret" })?.description ?? "" + ) + + let totalHeight = calculateTotalHeight( + clientIdHintHeight: clientIdHintHeight, + clientSecretHintHeight: clientSecretHintHeight + ) + + let containerView = NSView(frame: NSRect( + x: 0, + y: 0, + width: LayoutConstants.containerWidth, + height: totalHeight + )) + + let clientIdField = NSTextField() + let clientSecretField = NSSecureTextField() + + layoutComponents( + in: containerView, + clientIdField: clientIdField, + clientSecretField: clientSecretField, + clientIdHint: clientIdHint, + clientSecretHint: clientSecretHint, + clientIdHintHeight: clientIdHintHeight, + clientSecretHintHeight: clientSecretHintHeight, + params: params + ) + + alert.accessoryView = containerView + + return (clientIdField, clientSecretField) + } + + // MARK: - Component Creation + + @MainActor + private func createHintLabel(text: String) -> (label: NSTextField, height: CGFloat) { + let hint = NSTextField(wrappingLabelWithString: text) + hint.font = NSFont.systemFont(ofSize: LayoutConstants.hintFontSize) + hint.textColor = NSColor.secondaryLabelColor + let height = hint.sizeThatFits(NSSize( + width: LayoutConstants.fieldWidth, + height: CGFloat.greatestFiniteMagnitude + )).height + return (hint, height) + } + + @MainActor + private func createInputField(placeholder: String) -> NSTextField { + let field = NSTextField() + field.placeholderString = placeholder + field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + field.isEditable = true + return field + } + + @MainActor + private func createSecureField(placeholder: String) -> NSSecureTextField { + let field = NSSecureTextField() + field.placeholderString = placeholder + field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + field.isEditable = true + return field + } + + @MainActor + private func createLabel(text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize) + label.alignment = .left + return label + } + + // MARK: - Layout + + private func calculateTotalHeight( + clientIdHintHeight: CGFloat, + clientSecretHintHeight: CGFloat + ) -> CGFloat { + return clientSecretHintHeight + LayoutConstants.hintSpacing + LayoutConstants.fieldHeight + + LayoutConstants.spacing + clientIdHintHeight + LayoutConstants.hintSpacing + + LayoutConstants.fieldHeight + } + + @MainActor + private func layoutComponents( + in containerView: NSView, + clientIdField: NSTextField, + clientSecretField: NSSecureTextField, + clientIdHint: NSTextField, + clientSecretHint: NSTextField, + clientIdHintHeight: CGFloat, + clientSecretHintHeight: CGFloat, + params: DynamicOAuthParams + ) { + var currentY: CGFloat = 0 + + // Client Secret section (bottom) + layoutFieldSection( + in: containerView, + field: clientSecretField, + label: createLabel(text: Strings.clientSecretLabel), + hint: clientSecretHint, + hintHeight: clientSecretHintHeight, + placeholder: params.inputs.first(where: { $0.value == "clientSecret" })?.placeholder ?? Strings.clientSecretPlaceholder, + currentY: ¤tY, + isLastSection: false + ) + + // Client ID section (top) + layoutFieldSection( + in: containerView, + field: clientIdField, + label: createLabel(text: Strings.clientIdLabel), + hint: clientIdHint, + hintHeight: clientIdHintHeight, + placeholder: params.inputs.first(where: { $0.value == "clientId" })?.placeholder ?? Strings.clientIdPlaceholder, + currentY: ¤tY, + isLastSection: true + ) + } + + @MainActor + private func layoutFieldSection( + in containerView: NSView, + field: NSTextField, + label: NSTextField, + hint: NSTextField, + hintHeight: CGFloat, + placeholder: String, + currentY: inout CGFloat, + isLastSection: Bool + ) { + // Position hint + hint.frame = NSRect( + x: LayoutConstants.fieldX, + y: currentY, + width: LayoutConstants.fieldWidth, + height: hintHeight + ) + currentY += hintHeight + LayoutConstants.hintSpacing + + // Position field + field.frame = NSRect( + x: LayoutConstants.fieldX, + y: currentY, + width: LayoutConstants.fieldWidth, + height: LayoutConstants.fieldHeight + ) + field.placeholderString = placeholder + + // Position label + label.frame = NSRect( + x: LayoutConstants.labelX, + y: currentY + LayoutConstants.labelVerticalOffset, + width: LayoutConstants.labelWidth, + height: LayoutConstants.labelHeight + ) + + // Add to container + containerView.addSubview(label) + containerView.addSubview(field) + containerView.addSubview(hint) + + if !isLastSection { + currentY += LayoutConstants.fieldHeight + LayoutConstants.spacing + } + } + + // MARK: - Response Handling + + private func handleAlertResponse( + _ response: NSApplication.ModalResponse, + clientIdField: NSTextField, + clientSecretField: NSSecureTextField + ) -> DynamicOAuthResponse? { + guard response == .alertFirstButtonReturn else { + return nil + } + + let clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clientId.isEmpty else { + Logger.gitHubCopilot.info("Client ID is required but was not provided") + return nil + } + + let clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + + return DynamicOAuthResponse( + clientId: clientId, + clientSecret: clientSecret + ) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift deleted file mode 100644 index 47ea5017..00000000 --- a/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift +++ /dev/null @@ -1,67 +0,0 @@ -import JSONRPC -import Foundation -import Combine -import Logger -import AppKit - -public protocol MCPOAuthRequestHandler { - func handleShowOAuthMessage( - _ request: MCPOAuthRequest, - completion: @escaping ( - AnyJSONRPCResponse - ) -> Void - ) -} - -public final class MCPOAuthRequestHandlerImpl: MCPOAuthRequestHandler { - public static let shared = MCPOAuthRequestHandlerImpl() - - public func handleShowOAuthMessage(_ request: MCPOAuthRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { - guard let params = request.params else { return } - Logger.gitHubCopilot.debug("Received MCP OAuth Request: \(params)") - Task { @MainActor in - let confirmResult = showMCPOAuthAlert(params) - let jsonResult = try? JSONEncoder().encode(MCPOAuthResponse(confirm: confirmResult)) - let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null - completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) - } - } - - @MainActor - func showMCPOAuthAlert(_ params: MCPOAuthRequestParams) -> Bool { - let alert = NSAlert() - let mcpConfigString = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig) - - var serverName = params.mcpServer // Default fallback - - if let mcpConfigData = mcpConfigString.data(using: .utf8), - let mcpConfig = try? JSONDecoder().decode(JSONValue.self, from: mcpConfigData) { - // Iterate through the servers to find a match for the mcpServer URL - if case .hash(let serversDict) = mcpConfig { - for (userDefinedName, serverConfig) in serversDict { - if let url = serverConfig["url"]?.stringValue { - // Check if the mcpServer URL matches the configured URL - if params.mcpServer.contains(url) || url.contains(params.mcpServer) { - serverName = userDefinedName - break - } - } - } - } - } - - alert.messageText = "GitHub Copilot" - alert.informativeText = "The MCP Server Definition '\(serverName)' wants to authenticate to \(params.authLabel)." - alert.alertStyle = .informational - - alert.addButton(withTitle: "Continue") - alert.addButton(withTitle: "Cancel") - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - return true - } else { - return false - } - } -} diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift index cf137aa3..ad2de6a7 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift @@ -1,22 +1,118 @@ import JSONRPC +import Foundation import Combine +import Logger +import AppKit +import LanguageServerProtocol +import UserNotifications public protocol ShowMessageRequestHandler { - var onShowMessage: PassthroughSubject<(ShowMessageRequest, (AnyJSONRPCResponse) -> Void), Never> { get } - func handleShowMessage( + func handleShowMessageRequest( _ request: ShowMessageRequest, - completion: @escaping ( - AnyJSONRPCResponse - ) -> Void + callback: @escaping @Sendable (Result>) async -> Void ) } -public final class ShowMessageRequestHandlerImpl: ShowMessageRequestHandler { +public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHandler, UNUserNotificationCenterDelegate { public static let shared = ShowMessageRequestHandlerImpl() - public let onShowMessage: PassthroughSubject<(ShowMessageRequest, (AnyJSONRPCResponse) -> Void), Never> = .init() + private var isNotificationSetup = false + + private override init() { + super.init() + } + + @MainActor + private func setupNotificationCenterIfNeeded() async { + guard !isNotificationSetup else { return } + guard Bundle.main.bundleIdentifier != nil else { + // Skip notification setup in test environment + return + } + + isNotificationSetup = true + UNUserNotificationCenter.current().delegate = self + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) + } - public func handleShowMessage(_ request: ShowMessageRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) { - onShowMessage.send((request, completion)) + public func handleShowMessageRequest( + _ request: ShowMessageRequest, + callback: @escaping @Sendable (Result>) async -> Void + ) { + guard let params = request.params else { return } + Logger.gitHubCopilot.debug("Received Show Message Request: \(params)") + Task { @MainActor in + await setupNotificationCenterIfNeeded() + + let actionCount = params.actions?.count ?? 0 + + // Use notification for messages with no action, alert for messages with actions + if actionCount == 0 { + await showMessageRequestNotification(params) + await callback(.success(nil)) + } else { + let selectedAction = showMessageRequestAlert(params) + await callback(.success(selectedAction)) + } + } + } + + @MainActor + func showMessageRequestNotification(_ params: ShowMessageRequestParams) async { + let content = UNMutableNotificationContent() + content.title = "GitHub Copilot for Xcode" + content.body = params.message + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Logger.gitHubCopilot.error("Failed to show notification: \(error)") + } + } + + @MainActor + func showMessageRequestAlert(_ params: ShowMessageRequestParams) -> MessageActionItem? { + let alert = NSAlert() + + alert.messageText = "GitHub Copilot" + alert.informativeText = params.message + alert.alertStyle = params.type == .info ? .informational : .warning + + let actions = params.actions ?? [] + for item in actions { + alert.addButton(withTitle: item.title) + } + + let response = alert.runModal() + + // Map the button response to the corresponding action + // .alertFirstButtonReturn = 1000, .alertSecondButtonReturn = 1001, etc. + let buttonIndex = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue + + guard buttonIndex >= 0 && buttonIndex < actions.count else { + return nil + } + + return actions[buttonIndex] + } + + // MARK: - UNUserNotificationCenterDelegate + + // This method is called when a notification is delivered while the app is in the foreground + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show the notification banner even when app is in foreground + completionHandler([.banner, .list, .badge, .sound]) } } diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift index f450935e..73069562 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift @@ -29,26 +29,40 @@ public final class WatchedFilesHandlerImpl: WatchedFilesHandler { let fileUris = files.prefix(10000).map { $0.url.absoluteString } // Set max number of indexing file to 10000 let batchSize = BatchingFileChangeWatcher.maxEventPublishSize - /// only `batchSize`(100) files to complete this event for setup watching workspace in CLS side - let jsonResult: JSONValue = .array(fileUris.prefix(batchSize).map { .hash(["uri": .string($0)]) }) - let jsonValue: JSONValue = .hash(["files": jsonResult]) - - completion(AnyJSONRPCResponse(id: request.id, result: jsonValue)) Task { - if fileUris.count > batchSize { - for startIndex in stride(from: batchSize, to: fileUris.count, by: batchSize) { + var sentCount = 0 + if params.partialResultToken != nil && fileUris.count > batchSize { + for startIndex in stride(from: 0, to: fileUris.count, by: batchSize) { let endIndex = min(startIndex + batchSize, fileUris.count) - let batch = Array(fileUris[startIndex.. ProgressParams? { + let copilotProgress = CopilotProgressParams(token: token, value: value) + + if let jsonData = try? JSONEncoder().encode(copilotProgress), + let progressParams = try? JSONDecoder().decode(ProgressParams.self, from: jsonData) { + return progressParams + } + return nil + } +} diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 119278ee..b1f89d95 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -42,7 +42,7 @@ public final class GitHubCopilotExtension: BuiltinExtension { public func workspaceDidClose(_: WorkspaceInfo) {} - public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) { + public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) async { guard isLanguageServerInUse else { return } // check if file size is larger than 15MB, if so, return immediately if let attrs = try? FileManager.default @@ -51,21 +51,19 @@ public final class GitHubCopilotExtension: BuiltinExtension { fileSize > 15 * 1024 * 1024 { return } - Task { - let content: String - do { - content = try String(contentsOf: documentURL, encoding: .utf8) - } catch { - Logger.extension.info("Failed to read \(documentURL.lastPathComponent): \(error)") - return - } - - do { - guard let service = await serviceLocator.getService(from: workspace) else { return } - try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) - } catch { - Logger.gitHubCopilot.info(error.localizedDescription) - } + let content: String + do { + content = try String(contentsOf: documentURL, encoding: .utf8) + } catch { + Logger.extension.info("Failed to read \(documentURL.lastPathComponent): \(error)") + return + } + + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch { + Logger.gitHubCopilot.info(error.localizedDescription) } } @@ -96,8 +94,9 @@ public final class GitHubCopilotExtension: BuiltinExtension { public func workspace( _ workspace: WorkspaceInfo, didUpdateDocumentAt documentURL: URL, - content: String? - ) { + content: String?, + contentChanges: [TextDocumentContentChangeEvent]? = nil + ) async { guard isLanguageServerInUse else { return } // check if file size is larger than 15MB, if so, return immediately if let attrs = try? FileManager.default @@ -106,27 +105,26 @@ public final class GitHubCopilotExtension: BuiltinExtension { fileSize > 15 * 1024 * 1024 { return } - Task { - guard let content else { return } - guard let service = await serviceLocator.getService(from: workspace) else { return } - do { - try await service.notifyChangeTextDocument( - fileURL: documentURL, - content: content, - version: 0 - ) - } catch let error as ServerError { - switch error { - case .serverError(-32602, _, _): // parameter incorrect - Logger.gitHubCopilot.error(error.localizedDescription) - // Reopen document if it's not found in the language server - self.workspace(workspace, didOpenDocumentAt: documentURL) - default: - Logger.gitHubCopilot.info(error.localizedDescription) - } - } catch { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + do { + try await service.notifyChangeTextDocument( + fileURL: documentURL, + content: content, + version: 0, + contentChanges: contentChanges + ) + } catch let error as ServerError { + switch error { + case .serverError(-32602, _, _): // parameter incorrect + Logger.gitHubCopilot.error(error.localizedDescription) + // Reopen document if it's not found in the language server + await self.workspace(workspace, didOpenDocumentAt: documentURL) + default: Logger.gitHubCopilot.info(error.localizedDescription) } + } catch { + Logger.gitHubCopilot.info(error.localizedDescription) } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift index 554a4bfe..2c530455 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift @@ -5,6 +5,8 @@ import Logger public extension Notification.Name { static let gitHubCopilotToolsDidChange = Notification .Name("com.github.CopilotForXcode.CopilotToolsDidChange") + static let gitHubCopilotCustomAgentToolsDidChange = Notification + .Name("com.github.CopilotForXcode.CustomAgentToolsDidChange") } public class CopilotLanguageModelToolManager { @@ -24,8 +26,8 @@ public class CopilotLanguageModelToolManager { } // Map previous and new by name for merging. - let previousByName = Dictionary(uniqueKeysWithValues: previous.map { ($0.name, $0) }) - let incomingByName = Dictionary(uniqueKeysWithValues: tools.map { ($0.name, $0) }) + let previousByName = Dictionary(previous.map { ($0.name, $0) }) { first, _ in first } + let incomingByName = Dictionary(tools.map { ($0.name, $0) }) { first, _ in first } var merged: [LanguageModelTool] = [] diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 29e33d35..7b380443 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -1,4 +1,5 @@ import Combine +import ConversationServiceProvider import Foundation import JSONRPC import LanguageClient @@ -29,12 +30,37 @@ public enum ServerError: LocalizedError { message: error.message, data: error.data) } + + static func decodingError(_ error: DecodingError) -> ServerError { + let message: String + + switch error { + case .typeMismatch(let type, let context): + message = "Type mismatch: Expected \(type). \(context.debugDescription)" + + case .valueNotFound(let type, let context): + message = "Value not found: Expected \(type). \(context.debugDescription)" + + case .keyNotFound(let key, let context): + message = "Key '\(key.stringValue)' not found. \(context.debugDescription)" + + case .dataCorrupted(let context): + message = "Data corrupted: \(context.debugDescription)" + + @unknown default: + message = error.localizedDescription + } + + return ServerError.serverError(code: -32700, message: message, data: nil) + } static func convertToServerError(error: any Error) -> ServerError { if let serverError = error as? ServerError { return serverError } else if let jsonRPCError = error as? AnyJSONRPCResponseError { return responseError(jsonRPCError) + } else if let decodeError = error as? DecodingError { + return decodingError(decodeError) } return .unknownError(error) @@ -114,7 +140,7 @@ class CopilotLocalProcessServer { return } - if request.method == "getCompletionsCycling" { + if request.method == "getCompletionsCycling" || request.method == "textDocument/copilotInlineEdit" { Task { @MainActor [weak self] in self?.ongoingCompletionRequestIDs.append(request.id) } @@ -196,6 +222,9 @@ class CopilotLocalProcessServer { case "copilot/mcpRuntimeLogs": notificationPublisher.send(anyNotification) return true + case "policy/didChange": + notificationPublisher.send(anyNotification) + return true case "conversation/preconditionsNotification", "statusNotification": // Ignore return true @@ -237,13 +266,17 @@ extension CopilotLocalProcessServer: ServerConnection { let method = notif.method.rawValue - switch notif { - case .copilotDidChangeWatchedFiles(let params): - do { + do { + switch notif { + case .copilotDidChangeWatchedFiles(let params): + try await server.sendNotification(params, method: method) + case .clientProtocolProgress(let params): + try await server.sendNotification(params, method: method) + case .textDocumentDidShowInlineEdit(let params): try await server.sendNotification(params, method: method) - } catch { - throw ServerError.unableToSendNotification(error) } + } catch { + throw ServerError.unableToSendNotification(error) } } @@ -321,14 +354,22 @@ public struct CopilotDidChangeWatchedFilesParams: Codable, Hashable { public enum CopilotClientNotification { public enum Method: String { case workspaceDidChangeWatchedFiles = "workspace/didChangeWatchedFiles" + case protocolProgress = "$/progress" + case textDocumentDidShowInlineEdit = "textDocument/didShowInlineEdit" } case copilotDidChangeWatchedFiles(CopilotDidChangeWatchedFilesParams) + case clientProtocolProgress(ProgressParams) + case textDocumentDidShowInlineEdit(TextDocumentDidShowInlineEditParams) public var method: Method { switch self { case .copilotDidChangeWatchedFiles: return .workspaceDidChangeWatchedFiles + case .clientProtocolProgress: + return .protocolProgress + case .textDocumentDidShowInlineEdit: + return .textDocumentDidShowInlineEdit } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift index a2baecbc..9533e367 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -15,7 +15,12 @@ public class CopilotMCPToolManager { availableMCPServerTools = sortedMCPServerTools DispatchQueue.main.async { Logger.client.info("Notify about MCP tools change: \(getToolsSummary())") - DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotMCPToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) } } @@ -23,7 +28,7 @@ public class CopilotMCPToolManager { var summary = "" guard let tools = availableMCPServerTools else { return summary } for server in tools { - summary += "Server: \(server.name) with \(server.tools.count) tools (\(server.tools.filter { $0._status == .enabled }.count) enabled, \(server.tools.filter { $0._status == .disabled }.count) disabled). " + summary += "Server: \(server.name) \(server.status), with \(server.tools.count) tools (\(server.tools.filter { $0._status == .enabled }.count) enabled, \(server.tools.filter { $0._status == .disabled }.count) disabled). " } return summary @@ -45,7 +50,12 @@ public class CopilotMCPToolManager { public static func clearMCPTools() { availableMCPServerTools = [] DispatchQueue.main.async { - DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil) + DistributedNotificationCenter.default().postNotificationName( + .gitHubCopilotMCPToolsDidChange, + object: nil, + userInfo: nil, + deliverImmediately: true + ) } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 0d89df9c..0ada31e5 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -1,9 +1,10 @@ +import ConversationServiceProvider import Foundation import JSONRPC import LanguageServerProtocol +import Preferences import Status import SuggestionBasic -import ConversationServiceProvider struct GitHubCopilotDoc: Codable { var source: String @@ -80,7 +81,7 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { var authProvider: JSONValue? { let enterpriseURI = UserDefaults.shared.value(for: \.gitHubCopilotEnterpriseURI) - return .hash([ "uri": .string(enterpriseURI) ]) + return .hash(["uri": .string(enterpriseURI)]) } var mcp: JSONValue? { @@ -92,6 +93,76 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { let instructions = UserDefaults.shared.value(for: \.globalCopilotInstructions) return .string(instructions) } + + var agent: JSONValue? { + var d: [String: JSONValue] = [:] + + let agentMaxToolCallingLoop = Double(UserDefaults.shared.value(for: \.agentMaxToolCallingLoop)) + d["maxToolCallingLoop"] = .number(agentMaxToolCallingLoop) + + // Auto Approval Settings + // Disable auto approval (yolo mode) + let enableAutoApproval = false + d["toolConfirmAutoApprove"] = .bool(enableAutoApproval) + + let trustToolAnnotations = UserDefaults.shared.value(for: \.trustToolAnnotations) + d["trustToolAnnotations"] = .bool(trustToolAnnotations) + + let state = UserDefaults.autoApproval.value(for: \.sensitiveFilesGlobalApprovals) + var autoApproveList: [JSONValue] = [] + for (key, rule) in state.rules { + let item: [String: JSONValue] = [ + "pattern": .string(key), + "autoApprove": .bool(rule.autoApprove), + "description": .string(rule.description) + ] + autoApproveList.append(.hash(item)) + } + + var tools: [String: JSONValue] = [:] + + if !autoApproveList.isEmpty { + tools["edit"] = .hash([ + "autoApprove": .array(autoApproveList) + ]) + } + + let mcpGlobalApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals) + var mcpAutoApproveList: [JSONValue] = [] + + for (serverName, state) in mcpGlobalApprovals.servers { + let item: [String: JSONValue] = [ + "serverName": .string(serverName), + "isServerAllowed": .bool(state.isServerAllowed), + "allowedTools": .array(state.allowedTools.map { .string($0) }) + ] + mcpAutoApproveList.append(.hash(item)) + } + + if !mcpAutoApproveList.isEmpty { + tools["mcp"] = .hash([ + "autoApprove": .array(mcpAutoApproveList) + ]) + } + + let terminalState = UserDefaults.autoApproval.value(for: \.terminalCommandsGlobalApprovals) + var terminalAutoApprove: [String: JSONValue] = [:] + for (command, approved) in terminalState.commands { + terminalAutoApprove[command] = .bool(approved) + } + + if !terminalAutoApprove.isEmpty { + tools["terminal"] = .hash([ + "autoApprove": .hash(terminalAutoApprove) + ]) + } + + if !tools.isEmpty { + d["tools"] = .hash(tools) + } + + return .hash(d) + } var d: [String: JSONValue] = [:] if let http { d["http"] = http } @@ -103,6 +174,7 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { copilot["mcp"] = mcp } copilot["globalCopilotInstructions"] = customInstructions + copilot["agent"] = agent github["copilot"] = .hash(copilot) d["github"] = .hash(github) } @@ -135,7 +207,7 @@ enum GitHubCopilotRequest { .custom("checkStatus", .hash([:]), ClientRequest.NullHandler) } } - + struct CheckQuota: GitHubCopilotRequestType { typealias Response = GitHubCopilotQuotaInfo @@ -281,6 +353,20 @@ enum GitHubCopilotRequest { ]), ClientRequest.NullHandler) } } + + // MARK: - NES + + struct CopilotInlineEdit: GitHubCopilotRequestType { + typealias Response = CopilotInlineEditsResponse + + var params: CopilotInlineEditsParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("textDocument/copilotInlineEdit", dict, ClientRequest.NullHandler) + } + } struct NotifyShown: GitHubCopilotRequestType { struct Response: Codable {} @@ -312,6 +398,21 @@ enum GitHubCopilotRequest { return .custom("notifyAccepted", .hash(dict), ClientRequest.NullHandler) } } + + struct NotifyCopilotInlineEditAccepted: GitHubCopilotRequestType { + typealias Response = Bool + + // NES suggestion ID + var params: [String] + + var request: ClientRequest { + let args: [JSONValue] = params.map { JSONValue.string($0) } + return .workspaceExecuteCommand( + .init(command: "github.copilot.didAcceptNextEditSuggestionItem", arguments: args), + ClientRequest.NullHandler + ) + } + } struct NotifyRejected: GitHubCopilotRequestType { struct Response: Codable {} @@ -328,7 +429,7 @@ enum GitHubCopilotRequest { // MARK: Conversation struct CreateConversation: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: ConversationCreateParams @@ -342,7 +443,7 @@ enum GitHubCopilotRequest { // MARK: Conversation turn struct CreateTurn: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: TurnCreateParams @@ -353,6 +454,18 @@ enum GitHubCopilotRequest { } } + struct DeleteTurn: GitHubCopilotRequestType { + struct Response: Codable {} + + var params: TurnDeleteParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/turnDelete", dict, ClientRequest.NullHandler) + } + } + // MARK: Conversation rating struct ConversationRating: GitHubCopilotRequestType { @@ -366,12 +479,12 @@ enum GitHubCopilotRequest { return .custom("conversation/rating", dict, ClientRequest.NullHandler) } } - + // MARK: Conversation templates struct GetTemplates: GitHubCopilotRequestType { typealias Response = Array - + var params: ConversationTemplatesParams var request: ClientRequest { @@ -381,6 +494,22 @@ enum GitHubCopilotRequest { } } + // MARK: Conversation Modes + + struct GetModes: GitHubCopilotRequestType { + typealias Response = Array + + var params: ConversationModesParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("conversation/modes", dict, ClientRequest.NullHandler) + } + } + + // MARK: Copilot Models + struct CopilotModels: GitHubCopilotRequestType { typealias Response = Array @@ -388,12 +517,12 @@ enum GitHubCopilotRequest { .custom("copilot/models", .hash([:]), ClientRequest.NullHandler) } } - + // MARK: MCP Tools - + struct UpdatedMCPToolsStatus: GitHubCopilotRequestType { typealias Response = Array - + var params: UpdateMCPToolsStatusParams var request: ClientRequest { @@ -402,35 +531,43 @@ enum GitHubCopilotRequest { return .custom("mcp/updateToolsStatus", dict, ClientRequest.NullHandler) } } - + // MARK: MCP Registry - + struct MCPRegistryListServers: GitHubCopilotRequestType { typealias Response = MCPRegistryServerList - + var params: MCPRegistryListServersParams - + var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("mcp/registry/listServers", dict, ClientRequest.NullHandler) } } - + struct MCPRegistryGetServer: GitHubCopilotRequestType { typealias Response = MCPRegistryServerDetail - + var params: MCPRegistryGetServerParams - + var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) return .custom("mcp/registry/getServer", dict, ClientRequest.NullHandler) } } - + + struct MCPRegistryGetAllowlist: GitHubCopilotRequestType { + typealias Response = GetMCPRegistryAllowlistResult + + var request: ClientRequest { + .custom("mcp/registry/getAllowlist", .hash([:]), ClientRequest.NullHandler) + } + } + // MARK: - Conversation Agents - + struct GetAgents: GitHubCopilotRequestType { typealias Response = Array @@ -438,14 +575,14 @@ enum GitHubCopilotRequest { .custom("conversation/agents", .hash([:]), ClientRequest.NullHandler) } } - + // MARK: - Code Review - + struct ReviewChanges: GitHubCopilotRequestType { typealias Response = CodeReviewResult - + var params: ReviewChangesParams - + var request: ClientRequest { let data = (try? JSONEncoder().encode(params)) ?? Data() let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) @@ -464,7 +601,7 @@ enum GitHubCopilotRequest { return .custom("conversation/registerTools", dict, ClientRequest.NullHandler) } } - + struct UpdateToolsStatus: GitHubCopilotRequestType { typealias Response = Array @@ -490,7 +627,7 @@ enum GitHubCopilotRequest { return .custom("conversation/copyCode", dict, ClientRequest.NullHandler) } } - + // MARK: Telemetry struct TelemetryException: GitHubCopilotRequestType { @@ -504,11 +641,12 @@ enum GitHubCopilotRequest { return .custom("telemetry/exception", dict, ClientRequest.NullHandler) } } - + // MARK: BYOK + struct BYOKSaveModel: GitHubCopilotRequestType { typealias Response = BYOKSaveModelResponse - + var params: BYOKSaveModelParams var request: ClientRequest { @@ -517,10 +655,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/saveModel", dict, ClientRequest.NullHandler) } } - + struct BYOKDeleteModel: GitHubCopilotRequestType { typealias Response = BYOKDeleteModelResponse - + var params: BYOKDeleteModelParams var request: ClientRequest { @@ -529,10 +667,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/deleteModel", dict, ClientRequest.NullHandler) } } - + struct BYOKListModels: GitHubCopilotRequestType { typealias Response = BYOKListModelsResponse - + var params: BYOKListModelsParams var request: ClientRequest { @@ -541,10 +679,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/listModels", dict, ClientRequest.NullHandler) } } - + struct BYOKSaveApiKey: GitHubCopilotRequestType { typealias Response = BYOKSaveApiKeyResponse - + var params: BYOKSaveApiKeyParams var request: ClientRequest { @@ -553,10 +691,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/saveApiKey", dict, ClientRequest.NullHandler) } } - + struct BYOKDeleteApiKey: GitHubCopilotRequestType { typealias Response = BYOKDeleteApiKeyResponse - + var params: BYOKDeleteApiKeyParams var request: ClientRequest { @@ -565,10 +703,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/deleteApiKey", dict, ClientRequest.NullHandler) } } - + struct BYOKListApiKeys: GitHubCopilotRequestType { typealias Response = BYOKListApiKeysResponse - + var params: BYOKListApiKeysParams var request: ClientRequest { @@ -582,9 +720,8 @@ enum GitHubCopilotRequest { // MARK: Notifications public enum GitHubCopilotNotification { - public struct StatusNotification: Codable { - public enum StatusKind : String, Codable { + public enum StatusKind: String, Codable { case normal = "Normal" case error = "Error" case warning = "Warning" @@ -593,13 +730,13 @@ public enum GitHubCopilotNotification { public var clsStatus: CLSStatus.Status { switch self { case .normal: - .normal + .normal case .error: - .error + .error case .warning: - .warning + .warning case .inactive: - .inactive + .inactive } } } @@ -613,7 +750,6 @@ public enum GitHubCopilotNotification { } } - public struct MCPRuntimeNotification: Codable { public enum MCPRuntimeLogLevel: String, Codable { case Info = "info" @@ -626,10 +762,9 @@ public enum GitHubCopilotNotification { public var server: String public var tool: String? public var time: Double - + public static func decode(fromParams params: JSONValue?) -> MCPRuntimeNotification? { try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) } } - } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift index 355e04ee..95bff025 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift @@ -6,105 +6,6 @@ import ConversationServiceProvider import JSONRPC import Logger -enum ConversationSource: String, Codable { - case panel, inline -} - -public struct FileReference: Codable, Equatable, Hashable { - public var type: String = "file" - public let uri: String - public let position: Position? - public let visibleRange: SuggestionBasic.CursorRange? - public let selection: SuggestionBasic.CursorRange? - public let openedAt: String? - public let activeAt: String? -} - -public struct DirectoryReference: Codable, Equatable, Hashable { - public var type: String = "directory" - public let uri: String -} - -public enum Reference: Codable, Equatable, Hashable { - case file(FileReference) - case directory(DirectoryReference) - - public func encode(to encoder: Encoder) throws { - switch self { - case .file(let fileRef): - try fileRef.encode(to: encoder) - case .directory(let directoryRef): - try directoryRef.encode(to: encoder) - } - } - - private enum CodingKeys: String, CodingKey { - case type - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - - switch type { - case "file": - let fileRef = try FileReference(from: decoder) - self = .file(fileRef) - case "directory": - let directoryRef = try DirectoryReference(from: decoder) - self = .directory(directoryRef) - default: - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Unknown reference type: \(type)" - ) - ) - } - } - - public static func from(_ ref: ConversationAttachedReference) -> Reference { - switch ref { - case .file(let fileRef): - return .file( - .init( - uri: fileRef.url.absoluteString, - position: nil, - visibleRange: nil, - selection: nil, - openedAt: nil, - activeAt: nil - ) - ) - case .directory(let directoryRef): - return .directory(.init(uri: directoryRef.url.absoluteString)) - } - } -} - -struct ConversationCreateParams: Codable { - var workDoneToken: String - var turns: [TurnSchema] - var capabilities: Capabilities - var textDocument: Doc? - var references: [Reference]? - var computeSuggestions: Bool? - var source: ConversationSource? - var workspaceFolder: String? - var workspaceFolders: [WorkspaceFolder]? - var ignoredSkills: [String]? - var model: String? - var modelProviderName: String? - var chatMode: String? - var needToolCallConfirmation: Bool? - var userLanguage: String? - - struct Capabilities: Codable { - var skills: [String] - var allSkills: Bool? - } -} - // MARK: Conversation Progress public enum ConversationProgressKind: String, Codable { @@ -121,6 +22,7 @@ public struct ConversationProgressBegin: BaseConversationProgress { public let kind: ConversationProgressKind public let conversationId: String public let turnId: String + public let parentTurnId: String? } public struct ConversationProgressReport: BaseConversationProgress { @@ -132,6 +34,7 @@ public struct ConversationProgressReport: BaseConversationProgress { public let references: [FileReference]? public let steps: [ConversationProgressStep]? public let editAgentRounds: [AgentRound]? + public let parentTurnId: String? } public struct ConversationProgressEnd: BaseConversationProgress { @@ -189,6 +92,8 @@ struct ConversationTemplatesParams: Codable { var workspaceFolders: [WorkspaceFolder]? } +typealias ConversationModesParams = ConversationTemplatesParams + // MARK: Conversation turn struct TurnCreateParams: Codable { var workDoneToken: String @@ -203,9 +108,16 @@ struct TurnCreateParams: Codable { var workspaceFolder: String? var workspaceFolders: [WorkspaceFolder]? var chatMode: String? + var customChatModeId: String? var needToolCallConfirmation: Bool? } +struct TurnDeleteParams: Codable { + var conversationId: String + var turnId: String + var source: ConversationSource? +} + // MARK: Copy struct CopyCodeParams: Codable { @@ -236,6 +148,7 @@ public struct WatchedFilesParams: Codable { public var workspaceFolder: WorkspaceFolder public var excludeGitignoredFiles: Bool public var excludeIDEIgnoredFiles: Bool + public var partialResultToken: ProgressToken? } public typealias WatchedFilesRequest = JSONRPCRequest diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift index e7d37610..17792a88 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift @@ -7,6 +7,7 @@ public enum MCPServerStatus: String, Codable, Equatable, Hashable { case running = "running" case stopped = "stopped" case error = "error" + case blocked = "blocked" } public struct InputSchema: Codable, Equatable, Hashable { @@ -109,12 +110,20 @@ public struct MCPServerToolsCollection: Codable, Equatable, Hashable { public let status: MCPServerStatus public let tools: [MCPTool] public let error: String? + public let registryInfo: String? - public init(name: String, status: MCPServerStatus, tools: [MCPTool], error: String? = nil) { + public init( + name: String, + status: MCPServerStatus, + tools: [MCPTool], + error: String? = nil, + registryInfo: String? = nil + ) { self.name = name self.status = status self.tools = tools self.error = error + self.registryInfo = registryInfo } } @@ -147,22 +156,78 @@ public struct UpdateMCPToolsStatusServerCollection: Codable, Hashable { } public struct UpdateMCPToolsStatusParams: Codable, Hashable { + public var chatModeKind: ChatMode? + public var customChatModeId: String? + public var workspaceFolders: [WorkspaceFolder]? public var servers: [UpdateMCPToolsStatusServerCollection] - - public init(servers: [UpdateMCPToolsStatusServerCollection]) { + + public init( + chatModeKind: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil, + servers: [UpdateMCPToolsStatusServerCollection] + ) { + self.chatModeKind = chatModeKind + self.customChatModeId = customChatModeId + self.workspaceFolders = workspaceFolders self.servers = servers } } public typealias CopilotMCPToolsRequest = JSONRPCRequest -public struct MCPOAuthRequestParams: Codable, Hashable { - public var mcpServer: String - public var authLabel: String +public struct DynamicOAuthParams: Codable, Hashable { + public let title: String + public let header: String? + public let detail: String + public let inputs: [DynamicOAuthInput] + + public init( + title: String, + header: String?, + detail: String, + inputs: [DynamicOAuthInput] + ) { + self.title = title + self.header = header + self.detail = detail + self.inputs = inputs + } } -public struct MCPOAuthResponse: Codable, Hashable { - public var confirm: Bool +public struct DynamicOAuthInput: Codable, Hashable { + public let title: String + public let value: String + public let description: String + public let placeholder: String + public let required: Bool + + public init( + title: String, + value: String, + description: String, + placeholder: String, + required: Bool + ) { + self.title = title + self.value = value + self.description = description + self.placeholder = placeholder + self.required = required + } } -public typealias MCPOAuthRequest = JSONRPCRequest +public typealias DynamicOAuthRequest = JSONRPCRequest + +public struct DynamicOAuthResponse: Codable, Hashable { + public let clientId: String + public let clientSecret: String + + public init( + clientId: String, + clientSecret: String + ) { + self.clientId = clientId + self.clientSecret = clientSecret + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift index dc891647..fd1c8bf6 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -3,30 +3,17 @@ import JSONRPC import ConversationServiceProvider /// Schema definitions for MCP Registry API based on the OpenAPI spec: -/// https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml +/// https://github.com/modelcontextprotocol/registry/blob/v1.3.3/docs/reference/api/openapi.yaml -// MARK: - Repository - -public struct Repository: Codable { - public let url: String - public let source: String - public let id: String? - public let subfolder: String? - - enum CodingKeys: String, CodingKey { - case url, source, id, subfolder - } -} +// MARK: - Inputs -// MARK: - Server Status - -public enum ServerStatus: String, Codable { - case active - case deprecated +public enum ArgumentFormat: String, Codable { + case string + case number + case boolean + case filepath } -// MARK: - Base Input Protocol - public protocol InputProtocol: Codable { var description: String? { get } var isRequired: Bool? { get } @@ -34,11 +21,10 @@ public protocol InputProtocol: Codable { var value: String? { get } var isSecret: Bool? { get } var defaultValue: String? { get } + var placeholder: String? { get } var choices: [String]? { get } } -// MARK: - Input (base type) - public struct Input: InputProtocol { public let description: String? public let isRequired: Bool? @@ -46,21 +32,15 @@ public struct Input: InputProtocol { public let value: String? public let isSecret: Bool? public let defaultValue: String? + public let placeholder: String? public let choices: [String]? enum CodingKeys: String, CodingKey { - case description - case isRequired = "is_required" - case format - case value - case isSecret = "is_secret" + case description, isRequired, format, value, isSecret, placeholder, choices case defaultValue = "default" - case choices } } -// MARK: - Input with Variables - public struct InputWithVariables: InputProtocol { public let description: String? public let isRequired: Bool? @@ -68,47 +48,95 @@ public struct InputWithVariables: InputProtocol { public let value: String? public let isSecret: Bool? public let defaultValue: String? + public let placeholder: String? public let choices: [String]? public let variables: [String: Input]? enum CodingKeys: String, CodingKey { - case description - case isRequired = "is_required" - case format - case value - case isSecret = "is_secret" + case description, isRequired, format, value, isSecret, placeholder, choices, variables case defaultValue = "default" - case choices - case variables } } -// MARK: - Argument Format +public struct KeyValueInput: InputProtocol, Hashable { + public let name: String + public let description: String? + public let isRequired: Bool? + public let format: ArgumentFormat? + public let value: String? + public let isSecret: Bool? + public let defaultValue: String? + public let placeholder: String? + public let choices: [String]? + public let variables: [String: Input]? + + public init( + name: String, + description: String?, + isRequired: Bool?, + format: ArgumentFormat?, + value: String?, + isSecret: Bool?, + defaultValue: String?, + placeholder: String?, + choices: [String]?, + variables: [String : Input]? + ) { + self.name = name + self.description = description + self.isRequired = isRequired + self.format = format + self.value = value + self.isSecret = isSecret + self.defaultValue = defaultValue + self.placeholder = placeholder + self.choices = choices + self.variables = variables + } -public enum ArgumentFormat: String, Codable { - case string - case number - case boolean - case filepath + enum CodingKeys: String, CodingKey { + case name, description, isRequired, format, value, isSecret, placeholder, choices, variables + case defaultValue = "default" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(placeholder) + hasher.combine(choices) + } + + public static func == (lhs: KeyValueInput, rhs: KeyValueInput) -> Bool { + lhs.name == rhs.name && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.placeholder == rhs.placeholder && + lhs.choices == rhs.choices + } } -// MARK: - Argument Type +// MARK: - Arguments public enum ArgumentType: String, Codable { case positional case named } -// MARK: - Base Argument Protocol - public protocol ArgumentProtocol: InputProtocol { var type: ArgumentType { get } var variables: [String: Input]? { get } } -// MARK: - Positional Argument - -public struct PositionalArgument: ArgumentProtocol { +public struct PositionalArgument: ArgumentProtocol, Hashable { public let type: ArgumentType = .positional public let description: String? public let isRequired: Bool? @@ -116,24 +144,47 @@ public struct PositionalArgument: ArgumentProtocol { public let value: String? public let isSecret: Bool? public let defaultValue: String? + public let placeholder: String? public let choices: [String]? public let variables: [String: Input]? public let valueHint: String? public let isRepeated: Bool? enum CodingKeys: String, CodingKey { - case type, description, format, value, choices, variables - case isRequired = "is_required" - case isSecret = "is_secret" + case type, description, isRequired, format, value, isSecret, placeholder, choices, variables, valueHint, isRepeated case defaultValue = "default" - case valueHint = "value_hint" - case isRepeated = "is_repeated" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(placeholder) + hasher.combine(choices) + hasher.combine(valueHint) + hasher.combine(isRepeated) + } + + public static func == (lhs: PositionalArgument, rhs: PositionalArgument) -> Bool { + lhs.type == rhs.type && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.placeholder == rhs.placeholder && + lhs.choices == rhs.choices && + lhs.valueHint == rhs.valueHint && + lhs.isRepeated == rhs.isRepeated } } -// MARK: - Named Argument - -public struct NamedArgument: ArgumentProtocol { +public struct NamedArgument: ArgumentProtocol, Hashable { public let type: ArgumentType = .named public let name: String public let description: String? @@ -142,22 +193,46 @@ public struct NamedArgument: ArgumentProtocol { public let value: String? public let isSecret: Bool? public let defaultValue: String? + public let placeholder: String? public let choices: [String]? public let variables: [String: Input]? public let isRepeated: Bool? enum CodingKeys: String, CodingKey { - case type, name, description, format, value, choices, variables - case isRequired = "is_required" - case isSecret = "is_secret" + case type, name, description, isRequired, format, value, isSecret, placeholder, choices, variables, isRepeated case defaultValue = "default" - case isRepeated = "is_repeated" } -} -// MARK: - Argument Enum + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(name) + hasher.combine(description) + hasher.combine(isRequired) + hasher.combine(format) + hasher.combine(value) + hasher.combine(isSecret) + hasher.combine(defaultValue) + hasher.combine(placeholder) + hasher.combine(choices) + hasher.combine(isRepeated) + } + + public static func == (lhs: NamedArgument, rhs: NamedArgument) -> Bool { + lhs.type == rhs.type && + lhs.name == rhs.name && + lhs.description == rhs.description && + lhs.isRequired == rhs.isRequired && + lhs.format == rhs.format && + lhs.value == rhs.value && + lhs.isSecret == rhs.isSecret && + lhs.defaultValue == rhs.defaultValue && + lhs.placeholder == rhs.placeholder && + lhs.choices == rhs.choices && + lhs.isRepeated == rhs.isRepeated + } +} -public enum Argument: Codable { +public enum Argument: Codable, Hashable { case positional(PositionalArgument) case named(NamedArgument) @@ -186,121 +261,289 @@ public enum Argument: Codable { } } -// MARK: - KeyValueInput +// MARK: - Transport -public struct KeyValueInput: InputProtocol { - public let name: String - public let description: String? - public let isRequired: Bool? - public let format: ArgumentFormat? - public let value: String? - public let isSecret: Bool? - public let defaultValue: String? - public let choices: [String]? - public let variables: [String: Input]? +public enum TransportType: String, Codable { + case streamableHttp = "streamable-http" + case stdio = "stdio" + case sse = "sse" + + public var displayText: String { + switch self { + case .streamableHttp: + return "Streamable HTTP" + case .stdio: + return "Stdio" + case .sse: + return "SSE" + } + } +} + +public protocol TransportProtocol: Codable { + var type: TransportType { get } + var variables: [String: Input]? { get } +} + +public struct StdioTransport: TransportProtocol, Hashable { + public let type: TransportType = .stdio + public let variables: [String : Input]? enum CodingKeys: String, CodingKey { - case name, description, format, value, choices, variables - case isRequired = "is_required" - case isSecret = "is_secret" - case defaultValue = "default" + case type, variables + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + } + + public static func == (lhs: StdioTransport, rhs: StdioTransport) -> Bool { + lhs.type == rhs.type + } +} + +public struct StreamableHttpTransport: TransportProtocol, Hashable { + public let type: TransportType = .streamableHttp + public let url: String + public let headers: [KeyValueInput]? + public let variables: [String : Input]? + + enum CodingKeys: String, CodingKey { + case type, url, headers, variables + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(url) + hasher.combine(headers) + } + + public static func == (lhs: StreamableHttpTransport, rhs: StreamableHttpTransport) -> Bool { + lhs.type == rhs.type && + lhs.url == rhs.url && + lhs.headers == rhs.headers + } +} + +public struct SseTransport: TransportProtocol, Hashable { + public let type: TransportType = .sse + public let url: String + public let headers: [KeyValueInput]? + public let variables: [String : Input]? + + enum CodingKeys: String, CodingKey { + case type, url, headers, variables + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(url) + hasher.combine(headers) + } + + public static func == (lhs: SseTransport, rhs: SseTransport) -> Bool { + lhs.type == rhs.type && + lhs.url == rhs.url && + lhs.headers == rhs.headers + } +} + +public enum Transport: Codable, Hashable { + case stdio(StdioTransport) + case streamableHTTP(StreamableHttpTransport) + case sse(SseTransport) + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Discriminator.self) + let type = try container.decode(TransportType.self, forKey: .type) + switch type { + case .stdio: + self = .stdio(try StdioTransport(from: decoder)) + case .streamableHttp: + self = .streamableHTTP(try StreamableHttpTransport(from: decoder)) + case .sse: + self = .sse(try SseTransport(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .stdio(let arg): + try arg.encode(to: encoder) + case .streamableHTTP(let arg): + try arg.encode(to: encoder) + case .sse(let arg): + try arg.encode(to: encoder) + } + } + + private enum Discriminator: String, CodingKey { + case type + } +} + +public enum Remote: Codable, Hashable { + case streamableHTTP(StreamableHttpTransport) + case sse(SseTransport) + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Discriminator.self) + let type = try container.decode(TransportType.self, forKey: .type) + switch type { + case .stdio: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unexpected type: stdio for Remote" + ) + case .streamableHttp: + self = .streamableHTTP(try StreamableHttpTransport(from: decoder)) + case .sse: + self = .sse(try SseTransport(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .streamableHTTP(let arg): + try arg.encode(to: encoder) + case .sse(let arg): + try arg.encode(to: encoder) + } + } + + private enum Discriminator: String, CodingKey { + case type } } // MARK: - Package -public struct Package: Codable { - public let registryType: String? - public let registryBaseURL: String? - public let identifier: String? +public struct Package: Codable, Hashable { + public let registryType: String + public let registryBaseUrl: String? + public let identifier: String public let version: String? - public let fileSHA256: String? + public let fileSha256: String? public let runtimeHint: String? + public let transport: Transport public let runtimeArguments: [Argument]? public let packageArguments: [Argument]? public let environmentVariables: [KeyValueInput]? - - enum CodingKeys: String, CodingKey { - case version, identifier - case registryType = "registry_type" - case registryBaseURL = "registry_base_url" - case fileSHA256 = "file_sha256" - case runtimeHint = "runtime_hint" - case runtimeArguments = "runtime_arguments" - case packageArguments = "package_arguments" - case environmentVariables = "environment_variables" + + public init( + registryType: String, + registryBaseUrl: String?, + identifier: String, + version: String?, + fileSha256: String?, + runtimeHint: String?, + transport: Transport, + runtimeArguments: [Argument]?, + packageArguments: [Argument]?, + environmentVariables: [KeyValueInput]? + ) { + self.registryType = registryType + self.registryBaseUrl = registryBaseUrl + self.identifier = identifier + self.version = version + self.fileSha256 = fileSha256 + self.runtimeHint = runtimeHint + self.transport = transport + self.runtimeArguments = runtimeArguments + self.packageArguments = packageArguments + self.environmentVariables = environmentVariables } } -// MARK: - Transport Type +// MARK: - Icons -public enum TransportType: String, Codable { - case streamable = "streamable" - case streamableHttp = "streamable-http" - case sse = "sse" +public enum IconMimeType: String, Codable { + case png = "image/png" + case jpeg = "image/jpeg" + case jpg = "image/jpg" + case svg = "image/svg+xml" + case webp = "image/webp" +} + +public enum IconTheme: String, Codable { + case light, dark +} + +public struct Icon: Codable, Hashable { + public let src: String + public let mimeType: IconMimeType? + public let sizes: [String]? + public let theme: IconTheme? } -// MARK: - Remote +// MARK: - Repository -public struct Remote: Codable { - public let transportType: TransportType +public struct Repository: Codable { public let url: String - public let headers: [KeyValueInput]? + public let source: String + public let id: String? + public let subfolder: String? + + public init(url: String, source: String, id: String?, subfolder: String?) { + self.url = url + self.source = source + self.id = id + self.subfolder = subfolder + } enum CodingKeys: String, CodingKey { - case url, headers - case transportType = "type" + case url, source, id, subfolder } } -// MARK: - Publisher Provided Meta - -public struct PublisherProvidedMeta: Codable { - public let tool: String? - public let version: String? - public let buildInfo: BuildInfo? - private let additionalProperties: [String: AnyCodable]? +// MARK: - Meta - public struct BuildInfo: Codable { - public let commit: String? - public let timestamp: String? - public let pipelineID: String? +public enum ServerStatus: String, Codable { + case active + case deprecated + case deleted +} - enum CodingKeys: String, CodingKey { - case commit, timestamp - case pipelineID = "pipeline_id" - } +public struct OfficialMeta: Codable { + public let status: ServerStatus? + public let publishedAt: String? + public let updatedAt: String? + public let isLatest: Bool? + + public init( + status: ServerStatus? = nil, + publishedAt: String? = nil, + updatedAt: String? = nil, + isLatest: Bool? = nil + ) { + self.status = status + self.publishedAt = publishedAt + self.updatedAt = updatedAt + self.isLatest = isLatest } +} - enum CodingKeys: String, CodingKey { - case tool, version - case buildInfo = "build_info" +public struct PublisherProvidedMeta: Codable { + private let additionalProperties: [String: AnyCodable]? + + public init( + additionalProperties: [String: AnyCodable]? = nil + ) { + self.additionalProperties = additionalProperties } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - tool = try container.decodeIfPresent(String.self, forKey: .tool) - version = try container.decodeIfPresent(String.self, forKey: .version) - buildInfo = try container.decodeIfPresent(BuildInfo.self, forKey: .buildInfo) - - // Capture additional properties let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) var extras: [String: AnyCodable] = [:] for key in allKeys.allKeys { - if !["tool", "version", "build_info"].contains(key.stringValue) { - extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) - } + extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) } additionalProperties = extras.isEmpty ? nil : extras } public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(tool, forKey: .tool) - try container.encodeIfPresent(version, forKey: .version) - try container.encodeIfPresent(buildInfo, forKey: .buildInfo) - if let additionalProperties = additionalProperties { var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self) for (key, value) in additionalProperties { @@ -310,46 +553,43 @@ public struct PublisherProvidedMeta: Codable { } } -// MARK: - Official Meta - -public struct OfficialMeta: Codable { - public let id: String - public let publishedAt: String - public let updatedAt: String - public let isLatest: Bool +public struct MCPRegistryExtensionMeta: Codable { + public let publisherProvided: PublisherProvidedMeta? enum CodingKeys: String, CodingKey { - case id - case publishedAt = "published_at" - case updatedAt = "updated_at" - case isLatest = "is_latest" + case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided" } -} -// MARK: - Server Meta + public init(publisherProvided: PublisherProvidedMeta?) { + self.publisherProvided = publisherProvided + } +} public struct ServerMeta: Codable { - public let publisherProvided: PublisherProvidedMeta? public let official: OfficialMeta? private let additionalProperties: [String: AnyCodable]? enum CodingKeys: String, CodingKey { - case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided" case official = "io.modelcontextprotocol.registry/official" } + + public init( + official: OfficialMeta? = nil, + additionalProperties: [String: AnyCodable]? = nil + ) { + self.official = official + self.additionalProperties = additionalProperties + } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - publisherProvided = try container.decodeIfPresent(PublisherProvidedMeta.self, forKey: .publisherProvided) - official = try container.decodeIfPresent(OfficialMeta.self, forKey: .official) + official = try container.decode(OfficialMeta.self, forKey: .official) - // Capture additional properties let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) var extras: [String: AnyCodable] = [:] - - let knownKeys = ["io.modelcontextprotocol.registry/publisher-provided", "io.modelcontextprotocol.registry/official"] + for key in allKeys.allKeys { - if !knownKeys.contains(key.stringValue) { + if key.stringValue != "io.modelcontextprotocol.registry/official" { extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key) } } @@ -358,7 +598,6 @@ public struct ServerMeta: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(publisherProvided, forKey: .publisherProvided) try container.encodeIfPresent(official, forKey: .official) if let additionalProperties = additionalProperties { @@ -370,79 +609,132 @@ public struct ServerMeta: Codable { } } -// MARK: - Dynamic Coding Key Helper - -private struct AnyCodingKey: CodingKey { - let stringValue: String - let intValue: Int? - - init?(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } - - init?(intValue: Int) { - self.stringValue = String(intValue) - self.intValue = intValue - } -} - -// MARK: - Server Detail +// MARK: - Servers public struct MCPRegistryServerDetail: Codable { public let name: String public let description: String - public let status: ServerStatus? + public let title: String? public let repository: Repository? public let version: String - public let websiteURL: String? - public let createdAt: String? - public let updatedAt: String? + public let websiteUrl: String? + public let icons: [Icon]? public let schemaURL: String? public let packages: [Package]? public let remotes: [Remote]? - public let meta: ServerMeta? + public let meta: MCPRegistryExtensionMeta? enum CodingKeys: String, CodingKey { - case name, description, status, repository, version, packages, remotes - case websiteURL = "website_url" - case createdAt = "created_at" - case updatedAt = "updated_at" + case name, description, title, repository, version, packages, remotes, websiteUrl, icons case schemaURL = "$schema" case meta = "_meta" } + + public init( + name: String, + description: String, + title: String?, + repository: Repository?, + version: String, + websiteUrl: String?, + icons: [Icon]?, + schemaURL: String?, + packages: [Package]?, + remotes: [Remote]?, + meta: MCPRegistryExtensionMeta? + ) { + self.name = name + self.description = description + self.title = title + self.repository = repository + self.version = version + self.websiteUrl = websiteUrl + self.icons = icons + self.schemaURL = schemaURL + self.packages = packages + self.remotes = remotes + self.meta = meta + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + name = try container.decode(String.self, forKey: .name) + description = try container.decode(String.self, forKey: .description) + title = try container.decodeIfPresent(String.self, forKey: .title) + version = try container.decode(String.self, forKey: .version) + websiteUrl = try container.decodeIfPresent(String.self, forKey: .websiteUrl) + icons = try container.decodeIfPresent([Icon].self, forKey: .icons) + schemaURL = try container.decodeIfPresent(String.self, forKey: .schemaURL) + packages = try container.decodeIfPresent([Package].self, forKey: .packages) + remotes = try container.decodeIfPresent([Remote].self, forKey: .remotes) + meta = try container.decodeIfPresent(MCPRegistryExtensionMeta.self, forKey: .meta) + + // Custom handling for repository: {} โ†’ nil + if container.contains(.repository) { + // Decode raw dictionary to see if it is empty + let repoDict = try container.decode([String: AnyCodable].self, forKey: .repository) + if repoDict.isEmpty { + repository = nil + } else { + // Re-decode as Repository from the same key + repository = try container.decode(Repository.self, forKey: .repository) + } + } else { + repository = nil + } + } } -// MARK: - Server List Metadata +public struct MCPRegistryServerResponse : Codable { + public let server: MCPRegistryServerDetail + public let meta: ServerMeta? -public struct MCPRegistryServerListMetadata: Codable { - public let nextCursor: String? - public let count: Int? + public init(server: MCPRegistryServerDetail, meta: ServerMeta? = nil) { + self.server = server + self.meta = meta + } enum CodingKeys: String, CodingKey { - case nextCursor = "next_cursor" - case count + case server + case meta = "_meta" } } -// MARK: - Server List +public struct MCPRegistryServerListMetadata: Codable { + public let nextCursor: String? + public let count: Int? +} public struct MCPRegistryServerList: Codable { - public let servers: [MCPRegistryServerDetail] + public let servers: [MCPRegistryServerResponse] public let metadata: MCPRegistryServerListMetadata? } -// MARK: - Request Parameters +// MARK: - Requests public struct MCPRegistryListServersParams: Codable { public let baseUrl: String public let cursor: String? public let limit: Int? + public let search: String? + public let updatedSince: String? + public let version: String? - public init(baseUrl: String, cursor: String? = nil, limit: Int? = nil) { + public init( + baseUrl: String, + cursor: String? = nil, + limit: Int?, + search: String? = nil, + updatedSince: String? = nil, + version: String? = nil + ) { self.baseUrl = baseUrl self.cursor = cursor self.limit = limit + self.search = search + self.updatedSince = updatedSince + self.version = version } } @@ -457,3 +749,20 @@ public struct MCPRegistryGetServerParams: Codable { self.version = version } } + +// MARK: - Internal Helpers + +private struct AnyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift new file mode 100644 index 00000000..39cf074f --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift @@ -0,0 +1,83 @@ +import Foundation + +// MARK: - MCPRegistryOwner + +public struct MCPRegistryOwner: Codable, Hashable { + public let login: String + public let id: Int + public let type: String // "Business" (Enterprise) or "Organization" + public let parentLogin: String? + public let parentId: Int? + + enum CodingKeys: String, CodingKey { + case login + case id + case type + case parentLogin = "parent_login" + case parentId = "parent_id" + } + + public init(login: String, id: Int, type: String, parentLogin: String? = nil, parentId: Int? = nil) { + self.login = login + self.id = id + self.type = type + self.parentLogin = parentLogin + self.parentId = parentId + } +} + +// MARK: - RegistryAccess + +public enum RegistryAccess: String, Codable, Hashable { + case registryOnly = "registry_only" + case allowAll = "allow_all" +} + +// MARK: - McpRegistryEntry + +public struct MCPRegistryEntry: Codable, Hashable { + public let url: String + public let registryAccess: RegistryAccess + public let owner: MCPRegistryOwner + + enum CodingKeys: String, CodingKey { + case url + case registryAccess = "registry_access" + case owner + } + + public init(url: String, registryAccess: RegistryAccess, owner: MCPRegistryOwner) { + self.url = url + self.registryAccess = registryAccess + self.owner = owner + } +} + +// MARK: - GetMCPRegistryAllowlistResult + +/// Result schema for getMCPRegistryAllowlist method +public struct GetMCPRegistryAllowlistResult: Codable, Hashable { + public let mcpRegistries: [MCPRegistryEntry] + + enum CodingKeys: String, CodingKey { + case mcpRegistries = "mcp_registries" + } +} + +public struct MCPRegistryErrorData: Codable { + public let errorType: String + public let status: Int? + public let shouldRetry: Bool? + + enum CodingKeys: String, CodingKey { + case errorType + case status + case shouldRetry + } + + public init(errorType: String, status: Int? = nil, shouldRetry: Bool? = nil) { + self.errorType = errorType + self.status = status + self.shouldRetry = shouldRetry + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift new file mode 100644 index 00000000..9d87086e --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift @@ -0,0 +1,59 @@ +import SuggestionBasic +import LanguageServerProtocol + + +public struct CopilotInlineEditsParams: Codable { + public let textDocument: VersionedTextDocumentIdentifier + public let position: CursorPosition +} + +public struct CopilotInlineEdit: Codable { + public struct Command: Codable { + public let title: String + public let command: String + public let arguments: [String] + } + /** + * The new text for this edit. + */ + public let text: String + /** + * The text document this edit applies to including the version + * Uses the same schema as for completions: src + * + * "textDocument": { + * "uri": "file:///path/to/file", + * "version": 0 + * }, + * + */ + public let textDocument: VersionedTextDocumentIdentifier + public let range: CursorRange + /** + * Called by the client with workspace/executeCommand after accepting the next edit suggestion. + */ + public let command: Command? +} + +public struct CopilotInlineEditsResponse: Codable { + public let edits: [CopilotInlineEdit] +} + +// MARK: - Notification + +public struct TextDocumentDidShowInlineEditParams: Codable, Hashable { + public struct Command: Codable, Hashable { + public var arguments: [String] + } + + public struct NotificationCommandSchema: Codable, Hashable { + public var command: Command + } + + public var item: NotificationCommandSchema + + public static func from(id: String) -> Self { + .init(item: .init(command: .init(arguments: [id]))) + } +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 5a2acb3c..978c6e92 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -33,11 +33,23 @@ public protocol GitHubCopilotSuggestionServiceType { indentSize: Int, usesTabsForIndentation: Bool ) async throws -> [CodeSuggestion] + func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: CursorPosition + ) async throws -> [CodeSuggestion] func notifyShown(_ completion: CodeSuggestion) async + func notifyCopilotInlineEditShown(_ completion: CodeSuggestion) async func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int?) async + func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async func notifyRejected(_ completions: [CodeSuggestion]) async func notifyOpenTextDocument(fileURL: URL, content: String) async throws - func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws + func notifyChangeTextDocument( + fileURL: URL, + content: String, + version: Int, + contentChanges: [TextDocumentContentChangeEvent]? + ) async throws func notifyCloseTextDocument(fileURL: URL) async throws func notifySaveTextDocument(fileURL: URL) async throws func cancelRequest() async @@ -65,7 +77,8 @@ public protocol GitHubCopilotConversationServiceType { modelProviderName: String?, turns: [TurnSchema], agentMode: Bool, - userLanguage: String?) async throws + customChatModeId: String?, + userLanguage: String?) async throws -> ConversationCreateResponse func createTurn(_ message: MessageContent, workDoneToken: String, conversationId: String, @@ -77,11 +90,14 @@ public protocol GitHubCopilotConversationServiceType { modelProviderName: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]?, - agentMode: Bool) async throws + agentMode: Bool, + customChatModeId: String?) async throws -> ConversationCreateResponse + func deleteTurn(conversationId: String, turnId: String) async throws func rateConversation(turnId: String, rating: ConversationRating) async throws func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws func cancelProgress(token: String) async func templates(workspaceFolders: [WorkspaceFolder]?) async throws -> [ChatTemplate] + func modes(workspaceFolders: [WorkspaceFolder]?) async throws -> [ConversationMode] func models() async throws -> [CopilotModel] func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool] @@ -148,6 +164,12 @@ public enum GitHubCopilotError: Error, LocalizedError { public extension Notification.Name { static let gitHubCopilotShouldRefreshEditorInformation = Notification .Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") + static let githubCopilotAgentMaxToolCallingLoopDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentMaxToolCallingLoopDidChange") + static let githubCopilotAgentAutoApprovalDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoApprovalDidChange") + static let githubCopilotAgentTrustToolAnnotationsDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentTrustToolAnnotationsDidChange") } public class GitHubCopilotBaseService { @@ -193,6 +215,7 @@ public class GitHubCopilotBaseService { let watchedFiles = JSONValue( booleanLiteral: projectRootURL.path == "/" ? false : true ) + let enableSubagent = UserDefaults.shared.value(for: \.enableSubagent) #if DEBUG // Use local language server if set and available @@ -271,7 +294,10 @@ public class GitHubCopilotBaseService { "copilotCapabilities": [ /// The editor has support for watching files over LSP "watchedFiles": watchedFiles, - "didChangeFeatureFlags": true + "didChangeFeatureFlags": true, + "stateDatabase": true, + "subAgent": JSONValue(booleanLiteral: enableSubagent), + "mcpAllowlist": true, ], "githubAppId": authAppId.map(JSONValue.string) ?? .null, ], @@ -421,11 +447,10 @@ public final class GitHubCopilotService: private var cancellables = Set() private var statusWatcher: CopilotAuthStatusWatcher? private static var services: [GitHubCopilotService] = [] // cache all alive copilot service instances - private var isMCPInitialized = false - private var unrestoredMcpServers: [String] = [] private var mcpRuntimeLogFileName: String = "" private static let toolInitializationActor = ToolInitializationActor() private var lastSentConfiguration: JSONValue? + private var mcpToolsContinuation: AsyncStream.Continuation? override init(designatedServer: any GitHubCopilotLSP) { super.init(designatedServer: designatedServer) @@ -437,14 +462,18 @@ public final class GitHubCopilotService: self.handleSendWorkspaceDidChangeNotifications() + let (stream, continuation) = AsyncStream.makeStream(of: AnyJSONRPCNotification.self) + self.mcpToolsContinuation = continuation + + Task { [weak self] in + for await notification in stream { + await self?.handleMCPToolsNotification(notification) + } + } + localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in if notification.method == "copilot/mcpTools" && projectRootURL.path != "/" { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - Task { @MainActor in - await self.handleMCPToolsNotification(notification) - } - } + self?.mcpToolsContinuation?.yield(notification) } if notification.method == "copilot/mcpRuntimeLogs" && projectRootURL.path != "/" { @@ -463,15 +492,12 @@ public final class GitHubCopilotService: for await event in server.eventSequence { switch event { case let .request(id, request): - switch request { - case let .custom(method, params, callback): - if method == "copilot/mcpOAuth" && projectRootURL.path == "/" { - continue - } - self.serverRequestHandler.handleRequest(.init(id: id, method: method, params: params), workspaceURL: workspaceURL, callback: callback, service: self) - default: - break - } + self.serverRequestHandler.handleRequest( + id: id, + request, + workspaceURL: workspaceURL, + service: self + ) default: break } @@ -516,7 +542,7 @@ public final class GitHubCopilotService: do { let completions = try await self .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( - textDocument: .init(uri: fileURL.absoluteString, version: 1), + textDocument: .init(uri: fileURL.absoluteString, version: 0), position: cursorPosition, formattingOptions: .init( tabSize: tabSize, @@ -563,36 +589,12 @@ public final class GitHubCopilotService: } } - func recoverContent() async { - try? await notifyChangeTextDocument( - fileURL: fileURL, - content: originalContent, - version: 0 - ) - } - - // since when the language server is no longer using the passed in content to generate - // suggestions, we will need to update the content to the file before we do any request. - // - // And sometimes the language server's content was not up to date and may generate - // weird result when the cursor position exceeds the line. let task = Task { @GitHubCopilotSuggestionActor in - try? await notifyChangeTextDocument( - fileURL: fileURL, - content: content, - version: 1 - ) - do { + let maxTry: Int = 5 try Task.checkCancellation() - return try await sendRequest() - } catch let error as CancellationError { - if ongoingTasks.isEmpty { - await recoverContent() - } - throw error + return try await sendRequest(maxTry: maxTry) } catch { - await recoverContent() throw error } } @@ -601,21 +603,59 @@ public final class GitHubCopilotService: return try await task.value } + + // MARK: - NES + @GitHubCopilotSuggestionActor + public func getCopilotInlineEdit( + fileURL: URL, + content: String, + cursorPosition: CursorPosition + ) async throws -> [CodeSuggestion] { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() + await localProcessServer?.cancelOngoingTasks() + + do { + let completions = try await sendRequest( + GitHubCopilotRequest.CopilotInlineEdit( + params: CopilotInlineEditsParams( + textDocument: .init(uri: fileURL.absoluteString, version: 0), + position: cursorPosition + ) + )) + .edits + .compactMap { edit in + CodeSuggestion.init( + id: edit.command?.arguments.first ?? UUID().uuidString, + text: edit.text, + position: cursorPosition, + range: edit.range + ) + } + return completions + } catch { + Logger.gitHubCopilot.error("Failed to get copilot inline edit: \(error.localizedDescription)") + throw error + } + } @GitHubCopilotSuggestionActor - public func createConversation(_ message: MessageContent, - workDoneToken: String, - workspaceFolder: String, - workspaceFolders: [WorkspaceFolder]? = nil, - activeDoc: Doc?, - skills: [String], - ignoredSkills: [String]?, - references: [ConversationAttachedReference], - model: String?, - modelProviderName: String?, - turns: [TurnSchema], - agentMode: Bool, - userLanguage: String?) async throws { + public func createConversation( + _ message: MessageContent, + workDoneToken: String, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + activeDoc: Doc?, + skills: [String], + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + turns: [TurnSchema], + agentMode: Bool, + customChatModeId: String?, + userLanguage: String? + ) async throws -> ConversationCreateResponse { var conversationCreateTurns: [TurnSchema] = [] // invoke conversation history if turns.count > 0 { @@ -645,10 +685,11 @@ public final class GitHubCopilotService: model: model, modelProviderName: modelProviderName, chatMode: agentMode ? "Agent" : nil, + customChatModeId: customChatModeId, needToolCallConfirmation: true, userLanguage: userLanguage) do { - _ = try await sendRequest( + return try await sendRequest( GitHubCopilotRequest.CreateConversation(params: params)) } catch { print("Failed to create conversation. Error: \(error)") @@ -657,18 +698,21 @@ public final class GitHubCopilotService: } @GitHubCopilotSuggestionActor - public func createTurn(_ message: MessageContent, - workDoneToken: String, - conversationId: String, - turnId: String?, - activeDoc: Doc?, - ignoredSkills: [String]?, - references: [ConversationAttachedReference], - model: String?, - modelProviderName: String?, - workspaceFolder: String, - workspaceFolders: [WorkspaceFolder]? = nil, - agentMode: Bool) async throws { + public func createTurn( + _ message: MessageContent, + workDoneToken: String, + conversationId: String, + turnId: String?, + activeDoc: Doc?, + ignoredSkills: [String]?, + references: [ConversationAttachedReference], + model: String?, + modelProviderName: String?, + workspaceFolder: String, + workspaceFolders: [WorkspaceFolder]? = nil, + agentMode: Bool, + customChatModeId: String? + ) async throws -> ConversationCreateResponse { do { let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, @@ -682,14 +726,25 @@ public final class GitHubCopilotService: workspaceFolder: workspaceFolder, workspaceFolders: workspaceFolders, chatMode: agentMode ? "Agent" : nil, + customChatModeId: customChatModeId, needToolCallConfirmation: true) - _ = try await sendRequest( + return try await sendRequest( GitHubCopilotRequest.CreateTurn(params: params)) } catch { print("Failed to create turn. Error: \(error)") throw error } } + + @GitHubCopilotSuggestionActor + public func deleteTurn(conversationId: String, turnId: String) async throws { + do { + let params = TurnDeleteParams(conversationId: conversationId, turnId: turnId, source: .panel) + _ = try await sendRequest(GitHubCopilotRequest.DeleteTurn(params: params)) + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func templates(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ChatTemplate] { @@ -703,6 +758,19 @@ public final class GitHubCopilotService: throw error } } + + @GitHubCopilotSuggestionActor + public func modes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode] { + do { + let params = ConversationModesParams(workspaceFolders: workspaceFolders) + let response = try await sendRequest( + GitHubCopilotRequest.GetModes(params: params) + ) + return response + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func models() async throws -> [CopilotModel] { @@ -799,6 +867,18 @@ public final class GitHubCopilotService: throw error } } + + @GitHubCopilotSuggestionActor + public func getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult { + do { + let response = try await sendRequest( + GitHubCopilotRequest.MCPRegistryGetAllowlist() + ) + return response + } catch { + throw error + } + } @GitHubCopilotSuggestionActor public func rateConversation(turnId: String, rating: ConversationRating) async throws { @@ -843,6 +923,11 @@ public final class GitHubCopilotService: GitHubCopilotRequest.NotifyShown(completionUUID: completion.id) ) } + + @GitHubCopilotSuggestionActor + public func notifyCopilotInlineEditShown(_ completion: CodeSuggestion) async { + try? await sendCopilotNotification(.textDocumentDidShowInlineEdit(.from(id: completion.id))) + } @GitHubCopilotSuggestionActor public func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async { @@ -850,6 +935,13 @@ public final class GitHubCopilotService: GitHubCopilotRequest.NotifyAccepted(completionUUID: completion.id, acceptedLength: acceptedLength) ) } + + @GitHubCopilotSuggestionActor + public func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async { + _ = try? await sendRequest( + GitHubCopilotRequest.NotifyCopilotInlineEditAccepted(params: [completion.id]) + ) + } @GitHubCopilotSuggestionActor public func notifyRejected(_ completions: [CodeSuggestion]) async { @@ -884,20 +976,18 @@ public final class GitHubCopilotService: public func notifyChangeTextDocument( fileURL: URL, content: String, - version: Int + version: Int, + contentChanges: [TextDocumentContentChangeEvent]? = nil ) async throws { - let uri = "file://\(fileURL.path)" + let uri = fileURL.absoluteString + let changes: [TextDocumentContentChangeEvent] = contentChanges ?? [.init(range: nil, rangeLength: nil, text: content)] // Logger.service.debug("Change \(uri), \(content.count)") try await server.sendNotification( .textDocumentDidChange( DidChangeTextDocumentParams( uri: uri, version: version, - contentChange: .init( - range: nil, - rangeLength: nil, - text: content - ) + contentChanges: changes ) ) ) @@ -1233,6 +1323,53 @@ public final class GitHubCopilotService: return updatedTools } + /// Refresh client tools by registering an empty list to get the latest tools from the server. + /// This is a workaround for the issue where server-side tools may not be ready when client tools are initially registered. + public static func refreshClientTools() async { + // Use the first available service since CopilotLanguageModelToolManager is shared + guard let service = services.first(where: { $0.projectRootURL.path != "/" }) else { + Logger.gitHubCopilot.error("No available service to refresh client tools") + return + } + + do { + // Capture previous snapshot to detect newly added tools only + let previousNames = Set((CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { $0.name }) + + // Register empty list to get the complete updated tool list from server + let refreshedTools = try await service.registerTools(tools: []) + CopilotLanguageModelToolManager.updateToolsStatus(refreshedTools) + Logger.gitHubCopilot.info("Refreshed client tools: \(refreshedTools.count) tools available (previous: \(previousNames.count))") + + // Restore status ONLY for newly added tools whose saved status differs. + if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), + let data = try? JSONEncoder().encode(savedJSON), + let savedStatusList = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data), + !savedStatusList.isEmpty { + let refreshedByName = Dictionary(uniqueKeysWithValues: (CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { ($0.name, $0) }) + let newlyAddedNames = refreshedTools.map { $0.name }.filter { !previousNames.contains($0) } + if !newlyAddedNames.isEmpty { + let neededUpdates: [ToolStatusUpdate] = newlyAddedNames.compactMap { newName in + guard let saved = savedStatusList.first(where: { $0.name == newName }), + let current = refreshedByName[newName], current.status != saved.status else { return nil } + return saved + } + if !neededUpdates.isEmpty { + do { + let finalTools = try await service.updateToolsStatus(params: .init(tools: neededUpdates)) + CopilotLanguageModelToolManager.updateToolsStatus(finalTools) + Logger.gitHubCopilot.info("Restored statuses for newly added tools: \(neededUpdates.map{ $0.name }.joined(separator: ", "))") + } catch { + Logger.gitHubCopilot.error("Failed to restore newly added tool statuses: \(error)") + } + } + } + } + } catch { + Logger.gitHubCopilot.error("Failed to refresh client tools: \(error)") + } + } + private func loadUnrestoredLanguageModelTools() -> [ToolStatusUpdate] { if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"), let data = try? JSONEncoder().encode(savedJSON), @@ -1259,69 +1396,9 @@ public final class GitHubCopilotService: Logger.gitHubCopilot.error("Failed to restore tools for service at \(projectRootURL.path): \(error)") } } - - private func loadUnrestoredMCPServers() -> [String] { - if let savedJSON = AppState.shared.get(key: "mcpToolsStatus"), - let data = try? JSONEncoder().encode(savedJSON), - let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) { - return savedStatus - .filter { !$0.tools.isEmpty } - .map { $0.name } - } - - return [] - } - - private func restoreMCPToolsStatus(_ mcpServers: [String]) async -> [MCPServerToolsCollection]? { - guard let savedJSON = AppState.shared.get(key: "mcpToolsStatus"), - let data = try? JSONEncoder().encode(savedJSON), - let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) else { - Logger.gitHubCopilot.info("Failed to get MCP Tools status") - return nil - } - - do { - let savedServers = savedStatus.filter { mcpServers.contains($0.name) } - if savedServers.isEmpty { - return nil - } else { - return try await updateMCPToolsStatus( - params: .init(servers: savedServers) - ) - } - } catch let error as ServerError { - Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(GitHubCopilotError.languageServerError(error))") - } catch { - Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(error)") - } - - return nil - } public func handleMCPToolsNotification(_ notification: AnyJSONRPCNotification) async { - defer { - self.isMCPInitialized = true - } - - if !self.isMCPInitialized { - self.unrestoredMcpServers = self.loadUnrestoredMCPServers() - } - if let payload = GetAllToolsParams.decode(fromParams: notification.params) { - if !self.unrestoredMcpServers.isEmpty { - // Find servers that need to be restored - let toRestore = payload.servers.filter { !$0.tools.isEmpty } - .filter { self.unrestoredMcpServers.contains($0.name) } - .map { $0.name } - self.unrestoredMcpServers.removeAll { toRestore.contains($0) } - - if let tools = await self.restoreMCPToolsStatus(toRestore) { - Logger.gitHubCopilot.info("Restore MCP tools status for servers: \(toRestore)") - CopilotMCPToolManager.updateMCPTools(tools) - return - } - } - CopilotMCPToolManager.updateMCPTools(payload.servers) } } @@ -1360,7 +1437,15 @@ public final class GitHubCopilotService: let pathHash = String(workspacePath.hash.magnitude, radix: 36).prefix(6) return "\(workspaceName)-\(pathHash)" } - + + public static func getProjectGithubCopilotService(for projectRootURL: URL) -> GitHubCopilotService? { + if let existingService = services.first(where: { $0.projectRootURL == projectRootURL }) { + return existingService + } else { + return nil + } + } + public func handleSendWorkspaceDidChangeNotifications() { Task { if projectRootURL.path != "/" { @@ -1375,9 +1460,30 @@ public final class GitHubCopilotService: await sendConfigurationUpdate() // Combine both notification streams - let combinedNotifications = Publishers.Merge( - NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" }, - FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" } + let combinedNotifications = Publishers.MergeMany( + NotificationCenter.default + .publisher(for: .gitHubCopilotShouldRefreshEditorInformation) + .map { _ in "editorInfo" } + .eraseToAnyPublisher(), + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .map { _ in "featureFlags" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentMaxToolCallingLoopDidChange) + .map { _ in "agentMaxToolCallingLoop" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentAutoApprovalDidChange) + .map { _ in "agentAutoApproval" } + .eraseToAnyPublisher(), + NotificationCenter.default + .publisher(for: .githubCopilotAgentAutoApprovalDidChange) + .map { _ in "agentAutoApproval" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentTrustToolAnnotationsDidChange) + .map { _ in "agentTrustToolAnnotations" } + .eraseToAnyPublisher() ) for await _ in combinedNotifications.values { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift index 39c2c4a5..e7f9eba9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -13,6 +13,7 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { var protocolProgressSubject: PassthroughSubject var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared + var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared init() { self.protocolProgressSubject = PassthroughSubject() @@ -44,6 +45,15 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { featureFlagNotifier.handleFeatureFlagNotification(didChangeFeatureFlagsParams) } break + case "policy/didChange": + if let data = try? JSONEncoder().encode(notification.params), + let policy = try? JSONDecoder().decode( + CopilotPolicy.self, + from: data + ) { + copilotPolicyNotifier.handleCopilotPolicyNotification(policy) + } + break default: break } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift index 7b28b73b..eb61fa50 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift @@ -1,6 +1,6 @@ -import Foundation -import ConversationServiceProvider import Combine +import ConversationServiceProvider +import Foundation import JSONRPC import LanguageClient import LanguageServerProtocol @@ -10,91 +10,112 @@ public typealias ResponseHandler = ServerRequest.Handler public typealias LegacyResponseHandler = (AnyJSONRPCResponse) -> Void protocol ServerRequestHandler { - func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) + func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) } -class ServerRequestHandlerImpl : ServerRequestHandler { +class ServerRequestHandlerImpl: ServerRequestHandler { public static let shared = ServerRequestHandlerImpl() private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared private let showMessageRequestHandler: ShowMessageRequestHandler = ShowMessageRequestHandlerImpl.shared - private let mcpOAuthRequestHandler: MCPOAuthRequestHandler = MCPOAuthRequestHandlerImpl.shared + private let dynamicOAuthRequestHandler: DynamicOAuthRequestHandler = DynamicOAuthRequestHandlerImpl.shared + + func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) { + switch request { + case let .windowShowMessageRequest(params, callback): + if workspaceURL.path != "/" { + do { + let paramsData = try JSONEncoder().encode(params) + let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: paramsData) - func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) { - let methodName = request.method - let legacyResponseHandler = toLegacyResponseHandler(callback) - do { - switch methodName { - case "conversation/context": - let params = try JSONEncoder().encode(request.params) - let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: params) - conversationContextHandler.handleConversationContext( - ConversationContextRequest(id: request.id, method: request.method, params: contextParams), - completion: legacyResponseHandler) - - case "copilot/watchedFiles": - let params = try JSONEncoder().encode(request.params) - let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: params) - watchedFilesHandler.handleWatchedFiles(WatchedFilesRequest(id: request.id, method: request.method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: legacyResponseHandler, service: service) - - case "window/showMessageRequest": - let params = try JSONEncoder().encode(request.params) - let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: params) - showMessageRequestHandler - .handleShowMessage( + showMessageRequestHandler.handleShowMessageRequest( ShowMessageRequest( - id: request.id, - method: request.method, + id: id, + method: "window/showMessageRequest", params: showMessageRequestParams ), + callback: callback + ) + } catch { + Task { + await callback(.success(nil)) + } + } + } + + case let .custom(method, params, callback): + let legacyResponseHandler = toLegacyResponseHandler(callback) + do { + switch method { + case "conversation/context": + let paramsData = try JSONEncoder().encode(params) + let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: paramsData) + conversationContextHandler.handleConversationContext( + ConversationContextRequest(id: id, method: method, params: contextParams), completion: legacyResponseHandler ) - case "conversation/invokeClientTool": - let params = try JSONEncoder().encode(request.params) - let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params) - ClientToolHandlerImpl.shared.invokeClientTool(InvokeClientToolRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler) + case "copilot/watchedFiles": + let paramsData = try JSONEncoder().encode(params) + let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: paramsData) + watchedFilesHandler.handleWatchedFiles( + WatchedFilesRequest(id: id, method: method, params: watchedFilesParams), + workspaceURL: workspaceURL, + completion: legacyResponseHandler, + service: service + ) - case "conversation/invokeClientToolConfirmation": - let params = try JSONEncoder().encode(request.params) - let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params) - ClientToolHandlerImpl.shared.invokeClientToolConfirmation(InvokeClientToolConfirmationRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler) + case "conversation/invokeClientTool": + let paramsData = try JSONEncoder().encode(params) + let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData) + ClientToolHandlerImpl.shared.invokeClientTool( + InvokeClientToolRequest(id: id, method: method, params: invokeParams), + completion: legacyResponseHandler + ) - case "copilot/mcpOAuth": - let params = try JSONEncoder().encode(request.params) - let mcpOAuthRequestParams = try JSONDecoder().decode(MCPOAuthRequestParams.self, from: params) - mcpOAuthRequestHandler.handleShowOAuthMessage( - MCPOAuthRequest( - id: request.id, - method: request.method, - params: mcpOAuthRequestParams - ), - completion: legacyResponseHandler - ) + case "conversation/invokeClientToolConfirmation": + let paramsData = try JSONEncoder().encode(params) + let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData) + ClientToolHandlerImpl.shared.invokeClientToolConfirmation( + InvokeClientToolConfirmationRequest(id: id, method: method, params: invokeParams), + completion: legacyResponseHandler + ) + + case "copilot/dynamicOAuth": + let paramsData = try JSONEncoder().encode(params) + let dynamicOAuthParams = try JSONDecoder().decode(DynamicOAuthParams.self, from: paramsData) + DynamicOAuthRequestHandlerImpl.shared.handleDynamicOAuthRequest( + DynamicOAuthRequest(id: id, method: method, params: dynamicOAuthParams), + completion: legacyResponseHandler + ) - default: - break + default: + break + } + } catch { + handleError(id: id, method: method, error: error, callback: legacyResponseHandler) } - } catch { - handleError(request, error: error, callback: legacyResponseHandler) + + default: + break } } - - private func handleError(_ request: AnyJSONRPCRequest, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) { + + private func handleError(id: JSONId, method: String, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) { callback( AnyJSONRPCResponse( - id: request.id, + id: id, result: JSONValue.array([ JSONValue.null, JSONValue.hash([ - "code": .number(-32602/* Invalid params */), - "message": .string("Error: \(error.localizedDescription)")]) + "code": .number(-32602 /* Invalid params */ ), + "message": .string("Error handling \(method): \(error.localizedDescription)")]), ]) ) ) Logger.gitHubCopilot.error(error) } - + /// Converts a new Handler to work with old code that expects LegacyResponseHandler private func toLegacyResponseHandler( _ newHandler: @escaping ResponseHandler diff --git a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift new file mode 100644 index 00000000..5072ae12 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift @@ -0,0 +1,53 @@ +import Combine +import SwiftUI +import JSONRPC + +public extension Notification.Name { + static let gitHubCopilotPolicyDidChange = Notification + .Name("com.github.CopilotForXcode.CopilotPolicyDidChange") +} + +public struct CopilotPolicy: Hashable, Codable { + public var mcpContributionPointEnabled: Bool = true + public var customAgentEnabled: Bool = true + public var subagentEnabled: Bool = true + public var cveRemediatorAgentEnabled: Bool = true + public var agentModeAutoApprovalEnabled: Bool = false + + enum CodingKeys: String, CodingKey { + case mcpContributionPointEnabled = "mcp.contributionPoint.enabled" + case customAgentEnabled = "customAgent.enabled" + case subagentEnabled = "subagent.enabled" + case cveRemediatorAgentEnabled = "cveRemediatorAgent.enabled" + case agentModeAutoApprovalEnabled = "agentMode.autoApproval.enabled" + } +} + +public protocol CopilotPolicyNotifier { + var copilotPolicy: CopilotPolicy { get } + var policyDidChange: PassthroughSubject { get } + func handleCopilotPolicyNotification(_ policy: CopilotPolicy) +} + +public class CopilotPolicyNotifierImpl: CopilotPolicyNotifier { + public private(set) var copilotPolicy: CopilotPolicy + public static let shared = CopilotPolicyNotifierImpl() + public var policyDidChange: PassthroughSubject + + init( + copilotPolicy: CopilotPolicy = CopilotPolicy(), + policyDidChange: PassthroughSubject = PassthroughSubject() + ) { + self.copilotPolicy = copilotPolicy + self.policyDidChange = policyDidChange + } + + public func handleCopilotPolicyNotification(_ policy: CopilotPolicy) { + self.copilotPolicy = policy + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.policyDidChange.send(self.copilotPolicy) + DistributedNotificationCenter.default().post(name: .gitHubCopilotPolicyDidChange, object: nil) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 8b343d61..fe08a348 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -35,7 +35,8 @@ public struct FeatureFlags: Hashable, Codable { public var byok: Bool public var editorPreviewFeatures: Bool public var activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags - + public var agentModeAutoApproval: Bool + public init( restrictedTelemetry: Bool = true, snippy: Bool = true, @@ -47,7 +48,8 @@ public struct FeatureFlags: Hashable, Codable { ccr: Bool = true, byok: Bool = true, editorPreviewFeatures: Bool = true, - activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:] + activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:], + agentModeAutoApproval: Bool = true ) { self.restrictedTelemetry = restrictedTelemetry self.snippy = snippy @@ -60,6 +62,7 @@ public struct FeatureFlags: Hashable, Codable { self.byok = byok self.editorPreviewFeatures = editorPreviewFeatures self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags + self.agentModeAutoApproval = agentModeAutoApproval } } @@ -103,6 +106,7 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { self.featureFlags.byok = self.didChangeFeatureFlagsParams.byok != false self.featureFlags.editorPreviewFeatures = self.didChangeFeatureFlagsParams.token["editor_preview_features"] != "0" self.featureFlags.activeExperimentForFeatureFlags = self.didChangeFeatureFlagsParams.activeExps + self.featureFlags.agentModeAutoApproval = self.didChangeFeatureFlagsParams.token["agent_mode_auto_approval"] != "0" } public func handleFeatureFlagNotification(_ didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams) { diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index 2557d0ee..b99c854f 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -40,8 +40,10 @@ public final class GitHubCopilotConversationService: ConversationServiceType { return message } - public func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws { - guard let service = await serviceLocator.getService(from: workspace) else { return } + public func createConversation( + _ request: ConversationRequest, workspace: WorkspaceInfo + ) async throws -> ConversationCreateResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } let message = getMessageContent(request) @@ -57,11 +59,14 @@ public final class GitHubCopilotConversationService: ConversationServiceType { modelProviderName: request.modelProviderName, turns: request.turns, agentMode: request.agentMode, + customChatModeId: request.customChatModeId, userLanguage: request.userLanguage) } - public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws { - guard let service = await serviceLocator.getService(from: workspace) else { return } + public func createTurn( + with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo + ) async throws -> ConversationCreateResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } let message = getMessageContent(request) @@ -76,7 +81,14 @@ public final class GitHubCopilotConversationService: ConversationServiceType { modelProviderName: request.modelProviderName, workspaceFolder: workspace.projectURL.absoluteString, workspaceFolders: getWorkspaceFolders(workspace: workspace), - agentMode: request.agentMode) + agentMode: request.agentMode, + customChatModeId: request.customChatModeId) + } + + public func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + + return try await service.deleteTurn(conversationId: conversationId, turnId: turnId) } public func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws { @@ -101,6 +113,16 @@ public final class GitHubCopilotConversationService: ConversationServiceType { let workspaceFolders = isPreviewEnabled ? getWorkspaceFolders(workspace: workspace) : nil return try await service.templates(workspaceFolders: workspaceFolders) } + + 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( + workspace: workspace + ) : nil + return try await service.modes(workspaceFolders: workspaceFolders) + } public func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? { guard let service = await serviceLocator.getService(from: workspace) else { return nil } @@ -120,10 +142,17 @@ public final class GitHubCopilotConversationService: ConversationServiceType { return try await service.agents() } - public func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult? { + public func reviewChanges( + workspace: WorkspaceInfo, + changes: [ReviewChangesParams.Change] + ) async throws -> CodeReviewResult? { guard let service = await serviceLocator.getService(from: workspace) else { return nil } - return try await service.reviewChanges(params: params) + return try await service + .reviewChanges(params: .init( + changes: changes, + workspaceFolders: getWorkspaceFolders(workspace: workspace)) + ) } } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift index f9f8a9b5..b135fb65 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -2,8 +2,9 @@ import CopilotForXcodeKit import Foundation import SuggestionBasic import Workspace +import SuggestionProvider -public final class GitHubCopilotSuggestionService: SuggestionServiceType { +public final class GitHubCopilotSuggestionService: SuggestionServiceType, NESSuggestionServiceType { public var configuration: SuggestionServiceConfiguration { .init( acceptsRelevantCodeSnippets: true, @@ -19,7 +20,7 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType { } public func getSuggestions( - _ request: SuggestionRequest, + _ request: CopilotForXcodeKit.SuggestionRequest, workspace: WorkspaceInfo ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { guard let service = await serviceLocator.getService(from: workspace) else { return [] } @@ -36,6 +37,21 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType { usesTabsForIndentation: request.usesTabsForIndentation ).map(Self.convert) } + + public func getNESSuggestions( + _ request: CopilotForXcodeKit.SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + + return try await service + .getCopilotInlineEdit( + fileURL: request.fileURL, + content: request.content, + cursorPosition: .init(line: request.cursorPosition.line, character: request.cursorPosition.character) + ) + .map(Self.convert) + } public func notifyAccepted( _ suggestion: CopilotForXcodeKit.CodeSuggestion, diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift index 28172e69..6e52319b 100644 --- a/Tool/Sources/HostAppActivator/HostAppActivator.swift +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -9,8 +9,14 @@ public extension Notification.Name { .Name("com.github.CopilotForXcode.OpenSettingsWindowRequest") static let openToolsSettingsWindowRequest = Notification .Name("com.github.CopilotForXcode.OpenToolsSettingsWindowRequest") + static let openToolsSettingsAutoApproveWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenToolsSettingsAutoApproveWindowRequest") static let openBYOKSettingsWindowRequest = Notification .Name("com.github.CopilotForXcode.OpenBYOKSettingsWindowRequest") + static let openAdvancedSettingsWindowRequest = Notification + .Name("com.github.CopilotForXcode.OpenAdvancedSettingsWindowRequest") + static let selectedAgentSubModeDidChange = Notification + .Name("com.github.CopilotForXcode.SelectedAgentSubModeDidChange") } public enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError { @@ -56,7 +62,7 @@ public func launchHostAppSettings() throws { } } -public func launchHostAppToolsSettings() throws { +public func launchHostAppToolsSettings(currentAgentSubMode: String) throws { // Try the AppleScript approach first, but only if app is already running if let hostApp = getRunningHostApp() { let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) @@ -68,6 +74,15 @@ public func launchHostAppToolsSettings() throws { .openToolsSettingsWindowRequest, object: nil ) + + // Notify settings app of current agent submode + DistributedNotificationCenter.default().postNotificationName( + .selectedAgentSubModeDidChange, + object: nil, + userInfo: ["agentSubMode": currentAgentSubMode], + options: .deliverImmediately + ) + Logger.ui.info("\(hostAppName()) MCP settings notification sent after activation") return } else { @@ -76,6 +91,27 @@ public func launchHostAppToolsSettings() throws { } } +public func launchHostAppToolsSettingsAutoApprove() throws { + // Try the AppleScript approach first, but only if app is already running + if let hostApp = getRunningHostApp() { + let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) + Logger.ui.info("\(hostAppName()) activated: \(activated)") + + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openToolsSettingsAutoApproveWindowRequest, + object: nil + ) + + Logger.ui.info("\(hostAppName()) MCP settings (Auto-Approve) notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--tools-auto-approve"]) + } +} + public func launchHostAppBYOKSettings() throws { // Try the AppleScript approach first, but only if app is already running if let hostApp = getRunningHostApp() { @@ -96,6 +132,26 @@ public func launchHostAppBYOKSettings() throws { } } +public func launchHostAppAdvancedSettings() throws { + // Try the AppleScript approach first, but only if app is already running + if let hostApp = getRunningHostApp() { + let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) + Logger.ui.info("\(hostAppName()) activated: \(activated)") + + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openAdvancedSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) Advanced settings notification sent after activation") + return + } else { + // If app is not running, launch it with the settings flag + try launchHostAppWithArgs(args: ["--advanced"]) + } +} + private func tryLaunchWithAppleScript() -> Bool { // Try to launch settings using AppleScript let script = """ @@ -163,3 +219,5 @@ func hostAppName() -> String { return Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode" } + +public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode" diff --git a/Tool/Sources/Logger/MCPRuntimeLogger.swift b/Tool/Sources/Logger/MCPRuntimeLogger.swift index 36527e43..d633b440 100644 --- a/Tool/Sources/Logger/MCPRuntimeLogger.swift +++ b/Tool/Sources/Logger/MCPRuntimeLogger.swift @@ -2,16 +2,18 @@ import Foundation import System public final class MCPRuntimeFileLogger { - private let timestampFormat = Date.ISO8601FormatStyle.iso8601 - .year() - .month() - .day() - .timeZone(separator: .omitted).time(includingFractionalSeconds: true) - private static let implementation = MCPRuntimeFileLoggerImplementation() - + private lazy var dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private let implementation = MCPRuntimeFileLoggerImplementation() + /// Converts a timestamp in milliseconds since the Unix epoch to a formatted date string. private func timestamp(timeStamp: Double) -> String { - return Date(timeIntervalSince1970: timeStamp/1000).formatted(timestampFormat) + let date = Date(timeIntervalSince1970: timeStamp/1000) + return dateFormatter.string(from: date) } public func log( @@ -22,10 +24,16 @@ public final class MCPRuntimeFileLogger { tool: String? = nil, time: Double ) { - let log = "[\(timestamp(timeStamp: time))] [\(level)] [\(server)\(tool == nil ? "" : "-\(tool!))")] \(message)\(message.hasSuffix("\n") ? "" : "\n")" + guard time.isFinite, time >= 0 else { + return + } + + let toolSuffix = tool.map { "-\($0)" } ?? "" + let timestampStr = timestamp(timeStamp: time) + let log = "[\(timestampStr)] [\(level)] [\(server)\(toolSuffix)] \(message)\(message.hasSuffix("\n") ? "" : "\n")" - Task { - await MCPRuntimeFileLogger.implementation.logToFile(logFileName: logFileName, log: log) + Task { [implementation] in + await implementation.logToFile(logFileName: logFileName, log: log) } } } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 1e063da7..50ead5c8 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -166,6 +166,10 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionToggle: PreferenceKey { .init(defaultValue: true, key: "RealtimeSuggestionToggle") } + + var realtimeNESToggle: PreferenceKey { + .init(defaultValue: true, key: "RealtimeNESToggle") + } var suggestionDisplayCompactMode: PreferenceKey { .init(defaultValue: true, key: "SuggestionDisplayCompactMode") @@ -308,6 +312,10 @@ public extension UserDefaultPreferenceKeys { var chatResponseLocale: PreferenceKey { .init(defaultValue: "en", key: "ChatResponseLocale") } + + var agentMaxToolCallingLoop: PreferenceKey { + .init(defaultValue: 25, key: "AgentMaxToolCallingLoop") + } var globalCopilotInstructions: PreferenceKey { .init(defaultValue: "", key: "GlobalCopilotInstructions") @@ -320,6 +328,14 @@ public extension UserDefaultPreferenceKeys { var enableFixError: PreferenceKey { .init(defaultValue: true, key: "EnableFixError") } + + var suppressRestoreCheckpointConfirmation: PreferenceKey { + .init(defaultValue: false, key: "SuppressRestoreCheckpointConfirmation") + } + + var enableSubagent: PreferenceKey { + .init(defaultValue: true, key: "EnableSubagent") + } } // MARK: - Theme @@ -599,4 +615,36 @@ public extension UserDefaultPreferenceKeys { var currentUserName: PreferenceKey { .init(defaultValue: "", key: "CurrentUserName") } + + var mcpRegistryBaseURL: PreferenceKey { + .init(defaultValue: "https://api.mcp.github.com", key: "MCPRegistryBaseURL") + } + + var mcpRegistryBaseURLHistory: PreferenceKey<[String]> { + .init(defaultValue: [], key: "MCPRegistryBaseURLHistory") + } +} + +// MARK: - Auto Approval +public extension UserDefaultPreferenceKeys { + + var enableAutoApproval: PreferenceKey { + .init(defaultValue: false, key: "EnableAutoApproval") + } + + var trustToolAnnotations: PreferenceKey { + .init(defaultValue: false, key: "TrustToolAnnotations") + } + + var sensitiveFilesGlobalApprovals: PreferenceKey { + .init(defaultValue: SensitiveFilesRules(), key: "AutoApproval_SensitiveFiles_GlobalApprovals") + } + + var mcpServersGlobalApprovals: PreferenceKey { + .init(defaultValue: AutoApprovedMCPServers(), key: "AutoApproval_MCP_GlobalApprovals") + } + + var terminalCommandsGlobalApprovals: PreferenceKey { + .init(defaultValue: TerminalCommandsRules(), key: "AutoApproval_Terminal_GlobalApprovals") + } } diff --git a/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift b/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift new file mode 100644 index 00000000..902f4f02 --- /dev/null +++ b/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct MCPServerApprovalState: Codable, Equatable { + public var isServerAllowed: Bool + public var allowedTools: Set + + public init(isServerAllowed: Bool = false, allowedTools: Set = []) { + self.isServerAllowed = isServerAllowed + self.allowedTools = allowedTools + } +} + +public struct AutoApprovedMCPServers: Codable, Equatable, RawRepresentable { + public var servers: [String: MCPServerApprovalState] + + public init(servers: [String: MCPServerApprovalState] = [:]) { + self.servers = servers + } + + public init?(rawValue: [String: Any]) { + let serversDict = rawValue["servers"] as? [String: Any] ?? [:] + var parsedServers: [String: MCPServerApprovalState] = [:] + + for (serverName, value) in serversDict { + if let dict = value as? [String: Any] { + let isServerAllowed = dict["isServerAllowed"] as? Bool ?? false + let allowedToolsArray = dict["allowedTools"] as? [String] ?? [] + parsedServers[serverName] = MCPServerApprovalState( + isServerAllowed: isServerAllowed, + allowedTools: Set(allowedToolsArray) + ) + } + } + self.servers = parsedServers + } + + public var rawValue: [String: Any] { + var serversDict: [String: Any] = [:] + for (serverName, state) in servers { + serversDict[serverName] = [ + "isServerAllowed": state.isServerAllowed, + "allowedTools": Array(state.allowedTools) + ] + } + return ["servers": serversDict] + } +} diff --git a/Tool/Sources/Preferences/Types/SensitiveFilesRules.swift b/Tool/Sources/Preferences/Types/SensitiveFilesRules.swift new file mode 100644 index 00000000..e05f4b44 --- /dev/null +++ b/Tool/Sources/Preferences/Types/SensitiveFilesRules.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct SensitiveFileRule: Codable, Equatable { + public var description: String + public var autoApprove: Bool + + public init(description: String, autoApprove: Bool) { + self.description = description + self.autoApprove = autoApprove + } +} + +public struct SensitiveFilesRules: Codable, Equatable, RawRepresentable { + public var rules: [String: SensitiveFileRule] + + public init(rules: [String: SensitiveFileRule] = [:]) { + self.rules = rules + } + + public init?(rawValue: [String: Any]) { + let rulesDict = rawValue["rules"] as? [String: Any] ?? [:] + var parsedRules: [String: SensitiveFileRule] = [:] + for (key, value) in rulesDict { + if let dict = value as? [String: Any] { + let description = dict["description"] as? String ?? "" + let autoApprove = dict["autoApprove"] as? Bool ?? false + parsedRules[key] = SensitiveFileRule(description: description, autoApprove: autoApprove) + } + } + self.rules = parsedRules + } + + public var rawValue: [String: Any] { + var rulesDict: [String: Any] = [:] + for (pattern, rule) in rules { + rulesDict[pattern] = [ + "description": rule.description, + "autoApprove": rule.autoApprove + ] + } + return ["rules": rulesDict] + } +} diff --git a/Tool/Sources/Preferences/Types/TerminalCommandsRules.swift b/Tool/Sources/Preferences/Types/TerminalCommandsRules.swift new file mode 100644 index 00000000..52dcbbfb --- /dev/null +++ b/Tool/Sources/Preferences/Types/TerminalCommandsRules.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct TerminalCommandsRules: Codable, Equatable, RawRepresentable { + public var commands: [String: Bool] + + public init(commands: [String: Bool] = [:]) { + self.commands = commands + } + + public init?(rawValue: [String: Any]) { + let rulesDict = rawValue["commands"] as? [String: Any] ?? [:] + var parsedRules: [String: Bool] = [:] + for (key, value) in rulesDict { + if let autoApprove = value as? Bool { + parsedRules[key] = autoApprove + } + } + self.commands = parsedRules + } + + public var rawValue: [String: Any] { + return ["commands": commands] + } +} diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 56c19b47..9055c3c3 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -10,13 +10,23 @@ public protocol UserDefaultsType { public extension UserDefaults { static var shared = UserDefaults(suiteName: userDefaultSuiteName)! + /// Workspace-level auto-approval storage. + /// + /// Backed by the `group..autoApproval.prefs` suite so it persists + /// across app restarts and is isolated from general app preferences. + static var autoApproval = UserDefaults(suiteName: autoApprovalUserDefaultSuiteName)! + static func setupDefaultSettings() { shared.setupDefaultValue(for: \.quitXPCServiceOnXcodeAndAppQuit) shared.setupDefaultValue(for: \.realtimeSuggestionToggle) + shared.setupDefaultValue(for: \.realtimeNESToggle) shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) shared.setupDefaultValue(for: \.suggestionPresentationMode) shared.setupDefaultValue(for: \.autoAttachChatToXcode) shared.setupDefaultValue(for: \.enableFixError) + shared.setupDefaultValue(for: \.enableSubagent) + shared.setupDefaultValue(for: \.enableAutoApproval) + shared.setupDefaultValue(for: \.trustToolAnnotations) shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue( @@ -79,8 +89,10 @@ extension Bool: UserDefaultsStorable {} extension String: UserDefaultsStorable {} extension Data: UserDefaultsStorable {} extension URL: UserDefaultsStorable {} +extension Dictionary: UserDefaultsStorable {} + -extension Array: RawRepresentable where Element: Codable { +extension Array: @retroactive RawRepresentable where Element: Codable { public init?(rawValue: String) { guard let data = rawValue.data(using: .utf8), let result = try? JSONDecoder().decode([Element].self, from: data) @@ -309,3 +321,35 @@ public extension UserDefaultsType { } } +public extension UserDefaultsType { + // MARK: Dictionary Raw Representable + + func value( + for keyPath: KeyPath + ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? [String: Any] else { + return key.defaultValue + } + return K.Value(rawValue: rawValue) ?? key.defaultValue + } + + func set( + _ value: K.Value, + for keyPath: KeyPath + ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + set(value.rawValue, forKey: key.key) + } + + func setupDefaultValue( + for keyPath: KeyPath, + defaultValue: K.Value? = nil + ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + if value(forKey: key.key) == nil { + set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key) + } + } +} + diff --git a/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift similarity index 84% rename from Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift rename to Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift index 54ee7e9c..5e06037b 100644 --- a/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift +++ b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift @@ -2,16 +2,16 @@ import SwiftUI /// A small adaptive help link button that uses the native `HelpLink` on macOS 14+ /// and falls back to a styled question-mark button on earlier versions. -struct AdaptiveHelpLink: View { +public struct AdaptiveHelpLink: View { let action: () -> Void var controlSize: ControlSize = .small - init(controlSize: ControlSize = .small, action: @escaping () -> Void) { + public init(controlSize: ControlSize = .small, action: @escaping () -> Void) { self.controlSize = controlSize self.action = action } - var body: some View { + public var body: some View { Group { if #available(macOS 14.0, *) { HelpLink(action: action) diff --git a/Tool/Sources/SharedUIComponents/Base/Colors.swift b/Tool/Sources/SharedUIComponents/Base/Colors.swift index 2015102a..9ab89738 100644 --- a/Tool/Sources/SharedUIComponents/Base/Colors.swift +++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift @@ -2,4 +2,44 @@ import SwiftUI public extension Color { static var hoverColor: Color { .gray.opacity(0.1) } + + static var chatWindowBackgroundColor: Color { Color("ChatWindowBackgroundColor") } + + static var successLightGreen: Color { Color("LightGreen") } + + static var agentToolStatusDividerColor: Color { Color("AgentToolStatusDividerColor") } + + static var agentToolStatusOutlineColor: Color { Color("AgentToolStatusOutlineColor") } +} + +public var QuinarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quinarySystemFill) + } else { + return Color("QuinarySystemFillColor") + } +} + +public var QuaternarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .quaternarySystemFill) + } else { + return Color("QuaternarySystemFillColor") + } +} + +public var TertiarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .tertiarySystemFill) + } else { + return Color("TertiarySystemFillColor") + } +} + +public var SecondarySystemFillColor: Color { + if #available(macOS 14.0, *) { + return Color(nsColor: .secondarySystemFill) + } else { + return Color("SecondarySystemFillColor") + } } diff --git a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift index 92384e17..62a647d1 100644 --- a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift +++ b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift @@ -1,26 +1,119 @@ import Foundation import SwiftUI - -public func drawFileIcon(_ file: URL?) -> Image { - let defaultImage = Image(systemName: "doc.text") - - guard let file = file else { return defaultImage } +@ViewBuilder +public func drawFileIcon(_ file: URL?) -> some View { + let fileExtension = file?.pathExtension.lowercased() ?? "" - let fileExtension = file.pathExtension.lowercased() - if fileExtension == "swift" { + switch fileExtension { + case "swift": if let nsImage = NSImage(named: "SwiftIcon") { - return Image(nsImage: nsImage) + Image(nsImage: nsImage) + .resizable() + } else { + Image(systemName: "doc.text") + .resizable() + } + case "md": + Text("Mโ†“") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.indigo) + case "plist": + Image(systemName: "table") + .resizable() + case "xcconfig": + Image(systemName: "gearshape.2") + .resizable() + case "html": + Image(systemName: "chevron.left.slash.chevron.right") + .resizable() + .foregroundColor(.blue) + case "entitlements": + Image(systemName: "checkmark.seal.text.page") + .resizable() + .foregroundColor(.yellow) + case "sh": + Image(systemName: "terminal") + .resizable() + case "txt": + Image(systemName: "doc.plaintext") + .resizable() + case "c", "m", "mm": + Text("C") + .scaledFont(size: 12, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "cpp": + Text("C++") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "h": + Text("h") + .scaledFont(size: 12, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "xml": + Text("XML") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.orange) + case "yml", "yaml": + Text("YML") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.pink) + case "json": + Text("{}") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.red) + case "ts": + Text("TS") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "tsx": + Text("TSX") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + case "js": + Text("JS") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.yellow) + case "jsx": + Text("JSX") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.yellow) + case "css": + Text("CSS") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.purple) + case "py": + Text("PY") + .scaledFont(size: 10, weight: .bold, design: .monospaced) + .foregroundColor(.indigo) + case "xctestplan": + ZStack { + Text("P") + .scaledFont(size: 8, weight: .bold, design: .monospaced) + .foregroundColor(.blue) + RoundedRectangle(cornerRadius: 1.5) + .stroke(Color.blue, lineWidth: 1.5) + .scaledFrame(width: 10, height: 10) + .rotationEffect(.degrees(45)) } + default: + Image(systemName: "doc.text") + .resizable() } - - return defaultImage } -public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> Image { +@ViewBuilder +public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> some View { if isDirectory { - return Image(systemName: "folder") + if file?.lastPathComponent == "xcassets" { + Image(systemName: "photo.on.rectangle.angled") + .resizable() + .foregroundColor(.blue) + } else { + Image(systemName: "folder") + .resizable() + } } else { - return drawFileIcon(file) + drawFileIcon(file) } } diff --git a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift index e58b5b56..313346ad 100644 --- a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift +++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift @@ -5,24 +5,34 @@ public struct HoverButtonStyle: ButtonStyle { @State private var isHovered: Bool private var padding: CGFloat private var hoverColor: Color + private var backgroundColor: Color + private var cornerRadius: CGFloat - public init(isHovered: Bool = false, padding: CGFloat = 4, hoverColor: Color = .hoverColor) { + public init( + isHovered: Bool = false, + padding: CGFloat = 4, + hoverColor: Color = .hoverColor, + backgroundColor: Color = .clear, + cornerRadius: CGFloat = 4 + ) { self.isHovered = isHovered self.padding = padding self.hoverColor = hoverColor + self.backgroundColor = backgroundColor + self.cornerRadius = cornerRadius } public func makeBody(configuration: Configuration) -> some View { configuration.label - .padding(padding) + .scaledPadding(padding) .background( configuration.isPressed ? Color.gray.opacity(0.2) : isHovered ? hoverColor - : Color.clear + : backgroundColor ) - .cornerRadius(4) + .cornerRadius(cornerRadius) .onHover { hover in isHovered = hover } diff --git a/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift new file mode 100644 index 00000000..54edfe08 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift @@ -0,0 +1,118 @@ +import SwiftUI +import AppKit + +public struct CollapsibleSearchField: View { + @Binding public var searchText: String + @Binding public var isExpanded: Bool + public let placeholderString: String + + public init( + searchText: Binding, + isExpanded: Binding, + placeholderString: String = "Search..." + ) { + self._searchText = searchText + self._isExpanded = isExpanded + self.placeholderString = placeholderString + } + + public var body: some View { + Group { + if isExpanded { + SearchFieldRepresentable( + searchText: $searchText, + isExpanded: $isExpanded, + placeholderString: placeholderString + ) + .frame(width: 200, height: 24) + .transition(.opacity) + } else { + Button(action: { + isExpanded = true + }) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + } + .buttonStyle(.plain) + .frame(height: 24) + .transition(.opacity) + } + } + } +} + +private struct SearchFieldRepresentable: NSViewRepresentable { + @Binding var searchText: String + @Binding var isExpanded: Bool + let placeholderString: String + + func makeNSView(context: Context) -> NSSearchField { + let searchField = NSSearchField() + searchField.placeholderString = placeholderString + searchField.delegate = context.coordinator + searchField.target = context.coordinator + searchField.action = #selector(Coordinator.searchFieldDidChange(_:)) + + // Make the magnifying glass clickable to collapse + if let cell = searchField.cell as? NSSearchFieldCell { + cell.searchButtonCell?.target = context.coordinator + cell.searchButtonCell?.action = #selector(Coordinator.magnifyingGlassClicked(_:)) + } + + return searchField + } + + func updateNSView(_ nsView: NSSearchField, context: Context) { + if nsView.stringValue != searchText { + nsView.stringValue = searchText + } + + context.coordinator.isExpanded = $isExpanded + + // Auto-focus when expanded, only if not already first responder + if isExpanded && nsView.window?.firstResponder != nsView.currentEditor() { + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(nsView) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(searchText: $searchText, isExpanded: $isExpanded) + } + + class Coordinator: NSObject, NSSearchFieldDelegate, NSTextFieldDelegate { + @Binding var searchText: String + var isExpanded: Binding + + init(searchText: Binding, isExpanded: Binding) { + _searchText = searchText + self.isExpanded = isExpanded + } + + @objc func searchFieldDidChange(_ sender: NSSearchField) { + searchText = sender.stringValue + } + + @objc func magnifyingGlassClicked(_ sender: Any) { + // Collapse when magnifying glass is clicked + DispatchQueue.main.async { [weak self] in + withAnimation(.easeInOut(duration: 0.2)) { + self?.isExpanded.wrappedValue = false + } + } + } + + func controlTextDidEndEditing(_ obj: Notification) { + // Collapse search field when it loses focus and text is empty + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + DispatchQueue.main.async { [weak self] in + withAnimation(.easeInOut(duration: 0.2)) { + self?.isExpanded.wrappedValue = false + self?.searchText = "" + } + } + } + } + } +} diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift index d9673c17..e705d183 100644 --- a/Tool/Sources/SharedUIComponents/CopyButton.swift +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -28,11 +28,11 @@ public struct CopyButton: View { }) { Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") .resizable() - .aspectRatio(contentMode: .fit) - .scaledFrame(width: 14, height: 14) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(foregroundColor ?? .secondary) .conditionalFontWeight(fontWeight) - .padding(4) } .buttonStyle(HoverButtonStyle(padding: 0)) .help("Copy") diff --git a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift similarity index 56% rename from Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift rename to Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift index d3b45f07..2758a5cf 100644 --- a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift +++ b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift @@ -1,79 +1,78 @@ -import Client import SwiftUI -import XcodeInspector +import ConversationServiceProvider +import AppKitExtension -struct CreateCustomCopilotFileView: View { - var isOpen: Binding - let promptType: PromptType +public struct CreateCustomCopilotFileView: View { + public let promptType: PromptType + public let editorPluginVersion: String + public let getCurrentProjectURL: () async -> URL? + public let onSuccess: (String) -> Void + public let onError: (String) -> Void @State private var fileName = "" @State private var projectURL: URL? @State private var fileAlreadyExists = false - @Environment(\.toast) var toast + @Environment(\.dismiss) private var dismiss - init(isOpen: Binding, promptType: PromptType) { - self.isOpen = isOpen + public init( + promptType: PromptType, + editorPluginVersion: String, + getCurrentProjectURL: @escaping () async -> URL?, + onSuccess: @escaping (String) -> Void, + onError: @escaping (String) -> Void + ) { self.promptType = promptType + self.editorPluginVersion = editorPluginVersion + self.getCurrentProjectURL = getCurrentProjectURL + self.onSuccess = onSuccess + self.onError = onError } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center) { - Button(action: { self.isOpen.wrappedValue = false }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .padding() + public var body: some View { + Form { + VStack(alignment: .center, spacing: 20) { + HStack(alignment: .center) { + Spacer() + Text("Create \(promptType.displayName)").font(.headline) + Spacer() + AdaptiveHelpLink(action: openHelpLink) } - .buttonStyle(.plain) - Text("Create \(promptType.displayName)") - .font(.system(size: 13, weight: .bold)) - Spacer() - - AdaptiveHelpLink(action: openHelpLink) - .padding() - } - .frame(height: 28) - .background(Color(nsColor: .separatorColor)) - - // Content - VStack(alignment: .leading, spacing: 8) { - Text("Enter the name of \(promptType.rawValue) file:") - .font(.body) - - TextField("File name", text: $fileName) - .textFieldStyle(.roundedBorder) - .onSubmit { - Task { await createPromptFile() } + // Content + VStack(alignment: .leading, spacing: 4) { + TextFieldsContainer { + TextField("File name", text: Binding( + get: { fileName }, + set: { newValue in + fileName = newValue + updateFileExistence() + } + )) + .disableAutocorrection(true) + .textContentType(.none) + .onSubmit { + Task { await createPromptFile() } + } } - .onChange(of: fileName) { _ in - updateFileExistence() - } - - validationMessageView - Spacer() + validationMessageView + } - HStack(spacing: 12) { + HStack(spacing: 8) { Spacer() - - Button("Cancel") { - self.isOpen.wrappedValue = false - } - .buttonStyle(.bordered) - - Button("Create") { - Task { await createPromptFile() } - } + Button("Cancel", role: .cancel) { dismiss() } + Button("Create") { Task { await createPromptFile() } } .buttonStyle(.borderedProminent) .disabled(disableCreateButton) + .keyboardShortcut(.defaultAction) } } - .padding(.vertical, 8) - .padding(.horizontal, 20) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(20) } - .frame(width: 350, height: 160) + .frame(width: 350, height: 190) .onAppear { fileName = "" Task { await resolveProjectURL() } @@ -101,31 +100,35 @@ struct CreateCustomCopilotFileView: View { .foregroundColor(.red) .lineLimit(2) .multilineTextAlignment(.leading) + .truncationMode(.middle) .fixedSize(horizontal: false, vertical: true) - .layoutPriority(1) } else if trimmedFileName.isEmpty { Image(systemName: "info.circle") .foregroundColor(.secondary) - Text("Enter a file name") + Text("Enter the name of \(promptType.rawValue) file") .font(.caption) .foregroundColor(.secondary) } else { + Text("Location:") + .foregroundColor(.primary) + .padding(.leading, 10) + .layoutPriority(1) Text(".github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)") .font(.caption) .foregroundColor(.secondary) .lineLimit(2) .multilineTextAlignment(.leading) + .truncationMode(.middle) .fixedSize(horizontal: false, vertical: true) - .layoutPriority(1) } } - .transition(.opacity) + .padding(.horizontal, 2) } // MARK: - Actions / Helpers private func openHelpLink() { - if let url = URL(string: promptType.helpLink) { + if let url = URL(string: promptType.helpLink(editorPluginVersion: editorPluginVersion)) { NSWorkspace.shared.open(url) } } @@ -153,7 +156,7 @@ struct CreateCustomCopilotFileView: View { private func createPromptFile() async { guard let projectURL else { await MainActor.run { - toast("No active workspace found", .error) + onError("No active workspace found") } return } @@ -165,7 +168,7 @@ struct CreateCustomCopilotFileView: View { if FileManager.default.fileExists(atPath: filePath.path) { await MainActor.run { self.fileAlreadyExists = true - toast("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists", .warning) + onError("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists") } return } @@ -179,13 +182,13 @@ struct CreateCustomCopilotFileView: View { try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8) await MainActor.run { - toast("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'", .info) - NSWorkspace.shared.open(filePath) - self.isOpen.wrappedValue = false + onSuccess("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'") + NSWorkspace.openFileInXcode(fileURL: filePath) + dismiss() } } catch { await MainActor.run { - toast("Failed to create \(promptType.rawValue) file: \(error)", .error) + onError("Failed to create \(promptType.rawValue) file: \(error)") } } } diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index 5b0e967a..3110838f 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -55,8 +55,6 @@ public struct AutoresizingCustomTextEditor: View { onTextEditorStateChanged: onTextEditorStateChanged ) .frame(height: textEditorHeight) - .padding(.top, 1) - .padding(.bottom, -1) } } diff --git a/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift new file mode 100644 index 00000000..3896b01f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift @@ -0,0 +1,20 @@ +import SwiftUI + +public struct DestructiveButtonStyle: ButtonStyle { + public init() {} + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.red) + .padding(.horizontal, 13) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.25)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(configuration.isPressed ? 0.15 : 0)) + ) + ) + } +} diff --git a/Tool/Sources/SharedUIComponents/DownvoteButton.swift b/Tool/Sources/SharedUIComponents/DownvoteButton.swift index 703a2f24..b61e423c 100644 --- a/Tool/Sources/SharedUIComponents/DownvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/DownvoteButton.swift @@ -17,10 +17,10 @@ public struct DownvoteButton: View { }) { Image(systemName: isSelected ? "hand.thumbsdown.fill" : "hand.thumbsdown") .resizable() - .aspectRatio(contentMode: .fit) - .scaledFrame(width: 14, height: 14) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) - .padding(4) .help("Unhelpful") } .buttonStyle(HoverButtonStyle(padding: 0)) diff --git a/Tool/Sources/SharedUIComponents/InsertButton.swift b/Tool/Sources/SharedUIComponents/InsertButton.swift index dc465210..a6aca8c5 100644 --- a/Tool/Sources/SharedUIComponents/InsertButton.swift +++ b/Tool/Sources/SharedUIComponents/InsertButton.swift @@ -19,10 +19,10 @@ public struct InsertButton: View { }) { self.icon .resizable() - .aspectRatio(contentMode: .fit) - .scaledFrame(width: 14, height: 14) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) - .padding(4) } .buttonStyle(HoverButtonStyle(padding: 0)) .help("Insert at Cursor") diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift index 86a45280..8a17b57b 100644 --- a/Tool/Sources/SharedUIComponents/InstructionView.swift +++ b/Tool/Sources/SharedUIComponents/InstructionView.swift @@ -57,7 +57,7 @@ public struct Instruction: View { } } } - }.frame(maxWidth: 350) + }.scaledFrame(maxWidth: 350) } } } diff --git a/Tool/Sources/SharedUIComponents/OverlayScrollView.swift b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift new file mode 100644 index 00000000..751d3d4f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import AppKit + +public struct OverlayScrollView: NSViewRepresentable { + let showsVerticalScroller: Bool + let showsHorizontalScroller: Bool + let content: Content + + public init(showsVerticalScroller: Bool = true, + showsHorizontalScroller: Bool = false, + @ViewBuilder content: () -> Content) { + self.showsVerticalScroller = showsVerticalScroller + self.showsHorizontalScroller = showsHorizontalScroller + self.content = content() + } + + public func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = showsVerticalScroller + scrollView.hasHorizontalScroller = showsHorizontalScroller + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.verticalScrollElasticity = .automatic + scrollView.horizontalScrollElasticity = .automatic + + let hosting = NSHostingView(rootView: content) + hosting.translatesAutoresizingMaskIntoConstraints = false + + scrollView.documentView = hosting + + if let docView = scrollView.contentView.documentView { + docView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor).isActive = true + docView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor).isActive = true + docView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor).isActive = true + } + return scrollView + } + + public func updateNSView(_ nsView: NSScrollView, context: Context) { + if let hosting = nsView.documentView as? NSHostingView { + hosting.rootView = content + } + } +} diff --git a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift index 9693ac8b..08c33882 100644 --- a/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift +++ b/Tool/Sources/SharedUIComponents/ScaledComponent/ScaledFrame.swift @@ -4,6 +4,29 @@ extension View { public func scaledFrame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { ScaledFrameView(self, width: width, height: height, alignment: alignment) } + + /// Applies a scaled frame to the target view based on the current font scaling factor. + /// Use this function only when the target view requires dynamic scaling to adapt to font size changes. + public func scaledFrame( + minWidth: CGFloat? = nil, + idealWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + minHeight: CGFloat? = nil, + idealHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + alignment: Alignment = .center + ) -> some View { + ScaledConstraintFrameView( + self, + minWidth: minWidth, + idealWidth: idealWidth, + maxWidth: maxWidth, + minHeight: minHeight, + idealHeight: idealHeight, + maxHeight: maxHeight, + alignment: alignment + ) + } } struct ScaledFrameView: View { @@ -44,3 +67,61 @@ struct ScaledFrameView: View { .frame(width: scaledWidth, height: scaledHeight, alignment: alignment) } } + +struct ScaledConstraintFrameView: View { + let content: Content + let minWidth: CGFloat? + let idealWidth: CGFloat? + let maxWidth: CGFloat? + let minHeight: CGFloat? + let idealHeight: CGFloat? + let maxHeight: CGFloat? + let alignment: Alignment + + @StateObject private var fontScaleManager = FontScaleManager.shared + + var fontScale: Double { + fontScaleManager.currentScale + } + + private func getScaledValue(_ v: CGFloat?) -> CGFloat? { + guard let v = v else { + return nil + } + + return v * fontScale + } + + init( + _ content: Content, + minWidth: CGFloat? = nil, + idealWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + minHeight: CGFloat? = nil, + idealHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + alignment: Alignment = .center + ) { + self.content = content + self.minWidth = minWidth + self.idealWidth = idealWidth + self.maxWidth = maxWidth + self.minHeight = minHeight + self.idealHeight = idealHeight + self.maxHeight = maxHeight + self.alignment = alignment + } + + var body: some View { + content + .frame( + minWidth: getScaledValue(minWidth), + idealWidth: getScaledValue(idealWidth), + maxWidth: getScaledValue(maxWidth), + minHeight: getScaledValue(minHeight), + idealHeight: getScaledValue(idealHeight), + maxHeight: getScaledValue(maxHeight), + alignment: alignment + ) + } +} diff --git a/Tool/Sources/SharedUIComponents/SplitButton.swift b/Tool/Sources/SharedUIComponents/SplitButton.swift new file mode 100644 index 00000000..8ddead7b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/SplitButton.swift @@ -0,0 +1,292 @@ +import SwiftUI +import AppKit + +// MARK: - SplitButton Menu Item + +public struct SplitButtonMenuItem: Identifiable { + public enum Kind { + case action(() -> Void) + case divider + case header + } + + public let id: UUID + public let title: String + public let kind: Kind + + public init(title: String, action: @escaping () -> Void) { + self.id = UUID() + self.title = title + self.kind = .action(action) + } + + private init(id: UUID = UUID(), title: String, kind: Kind) { + self.id = id + self.title = title + self.kind = kind + } + + public static func divider(id: UUID = UUID()) -> SplitButtonMenuItem { + .init(id: id, title: "", kind: .divider) + } + + public static func header(_ title: String, id: UUID = UUID()) -> SplitButtonMenuItem { + .init(id: id, title: title, kind: .header) + } +} + +@available(macOS 13.0, *) +private enum SplitButtonMenuBuilder { + static func buildMenu( + items: [SplitButtonMenuItem], + pullsDownCoverItem: Bool, + target: NSObject, + action: Selector, + menuItemActions: inout [UUID: () -> Void] + ) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menuItemActions.removeAll() + + if pullsDownCoverItem { + // First item is the "cover" item for pullsDown + menu.addItem(NSMenuItem(title: "", action: nil, keyEquivalent: "")) + } + + for item in items { + switch item.kind { + case .divider: + menu.addItem(.separator()) + + case .header: + if #available(macOS 14.0, *) { + menu.addItem(NSMenuItem.sectionHeader(title: item.title)) + } else { + let headerItem = NSMenuItem() + headerItem.title = item.title + headerItem.isEnabled = false + menu.addItem(headerItem) + } + + case .action(let handler): + let menuItem = NSMenuItem( + title: item.title, + action: action, + keyEquivalent: "" + ) + menuItem.target = target + menuItem.representedObject = item.id + menuItemActions[item.id] = handler + menu.addItem(menuItem) + } + } + + return menu + } +} + +// MARK: - SplitButton using NSComboButton + +@available(macOS 13.0, *) +public struct SplitButton: View { + let title: String + let primaryAction: () -> Void + let isDisabled: Bool + let menuItems: [SplitButtonMenuItem] + var style: SplitButtonStyle + + @AppStorage(\.fontScale) private var fontScale + + public enum SplitButtonStyle { + case standard + case prominent + } + + public init( + title: String, + isDisabled: Bool = false, + primaryAction: @escaping () -> Void, + menuItems: [SplitButtonMenuItem] = [], + style: SplitButtonStyle = .standard + ) { + self.title = title + self.isDisabled = isDisabled + self.primaryAction = primaryAction + self.menuItems = menuItems + self.style = style + } + + public var body: some View { + switch style { + case .standard: + SplitButtonRepresentable( + title: title, + isDisabled: isDisabled, + primaryAction: primaryAction, + menuItems: menuItems + ) + case .prominent: + HStack(spacing: 0) { + Button(action: primaryAction) { + Text(title) + .scaledFont(.body) + .padding(.horizontal, 6) + .padding(.vertical, 4) + } + .buttonStyle(.borderless) + + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(width: fontScale) + .padding(.vertical, 4) + + ProminentMenuButton( + menuItems: menuItems, + isDisabled: isDisabled + ) + .frame(width: 16) + } + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .disabled(isDisabled) + .opacity(isDisabled ? 0.5 : 1) + } + } +} + +@available(macOS 13.0, *) +private struct ProminentMenuButton: NSViewRepresentable { + let menuItems: [SplitButtonMenuItem] + let isDisabled: Bool + + func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: true) + button.bezelStyle = .smallSquare + button.isBordered = false + button.imagePosition = .imageOnly + + updateImage(for: button) + + button.contentTintColor = .white + + return button + } + + func updateNSView(_ nsView: NSPopUpButton, context: Context) { + nsView.isEnabled = !isDisabled + nsView.contentTintColor = isDisabled ? NSColor.white.withAlphaComponent(0.5) : .white + + updateImage(for: nsView) + + context.coordinator.updateMenu(for: nsView, with: menuItems) + } + + private func updateImage(for button: NSPopUpButton) { + let config = NSImage.SymbolConfiguration(textStyle: .body) + let image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: "More options")? + .withSymbolConfiguration(config) + button.image = image + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject { + private var menuItemActions: [UUID: () -> Void] = [:] + + func updateMenu(for button: NSPopUpButton, with items: [SplitButtonMenuItem]) { + button.menu = SplitButtonMenuBuilder.buildMenu( + items: items, + pullsDownCoverItem: true, + target: self, + action: #selector(handleMenuItemAction(_:)), + menuItemActions: &menuItemActions + ) + } + + @objc func handleMenuItemAction(_ sender: NSMenuItem) { + if let itemId = sender.representedObject as? UUID, + let action = menuItemActions[itemId] { + action() + } + } + } +} + +@available(macOS 13.0, *) +struct SplitButtonRepresentable: NSViewRepresentable { + let title: String + let primaryAction: () -> Void + let isDisabled: Bool + let menuItems: [SplitButtonMenuItem] + + init( + title: String, + isDisabled: Bool = false, + primaryAction: @escaping () -> Void, + menuItems: [SplitButtonMenuItem] = [] + ) { + self.title = title + self.isDisabled = isDisabled + self.primaryAction = primaryAction + self.menuItems = menuItems + } + + func makeNSView(context: Context) -> NSComboButton { + let button = NSComboButton() + + button.title = title + button.target = context.coordinator + button.action = #selector(Coordinator.handlePrimaryAction) + button.isEnabled = !isDisabled + + + context.coordinator.button = button + context.coordinator.updateMenu(with: menuItems) + + return button + } + + func updateNSView(_ nsView: NSComboButton, context: Context) { + nsView.title = title + nsView.isEnabled = !isDisabled + context.coordinator.updateMenu(with: menuItems) + } + + func makeCoordinator() -> Coordinator { + Coordinator(primaryAction: primaryAction) + } + + class Coordinator: NSObject { + let primaryAction: () -> Void + weak var button: NSComboButton? + private var menuItemActions: [UUID: () -> Void] = [:] + + init(primaryAction: @escaping () -> Void) { + self.primaryAction = primaryAction + } + + @objc func handlePrimaryAction() { + primaryAction() + } + + @objc func handleMenuItemAction(_ sender: NSMenuItem) { + if let itemId = sender.representedObject as? UUID, + let action = menuItemActions[itemId] { + action() + } + } + + func updateMenu(with items: [SplitButtonMenuItem]) { + button?.menu = SplitButtonMenuBuilder.buildMenu( + items: items, + pullsDownCoverItem: false, + target: self, + action: #selector(handleMenuItemAction(_:)), + menuItemActions: &menuItemActions + ) + } + } +} diff --git a/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift similarity index 78% rename from Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift rename to Tool/Sources/SharedUIComponents/TextFieldsContainer.swift index 6cf592f3..b4c9bcc9 100644 --- a/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift +++ b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift @@ -1,13 +1,13 @@ import SwiftUI -struct TextFieldsContainer: View { +public struct TextFieldsContainer: View { let content: Content - init(@ViewBuilder content: () -> Content) { + public init(@ViewBuilder content: () -> Content) { self.content = content() } - var body: some View { + public var body: some View { VStack(spacing: 8) { content } diff --git a/Tool/Sources/SharedUIComponents/UpvoteButton.swift b/Tool/Sources/SharedUIComponents/UpvoteButton.swift index 7a0b88b6..1af8ebf7 100644 --- a/Tool/Sources/SharedUIComponents/UpvoteButton.swift +++ b/Tool/Sources/SharedUIComponents/UpvoteButton.swift @@ -17,10 +17,10 @@ public struct UpvoteButton: View { }) { Image(systemName: isSelected ? "hand.thumbsup.fill" : "hand.thumbsup") .resizable() - .aspectRatio(contentMode: .fit) - .scaledFrame(width: 14, height: 14) + .scaledToFit() + .scaledPadding(2) + .scaledFrame(width: 16, height: 16) .foregroundColor(.secondary) - .padding(4) .help("Helpful") } .buttonStyle(HoverButtonStyle(padding: 0)) diff --git a/Tool/Sources/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift index f1b2d1d3..4f073716 100644 --- a/Tool/Sources/StatusBarItemView/QuotaView.swift +++ b/Tool/Sources/StatusBarItemView/QuotaView.swift @@ -68,7 +68,6 @@ public class QuotaView: NSView { autoresizingMask = [.width] setupView() - layoutSubtreeIfNeeded() let calculatedHeight = fittingSize.height frame = NSRect(x: 0, y: 0, width: Layout.viewWidth, height: calculatedHeight) } @@ -374,11 +373,19 @@ extension QuotaView { button.translatesAutoresizingMaskIntoConstraints = false button.bezelStyle = .push if isFreeQuotaUsedUp { - button.attributedTitle = NSAttributedString( - string: upgradeTitle, - attributes: [.foregroundColor: NSColor.white] - ) - button.bezelColor = .controlAccentColor + if #available(macOS 26.0, *) { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.controlTextColor] + ) + button.bezelColor = .controlBackgroundColor + } else { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.white] + ) + button.bezelColor = .controlAccentColor + } } else { button.title = upgradeTitle } diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift index bd124fc1..e910af17 100644 --- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -1,6 +1,10 @@ import Foundation import CodableWrappers +public enum CodeSuggestionType: String { + case codeCompletion, nes +} + public struct CodeSuggestion: Codable, Equatable { public init( id: String, diff --git a/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift b/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift new file mode 100644 index 00000000..c088a688 --- /dev/null +++ b/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift @@ -0,0 +1,9 @@ +import CopilotForXcodeKit + +public protocol NESSuggestionServiceType { + func getNESSuggestions( + _ request: CopilotForXcodeKit.SuggestionRequest, + workspace: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] +} + diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift index e69e29d2..3a60489a 100644 --- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -19,6 +19,23 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle return suggestion } } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + let suggestions = try await next(request) + + return suggestions.compactMap { + var suggestion = $0 + if suggestion.text.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return nil } + Self.removeTrailingWhitespacesAndNewlines(&suggestion) + // TODO: If need to check? + // if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil } + return suggestion + } + } static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) { var text = suggestion.text[...] diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index dcbfba5e..1bec7d30 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -10,6 +10,12 @@ public protocol SuggestionServiceMiddleware { configuration: SuggestionServiceConfiguration, next: Next ) async throws -> [CodeSuggestion] + + func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] } public enum SuggestionServiceMiddlewareContainer { @@ -49,6 +55,24 @@ public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMidd return try await next(request) } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + let language = languageIdentifierFromFileURL(request.fileURL) + if UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList) + .contains(where: { $0 == language.rawValue }) + { + #if DEBUG + Logger.service.info("Suggestion service is disabled for \(language).") + #endif + return [] + } + + return try await next(request) + } } public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { @@ -76,5 +100,28 @@ public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { throw error } } + + public func getNESSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] { + Logger.service.info(""" + Get suggestion for \(request.fileURL) at \(request.cursorPosition) + """) + do { + let suggestions = try await next(request) + Logger.service.info(""" + Receive \(suggestions.count) suggestions for \(request.fileURL) \ + at \(request.cursorPosition) + """) + return suggestions + } catch { + Logger.service.info(""" + Error: \(error.localizedDescription) + """) + throw error + } + } } diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift index 24265613..bec85e8f 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift @@ -63,6 +63,10 @@ public protocol SuggestionServiceProvider { _ request: SuggestionRequest, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async throws -> [CodeSuggestion] + func getNESSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo, + ) async throws -> [CodeSuggestion] func notifyAccepted( _ suggestion: CodeSuggestion, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo diff --git a/Tool/Sources/SystemUtils/SystemUtils.swift b/Tool/Sources/SystemUtils/SystemUtils.swift index 43569b88..fb785f45 100644 --- a/Tool/Sources/SystemUtils/SystemUtils.swift +++ b/Tool/Sources/SystemUtils/SystemUtils.swift @@ -38,6 +38,21 @@ public class SystemUtils { public static let buildType: String = { return shared.isDeveloperMode() ? "true" : "false" }() + + public static let isDeveloperMode: Bool = { + return shared.isDeveloperMode() + }() + + public static let isPrereleaseBuild: Bool = { + let components = editorPluginVersionString.split(separator: ".") + if components.count >= 3 { + let patchComponent = String(components[2]) + // If patch version is not "0" + return patchComponent != "0" + } + + return false + }() private init() {} diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 8da014a5..da77d574 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -27,6 +27,7 @@ public final class FilespacePropertyValues { } public struct FilespaceCodeMetadata: Equatable { + /// Stands for `Uniform Type Identifier` public var uti: String? public var tabSize: Int? public var indentSize: Int? @@ -66,6 +67,7 @@ public final class Filespace { // MARK: Metadata public let fileURL: URL + public private(set) var fileContent: String? = nil public private(set) lazy var language: CodeLanguage = languageIdentifierFromFileURL(fileURL) public var codeMetadata: FilespaceCodeMetadata = .init() public var isTextReadable: Bool { @@ -76,13 +78,22 @@ public final class Filespace { public private(set) var suggestionIndex: Int = 0 public internal(set) var suggestions: [CodeSuggestion] = [] { - didSet { refreshUpdateTime() } + didSet{ refreshUpdateTime() } + } + // Use Array for potential extensibility + public internal(set) var nesSuggestions: [CodeSuggestion] = [] { + didSet { refreshNESUpdateTime() } } public var presentingSuggestion: CodeSuggestion? { guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } return suggestions[suggestionIndex] } + + public var presentingNESSuggestion: CodeSuggestion? { + // Currently, only one nes suggestion will exist there + return nesSuggestions.first + } public private(set) var errorMessage: String = "" { didSet { refreshUpdateTime() } @@ -93,8 +104,13 @@ public final class Filespace { public var isExpired: Bool { Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3 } + + public var isNESExpired: Bool { + Environment.now().timeIntervalSince(lastNESUpdateTime) > 60 * 3 + } public private(set) var lastUpdateTime: Date = Environment.now() + public private(set) var lastNESUpdateTime: Date = Environment.now() private var additionalProperties = FilespacePropertyValues() let fileSaveWatcher: FileSaveWatcher let onClose: (URL) -> Void @@ -110,15 +126,19 @@ public final class Filespace { init( fileURL: URL, + content: String, onSave: @escaping (Filespace) -> Void, onClose: @escaping (URL) -> Void ) { self.fileURL = fileURL + self.fileContent = content self.onClose = onClose fileSaveWatcher = .init(fileURL: fileURL) fileSaveWatcher.changeHandler = { [weak self] in guard let self else { return } + // TODO: should distinguish code completion and NES? onSave(self) + self.fileContent = try? String(contentsOf: self.fileURL) } } @@ -135,6 +155,11 @@ public final class Filespace { suggestions = [] suggestionIndex = 0 } + + @WorkspaceActor + public func resetNESSuggestion() { + nesSuggestions = [] + } @WorkspaceActor public func updateSuggestionsWithSameSelection(_ suggestions: [CodeSuggestion]) { @@ -145,11 +170,25 @@ public final class Filespace { public func refreshUpdateTime() { lastUpdateTime = Environment.now() } + + public func refreshNESUpdateTime() { + lastNESUpdateTime = Date.now + } @WorkspaceActor public func setSuggestions(_ suggestions: [CodeSuggestion]) { self.suggestions = suggestions suggestionIndex = 0 + if !self.suggestions.isEmpty { + self.resetNESSuggestion() + } + } + + @WorkspaceActor + public func setNESSuggestions(_ nesSuggestions: [CodeSuggestion]) { + // Only when there is no code completion suggestion, NES suggestion can be set + guard self.suggestions.isEmpty else { return } + self.nesSuggestions = nesSuggestions } @WorkspaceActor @@ -182,5 +221,23 @@ public final class Filespace { public func dismissError() { errorMessage = "" } + + @WorkspaceActor + public func updateCodeMetadata( + uti: String, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) { + self.codeMetadata.uti = uti + self.codeMetadata.tabSize = tabSize + self.codeMetadata.indentSize = indentSize + self.codeMetadata.usesTabsForIndentation = usesTabsForIndentation + } + + @WorkspaceActor + public func setFileContent(_ content: String) { + fileContent = content + } } diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 82248822..a179bc83 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -4,6 +4,7 @@ import UserDefaultsObserver import XcodeInspector import Logger import UniformTypeIdentifiers +import LanguageServerProtocol enum Environment { static var now = { Date() } @@ -43,9 +44,9 @@ open class WorkspacePlugin { self.workspace = workspace } - open func didOpenFilespace(_: Filespace) {} + open func didOpenFilespace(_: Filespace) async {} open func didSaveFilespace(_: Filespace) {} - open func didUpdateFilespace(_: Filespace, content: String) {} + open func didUpdateFilespace(_: Filespace, content: String, contentChanges: [TextDocumentContentChangeEvent]?) async {} open func didCloseFilespace(_: URL) {} } @@ -115,7 +116,7 @@ public final class Workspace { Task { @WorkspaceActor in for fileURL in openedFiles { do { - _ = try createFilespaceIfNeeded(fileURL: fileURL) + _ = try await createFilespaceIfNeeded(fileURL: fileURL) } catch _ as WorkspaceFileError { openedFileRecoverableStorage.closeFile(fileURL: fileURL) } catch { @@ -130,7 +131,7 @@ public final class Workspace { } @WorkspaceActor - public func createFilespaceIfNeeded(fileURL: URL) throws -> Filespace { + public func createFilespaceIfNeeded(fileURL: URL) async throws -> Filespace { let extensionName = fileURL.pathExtension if ["xcworkspace", "xcodeproj"].contains( @@ -149,9 +150,12 @@ public final class Workspace { throw WorkspaceFileError.invalidFileFormat(fileURL: fileURL) } + let content = try String(contentsOf: fileURL) + let existedFilespace = filespaces[fileURL] let filespace = existedFilespace ?? .init( fileURL: fileURL, + content: content, onSave: { [weak self] filespace in guard let self else { return } self.didSaveFilespace(filespace) @@ -165,7 +169,7 @@ public final class Workspace { filespaces[fileURL] = filespace } if existedFilespace == nil { - didOpenFilespace(filespace) + await didOpenFilespace(filespace) } else { filespace.refreshUpdateTime() } @@ -178,22 +182,38 @@ public final class Workspace { } @WorkspaceActor - public func didUpdateFilespace(fileURL: URL, content: String) { + public func didUpdateFilespace(fileURL: URL, content: String) async { refreshUpdateTime() guard let filespace = filespaces[fileURL] else { return } filespace.bumpVersion() filespace.refreshUpdateTime() + + let oldContent = filespace.fileContent + + // Calculate incremental changes if NES is enabled and we have old content + let changes: [TextDocumentContentChangeEvent]? = { + guard let oldContent = oldContent else { return nil } + return calculateIncrementalChanges(oldContent: oldContent, newContent: content) + }() + for plugin in plugins.values { - plugin.didUpdateFilespace(filespace, content: content) + if let changes, let oldContent { + await plugin.didUpdateFilespace(filespace, content: oldContent, contentChanges: changes) + } else { + // fallback to full content sync + await plugin.didUpdateFilespace(filespace, content: content, contentChanges: nil) + } } + + filespace.setFileContent(content) } @WorkspaceActor - func didOpenFilespace(_ filespace: Filespace) { + public func didOpenFilespace(_ filespace: Filespace) async { refreshUpdateTime() openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) for plugin in plugins.values { - plugin.didOpenFilespace(filespace) + await plugin.didOpenFilespace(filespace) } } @@ -214,3 +234,138 @@ public final class Workspace { } } +extension Workspace { + static let maxCalculationLength = 200_000 + + /// Calculates incremental changes between two document states. + /// Each change is computed on the state resulting from the previous change, + /// as required by the LSP specification. + /// + /// This implementation finds the common prefix and suffix, then creates + /// a single change event for the differing middle section. This ensures + /// correctness while being efficient for typical editing scenarios. + /// + /// - Parameters: + /// - oldContent: The original document content + /// - newContent: The new document content + /// - Returns: Array of TextDocumentContentChangeEvent in order + func calculateIncrementalChanges( + oldContent: String, + newContent: String + ) -> [TextDocumentContentChangeEvent]? { + // Handle identical content + if oldContent == newContent { + return nil + } + + // Handle empty old content (new file) + if oldContent.isEmpty { + let endPosition = calculateEndPosition(content: oldContent) + return [TextDocumentContentChangeEvent( + range: LSPRange( + start: Position(line: 0, character: 0), + end: Position(line: 0, character: 0) + ), + rangeLength: 0, + text: newContent + )] + } + + // Handle empty new content (cleared file) + if newContent.isEmpty { + let endPosition = calculateEndPosition(content: oldContent) + return [TextDocumentContentChangeEvent( + range: LSPRange( + start: Position(line: 0, character: 0), + end: endPosition + ), + rangeLength: oldContent.utf16.count, + text: "" + )] + } + + // Find common prefix + let oldUTF16 = Array(oldContent.utf16) + let newUTF16 = Array(newContent.utf16) + guard oldUTF16.count <= Self.maxCalculationLength, + newUTF16.count <= Self.maxCalculationLength else { + // Fallback to full replacement for very large contents + return nil + } + + var prefixLength = 0 + let minLength = min(oldUTF16.count, newUTF16.count) + while prefixLength < minLength && oldUTF16[prefixLength] == newUTF16[prefixLength] { + prefixLength += 1 + } + + // Find common suffix (after prefix) + var suffixLength = 0 + while suffixLength < minLength - prefixLength && + oldUTF16[oldUTF16.count - 1 - suffixLength] == newUTF16[newUTF16.count - 1 - suffixLength] { + suffixLength += 1 + } + + // Calculate positions + let startPosition = utf16OffsetToPosition( + content: oldContent, + offset: prefixLength + ) + + let endOffset = oldUTF16.count - suffixLength + let endPosition = utf16OffsetToPosition( + content: oldContent, + offset: endOffset + ) + + // Extract replacement text from new content + let newStartOffset = prefixLength + let newEndOffset = newUTF16.count - suffixLength + + let replacementText: String + if newStartOffset <= newEndOffset { + let startIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newStartOffset) + let endIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newEndOffset) + replacementText = String(newContent[startIndex.. Position { + var line = 0 + var character = 0 + + let utf16View = content.utf16 + let safeOffset = min(offset, utf16View.count) + let endIndex = utf16View.index(utf16View.startIndex, offsetBy: safeOffset) + + for char in utf16View[.. Position { + return utf16OffsetToPosition(content: content, offset: content.utf16.count) + } +} diff --git a/Tool/Sources/Workspace/WorkspaceDependency.swift b/Tool/Sources/Workspace/WorkspaceDependency.swift new file mode 100644 index 00000000..25ad22fb --- /dev/null +++ b/Tool/Sources/Workspace/WorkspaceDependency.swift @@ -0,0 +1,20 @@ +import Dependencies +import Foundation + +public final class WorkspaceInvoker { + // Manually trigger the update of the filespace + public var invokeFilespaceUpdate: (URL, String) async -> Void = { _, _ in } + + public init() {} +} + +struct WorkspaceInvokerKey: DependencyKey { + static let liveValue = WorkspaceInvoker() +} + +public extension DependencyValues { + var workspaceInvoker: WorkspaceInvoker { + get { self[WorkspaceInvokerKey.self] } + set { self[WorkspaceInvokerKey.self] = newValue } + } +} diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 9807702d..44468e07 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -67,7 +67,24 @@ public class WorkspacePool { if filespaces.count == 1 { return filespaces.first } Logger.workspacePool.info("Multiple workspaces found with file: \(fileURL)") // If multiple workspaces are found, return the first with a suggestion - return filespaces.first { $0.presentingSuggestion != nil } + return filespaces.first { $0.presentingSuggestion != nil } ?? filespaces.first { $0.presentingNESSuggestion != nil } + } + + public func fetchWorkspaceAndFilespace(fileURL: URL) -> (Workspace, Filespace)? { + var workspace: Workspace? + var filespace: Filespace? + + for wp in workspaces.values { + if let fp = wp.filespaces[fileURL] { + if fp.presentingSuggestion != nil || fp.presentingNESSuggestion != nil { + return (wp, fp) + } + workspace = wp + filespace = fp + } + } + + return workspace.flatMap { ws in filespace.map { fs in (ws, fs) } } } @WorkspaceActor @@ -93,13 +110,13 @@ public class WorkspacePool { if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { // Reuse the existed workspace. - let filespace = try existed.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await existed.createFilespaceIfNeeded(fileURL: fileURL) return (existed, filespace) } let new = createNewWorkspace(workspaceURL: currentWorkspaceURL) workspaces[currentWorkspaceURL] = new - let filespace = try new.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await new.createFilespaceIfNeeded(fileURL: fileURL) return (new, filespace) } @@ -133,7 +150,7 @@ public class WorkspacePool { return createNewWorkspace(workspaceURL: workspaceURL) }() - let filespace = try workspace.createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await workspace.createFilespaceIfNeeded(fileURL: fileURL) workspaces[workspaceURL] = workspace workspace.refreshUpdateTime() return (workspace, filespace) diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 47e1d9dc..03656855 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -4,6 +4,7 @@ import Workspace import XPCShared public struct FilespaceSuggestionSnapshot: Equatable { + public let lines: [String] public let linesHash: Int public let prefixLinesHash: Int public let suffixLinesHash: Int @@ -15,6 +16,7 @@ public struct FilespaceSuggestionSnapshot: Equatable { return max(min(index, lines.endIndex), lines.startIndex) } + self.lines = lines self.linesHash = lines.hashValue self.cursorPosition = cursorPosition self.prefixLinesHash = lines[0.. FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } } +public struct FilespaceNESSuggestionSnapshotKey: FilespacePropertyKey { + public static func createDefaultValue() + -> FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } +} + public extension FilespacePropertyValues { @WorkspaceActor var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { get { self[FilespaceSuggestionSnapshotKey.self] } set { self[FilespaceSuggestionSnapshotKey.self] = newValue } } + + @WorkspaceActor + var nesSuggestionSourceSnapshot: FilespaceSuggestionSnapshot { + get { self[FilespaceNESSuggestionSnapshotKey.self] } + set { self[FilespaceNESSuggestionSnapshotKey.self] = newValue } + } } public extension Filespace { @@ -53,6 +66,13 @@ public extension Filespace { self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() // swiftformat:enable all } + + @WorkspaceActor + func resetNESSnapshot() { + // swiftformat:disable redundantSelf + self.nesSuggestionSourceSnapshot = FilespaceNESSuggestionSnapshotKey.createDefaultValue() + // swiftformat:enable all + } /// Validate the suggestion is still valid. /// - Parameters: @@ -125,6 +145,26 @@ public extension Filespace { resetSnapshot() return false } - + + /// Validate the nes suggestion is still valid. + /// - Parameters: + /// - lines: lines of the file + /// - cursorPosition: cursor position + /// - Returns: `true` if the nes suggestion is still valid + @WorkspaceActor + func validateNESSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { + guard let presentingNESSuggestion else { return false } + + let updatedSnapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition) + + // document state is unchanged + if updatedSnapshot == self.nesSuggestionSourceSnapshot { + return true + } + + resetNESSuggestion() + resetNESSnapshot() + return false + } } diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index e0c3f0f1..d59859bb 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -46,6 +46,52 @@ public extension Workspace { func generateSuggestions( forFileAt fileURL: URL, editor: EditorContent + ) async throws -> [CodeSuggestion] { + refreshUpdateTime() + + guard editor.cursorPosition != .outOfScope else { + throw EditorCursorOutOfScopeError() + } + + let filespace = try await createFilespaceIfNeeded(fileURL: fileURL) + + if !editor.uti.isEmpty { + filespace.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + filespace.codeMetadata.guessLineEnding(from: editor.lines.first) + + let snapshot = FilespaceSuggestionSnapshot(content: editor) + filespace.suggestionSourceSnapshot = snapshot + + guard let suggestionService else { throw SuggestionFeatureDisabledError() } + let content = editor.lines.joined(separator: "") + let completions = try await suggestionService.getSuggestions( + .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL), + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) + + let clsStatus = await Status.shared.getCLSStatus() + if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") { + filespace.setError(clsStatus.message) + } else { + filespace.setError("") + filespace.setSuggestions(completions) + } + + return completions +} + + @WorkspaceActor + @discardableResult + func generateNESSuggestions( + forFileAt fileURL: URL, + editor: EditorContent ) async throws -> [CodeSuggestion] { refreshUpdateTime() @@ -53,48 +99,33 @@ public extension Workspace { throw EditorCursorOutOfScopeError() } - let filespace = try createFilespaceIfNeeded(fileURL: fileURL) + let filespace = try await createFilespaceIfNeeded(fileURL: fileURL) if !editor.uti.isEmpty { - filespace.codeMetadata.uti = editor.uti - filespace.codeMetadata.tabSize = editor.tabSize - filespace.codeMetadata.indentSize = editor.indentSize - filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespace.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } filespace.codeMetadata.guessLineEnding(from: editor.lines.first) let snapshot = FilespaceSuggestionSnapshot(content: editor) - filespace.suggestionSourceSnapshot = snapshot + filespace.nesSuggestionSourceSnapshot = snapshot guard let suggestionService else { throw SuggestionFeatureDisabledError() } let content = editor.lines.joined(separator: "") - let completions = try await suggestionService.getSuggestions( - .init( - fileURL: fileURL, - relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), - content: content, - originalContent: content, - lines: editor.lines, - cursorPosition: editor.cursorPosition, - cursorOffset: editor.cursorOffset, - tabSize: editor.tabSize, - indentSize: editor.indentSize, - usesTabsForIndentation: editor.usesTabsForIndentation, - relevantCodeSnippets: [] - ), + let completions = try await suggestionService.getNESSuggestions( + .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL), workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) ) - - let clsStatus = await Status.shared.getCLSStatus() - if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") { - filespace.setError(clsStatus.message) - } else { - filespace.setError("") - filespace.setSuggestions(completions) - } - + + // TODO: How to get the `limit reached` error? Same as Code Completion? + filespace.setNESSuggestions(completions) + return completions } @@ -124,16 +155,27 @@ public extension Workspace { } } } + + @WorkspaceActor + func notifyNESSuggestionShown(forFileAt fileURL: URL) { + if let suggestion = filespaces[fileURL]?.presentingNESSuggestion { + Task { + await gitHubCopilotService?.notifyCopilotInlineEditShown(suggestion) + } + } + } @WorkspaceActor func rejectSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { refreshUpdateTime() if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.codeMetadata.uti = editor.uti - filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize - filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize - filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } Task { @@ -147,6 +189,31 @@ public extension Workspace { } filespaces[fileURL]?.reset() } + + @WorkspaceActor + func rejectNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?) { + refreshUpdateTime() + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + Task { + await suggestionService?.notifyRejected( + filespaces[fileURL]?.nesSuggestions ?? [], + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) + } + filespaces[fileURL]?.resetNESSuggestion() + } @WorkspaceActor func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? { @@ -158,10 +225,12 @@ public extension Workspace { else { return nil } if let editor, !editor.uti.isEmpty { - filespaces[fileURL]?.codeMetadata.uti = editor.uti - filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize - filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize - filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) } var allSuggestions = filespace.suggestions @@ -184,5 +253,57 @@ public extension Workspace { return suggestion } + + @WorkspaceActor + func acceptNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + let suggestion = filespace.presentingNESSuggestion + else { return nil } + + if let editor, !editor.uti.isEmpty { + filespaces[fileURL]?.updateCodeMetadata( + uti: editor.uti, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + } + + Task { + await gitHubCopilotService?.notifyCopilotInlineEditAccepted(suggestion) + } + + filespace.resetNESSuggestion() + filespace.resetNESSnapshot() + + return suggestion + } + + @WorkspaceActor + func getNESSuggestion(forFileAt fileURL: URL) -> CodeSuggestion? { + guard let filespace = filespaces[fileURL], + let suggestion = filespace.presentingNESSuggestion + else { return nil } + + return suggestion + } } +extension SuggestionRequest { + static func from(fileURL: URL, content: String, editor: EditorContent, projectRootURL: URL) -> Self { + return .init( + fileURL: fileURL, + relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""), + content: content, + originalContent: content, + lines: editor.lines, + cursorPosition: editor.cursorPosition, + cursorOffset: editor.cursorOffset, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation, + relevantCodeSnippets: [] + ) + } +} diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index c48cb640..4e4d59f5 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -3,6 +3,7 @@ import GitHubCopilotService import ConversationServiceProvider import Logger import Status +import LanguageServerProtocol public enum XPCExtensionServiceError: Swift.Error, LocalizedError { case failedToGetServiceEndpoint @@ -19,6 +20,15 @@ public enum XPCExtensionServiceError: Swift.Error, LocalizedError { return "Connection to extension service error: \(error.localizedDescription)" } } + + public var underlyingError: Error? { + switch self { + case let .xpcServiceError(error): + return error + default: + return nil + } + } } public class XPCExtensionService { @@ -109,6 +119,13 @@ public class XPCExtensionService { { $0.getSuggestionAcceptedCode } ) } + + public func getNESSuggestionAcceptedCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.getNESSuggestionAcceptedCode } + ) + } public func getSuggestionRejectedCode(editorContent: EditorContent) async throws -> UpdatedContent? @@ -118,6 +135,15 @@ public class XPCExtensionService { { $0.getSuggestionRejectedCode } ) } + + public func getNESSuggestionRejectedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getNESSuggestionRejectedCode } + ) + } public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? @@ -149,6 +175,19 @@ public class XPCExtensionService { } } as Void } + + public func toggleRealtimeNES() async throws { + try await withXPCServiceConnected { + service, continuation in + service.toggleRealtimeNES { error in + if let error { + continuation.reject(error) + return + } + continuation.resume(()) + } + } as Void + } public func prefetchRealtimeSuggestions(editorContent: EditorContent) async { guard let data = try? JSONEncoder().encode(editorContent) else { return } @@ -382,12 +421,25 @@ extension XPCExtensionService { } @XPCServiceActor - public func updateMCPServerToolsStatus(_ update: [UpdateMCPToolsStatusServerCollection]) async throws { + public func updateMCPServerToolsStatus( + _ update: [UpdateMCPToolsStatusServerCollection], + chatAgentMode: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil + ) async throws { return try await withXPCServiceConnected { service, continuation in do { let data = try JSONEncoder().encode(update) - service.updateMCPServerToolsStatus(tools: data) + let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) } + let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) } + service.updateMCPServerToolsStatus( + tools: data, + chatAgentMode: modeData, + customChatModeId: modeIdData, + workspaceFolders: foldersData + ) continuation.resume(()) } catch { continuation.reject(error) @@ -457,6 +509,31 @@ extension XPCExtensionService { } } + @XPCServiceActor + public func getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult? { + return try await withXPCServiceConnected { + service, continuation in + service.getMCPRegistryAllowlist { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let response = try JSONDecoder().decode(GetMCPRegistryAllowlistResult.self, from: data) + continuation.resume(response) + } catch { + continuation.reject(error) + } + } + } + } + @XPCServiceActor public func getAvailableLanguageModelTools() async throws -> [LanguageModelTool]? { return try await withXPCServiceConnected { @@ -478,12 +555,45 @@ extension XPCExtensionService { } @XPCServiceActor - public func updateToolsStatus(_ update: [ToolStatusUpdate]) async throws -> [LanguageModelTool]? { + public func refreshClientTools() async throws -> [LanguageModelTool]? { + return try await withXPCServiceConnected { + service, continuation in + service.refreshClientTools { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let tools = try JSONDecoder().decode([LanguageModelTool].self, from: data) + continuation.resume(tools) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func updateToolsStatus( + _ update: [ToolStatusUpdate], + chatAgentMode: ChatMode? = nil, + customChatModeId: String? = nil, + workspaceFolders: [WorkspaceFolder]? = nil + ) async throws -> [LanguageModelTool]? { return try await withXPCServiceConnected { service, continuation in do { let data = try JSONEncoder().encode(update) - service.updateToolsStatus(tools: data) { data in + let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) } + let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) } + service.updateToolsStatus( + tools: data, + chatAgentMode: modeData, + customChatModeId: modeIdData, + workspaceFolders: foldersData + ) { data in guard let data else { continuation.resume(nil) return @@ -522,6 +632,52 @@ extension XPCExtensionService { } } + @XPCServiceActor + public func getCopilotPolicy() async throws -> CopilotPolicy? { + return try await withXPCServiceConnected { + service, continuation in + service.getCopilotPolicy { data in + guard let data else { + continuation.resume(nil) + return + } + + do { + let copilotPolicy = try JSONDecoder().decode(CopilotPolicy.self, from: data) + continuation.resume(copilotPolicy) + } catch { + continuation.reject(error) + } + } + } + } + + @XPCServiceActor + public func getModes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode]? { + return try await withXPCServiceConnected { + service, continuation in + let workspaceFoldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) } + service.getModes(workspaceFolders: workspaceFoldersData) { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let modes = try JSONDecoder().decode([ConversationMode].self, from: data) + continuation.resume(modes) + } catch { + continuation.reject(error) + } + } + } + } + @XPCServiceActor public func signOutAllGitHubCopilotService() async throws { return try await withXPCServiceConnected { @@ -549,6 +705,31 @@ extension XPCExtensionService { } } + @XPCServiceActor + public func updateCopilotModels() async throws -> [CopilotModel]? { + return try await withXPCServiceConnected { + service, continuation in + service.updateCopilotModels { data, error in + if let error { + continuation.reject(error) + return + } + + guard let data else { + continuation.resume(nil) + return + } + + do { + let models = try JSONDecoder().decode([CopilotModel].self, from: data) + continuation.resume(models) + } catch { + continuation.reject(error) + } + } + } + } + // MARK: BYOK @XPCServiceActor public func saveBYOKApiKey(_ params: BYOKSaveApiKeyParams) async throws -> BYOKSaveApiKeyResponse? { diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 1ffddc10..4489233f 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -8,13 +8,17 @@ public protocol XPCServiceProtocol { func getNextSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) func getPreviousSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) func getSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getNESSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) func getSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) + func getNESSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) func getRealtimeSuggestedCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) func getPromptToCodeAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void) func openChat(withReply reply: @escaping (Error?) -> Void) func promptToCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) func customCommand(id: String, editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void) + func toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void) func prefetchRealtimeSuggestions(editorContent: Data, withReply reply: @escaping () -> Void) func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) @@ -24,13 +28,29 @@ public protocol XPCServiceProtocol { func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) - func updateMCPServerToolsStatus(tools: Data) + func updateMCPServerToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data? + ) func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) + func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void) func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) - func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void) + func refreshClientTools(withReply reply: @escaping (Data?) -> Void) + func updateToolsStatus( + tools: Data, + chatAgentMode: Data?, + customChatModeId: Data?, + workspaceFolders: Data?, + withReply reply: @escaping (Data?) -> Void + ) func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void) + func getCopilotPolicy(withReply reply: @escaping (Data?) -> Void) + func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void) + func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void) func signOutAllGitHubCopilotService() func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void) diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index c1d1b415..1b4fbdf3 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -447,7 +447,7 @@ public extension AXUIElement { if element.identifier == "editor context" { return .skipDescendantsAndSiblings } - if element.isSourceEditor { + if element.isNonNavigatorSourceEditor { return .skipDescendantsAndSiblings } if description == "Code Coverage Ribbon" { diff --git a/Tool/Sources/XcodeInspector/Helpers.swift b/Tool/Sources/XcodeInspector/Helpers.swift index 3899412b..a5355be5 100644 --- a/Tool/Sources/XcodeInspector/Helpers.swift +++ b/Tool/Sources/XcodeInspector/Helpers.swift @@ -17,7 +17,7 @@ public extension FileManager { } extension AXUIElement { - var realtimeDocumentURL: URL? { + public var realtimeDocumentURL: URL? { guard let window = self.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" else { return nil } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index bb5c3cf9..6a8c5d5e 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -292,13 +292,13 @@ public final class XcodeInspector: ObservableObject { focusedElement = xcode.getFocusedElement(shouldRecordStatus: true) - if let editorElement = focusedElement, editorElement.isSourceEditor { + if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor { focusedEditor = .init( runningApplication: xcode.runningApplication, element: editorElement ) } else if let element = focusedElement, - let editorElement = element.firstParent(where: \.isSourceEditor) + let editorElement = element.firstParent(where: \.isNonNavigatorSourceEditor) { focusedEditor = .init( runningApplication: xcode.runningApplication, @@ -374,7 +374,7 @@ public final class XcodeInspector: ObservableObject { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } - if let editor = focusedEditor, !editor.element.isSourceEditor { + if let editor = focusedEditor, !editor.element.isNonNavigatorSourceEditor { NotificationCenter.default.post( name: .accessibilityAPIMalfunctioning, object: "Source Editor Element Corrupted: \(source)" diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d2506822..1f767be6 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -111,6 +111,72 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return url } } + + // Fallback: If no child has the workspace path in description, + // try to derive it from the window's document URL + if let documentURL = extractDocumentURL(windowElement: windowElement) { + if let workspaceURL = deriveWorkspaceFromDocumentURL(documentURL) { + return workspaceURL + } + } + + return nil + } + + static func deriveWorkspaceFromDocumentURL(_ documentURL: URL) -> URL? { + // Check if documentURL itself is already a workspace/project/playground + if documentURL.pathExtension == "xcworkspace" || + documentURL.pathExtension == "xcodeproj" || + documentURL.pathExtension == "playground" { + return documentURL + } + + // Try to find .xcodeproj or .xcworkspace in parent directories + var currentURL = documentURL + while currentURL.pathComponents.count > 1 { + currentURL.deleteLastPathComponent() + + // Check if current directory is a playground + if currentURL.pathExtension == "playground" { + return currentURL + } + + // Check if this directory contains .xcodeproj or .xcworkspace + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: currentURL.path) else { + continue + } + + // Check for .playground, .xcworkspace, and .xcodeproj in a single pass + var foundPlaygroundURL: URL? + var foundWorkspaceURL: URL? + var foundProjectURL: URL? + for item in contents { + if foundPlaygroundURL == nil, item.hasSuffix(".playground") { + foundPlaygroundURL = currentURL.appendingPathComponent(item) + } + if foundWorkspaceURL == nil, item.hasSuffix(".xcworkspace") { + foundWorkspaceURL = currentURL.appendingPathComponent(item) + } + if foundProjectURL == nil, item.hasSuffix(".xcodeproj") { + foundProjectURL = currentURL.appendingPathComponent(item) + } + } + if let playgroundURL = foundPlaygroundURL { + return playgroundURL + } + if let workspaceURL = foundWorkspaceURL { + return workspaceURL + } + if let projectURL = foundProjectURL { + return projectURL + } + + // Stop at the user's home directory or root + if currentURL.path == "/" || currentURL.path == NSHomeDirectory() { + break + } + } + return nil } @@ -152,4 +218,3 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return url } } - diff --git a/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift new file mode 100644 index 00000000..87276a06 --- /dev/null +++ b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift @@ -0,0 +1,460 @@ +import XCTest +import Foundation +@testable import Workspace + +class WorkspaceFileTests: XCTestCase { + func testMatchesPatterns() { + let url1 = URL(fileURLWithPath: "/path/to/file.swift") + let url2 = URL(fileURLWithPath: "/path/to/.git") + let patterns = [".git", ".svn"] + + XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns)) + XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns)) + } + + func testIsXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") + XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) + let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) + } catch { + throw error + } + } + + func testIsXCProject() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") + XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL)) + let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL)) + } catch { + throw error + } + } + + func testGetFilesInActiveProject() throws { + let tmpDir = try createTemporaryDirectory() + do { + let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj") + _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "") + _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "") + _ = try createSubdirectory(in: tmpDir, withName: ".git") + let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir) + let fileNames = files.map { $0.url.lastPathComponent } + XCTAssertEqual(files.count, 2) + XCTAssertTrue(fileNames.contains("file1.swift")) + XCTAssertTrue(fileNames.contains("file2.swift")) + } catch { + deleteDirectoryIfExists(at: tmpDir) + throw error + } + deleteDirectoryIfExists(at: tmpDir) + } + + func testGetFilesInActiveWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") + let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [ + "container:myProject.xcodeproj", + "group:../notExistedDir/notExistedProject.xcodeproj", + "group:../myDependency",]) + let xcprojectURL = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj") + let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") + + // Files under workspace should be included + _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "") + // unsupported patterns and file extension should be excluded + _ = try createFile(in: myWorkspaceRoot, withName: "unsupportedFileExtension.xyz", contents: "") + _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git") + + // Files under project metadata folder should be excluded + _ = try createFile(in: xcprojectURL, withName: "fileUnderProjectMetadata.swift", contents: "") + + // Files under dependency should be included + _ = try createFile(in: myDependencyURL, withName: "depFile1.swift", contents: "") + // Should be excluded + _ = try createSubdirectory(in: myDependencyURL, withName: ".git") + + // Files under unrelated directories should be excluded + _ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "") + + let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot) + let fileNames = files.map { $0.url.lastPathComponent } + XCTAssertEqual(files.count, 2) + XCTAssertTrue(fileNames.contains("file1.swift")) + XCTAssertTrue(fileNames.contains("depFile1.swift")) + } catch { + throw error + } + } + + func testGetSubprojectURLsFromXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + let workspaceDir = try createSubdirectory(in: tmpDir, withName: "workspace") + + // Create tryapp directory and project + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + // Create Copilot for Xcode project + _ = try createXCProjectFolder(in: workspaceDir, withName: "Copilot for Xcode.xcodeproj") + + // Create Test1 directory + let test1Dir = try createSubdirectory(in: tmpDir, withName: "Test1") + + // Create Test2 directory and project + let test2Dir = try createSubdirectory(in: tmpDir, withName: "Test2") + _ = try createXCProjectFolder(in: test2Dir, withName: "project2.xcodeproj") + + // Create the workspace data file with our references + let xcworkspaceData = """ + + + + + + + + + + + + + + """ + let workspaceURL = try createXCWorkspaceFolder(in: workspaceDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + + XCTAssertEqual(subprojectURLs.count, 4) + let resolvedPaths = subprojectURLs.map { $0.path } + let expectedPaths = [ + tryappDir.path, + workspaceDir.path, // For Copilot for Xcode.xcodeproj + test1Dir.path, + test2Dir.path + ] + XCTAssertEqual(resolvedPaths, expectedPaths) + } + + func testGetSubprojectURLsFromEmbeddedXCWorkspace() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create the workspace data file with a self reference + let xcworkspaceData = """ + + + + + + """ + + // Create the MyApp directory structure + let myAppDir = try createSubdirectory(in: tmpDir, withName: "MyApp") + let xcodeProjectDir = try createXCProjectFolder(in: myAppDir, withName: "MyApp.xcodeproj") + let embeddedWorkspaceDir = try createXCWorkspaceFolder(in: xcodeProjectDir, withName: "MyApp.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: embeddedWorkspaceDir) + XCTAssertEqual(subprojectURLs.count, 1) + XCTAssertEqual(subprojectURLs[0].lastPathComponent, "MyApp") + XCTAssertEqual(subprojectURLs[0].path, myAppDir.path) + } + + func testGetSubprojectURLsFromXCWorkspaceOrganizedByGroup() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + + // Create directories for the projects and groups + let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") + _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") + + let webLibraryDir = try createSubdirectory(in: tmpDir, withName: "WebLibrary") + + // Create the group directories + let group1Dir = try createSubdirectory(in: tmpDir, withName: "group1") + let group2Dir = try createSubdirectory(in: group1Dir, withName: "group2") + _ = try createSubdirectory(in: group2Dir, withName: "group3") + _ = try createSubdirectory(in: group1Dir, withName: "group4") + + // Create the MyProjects directory + let myProjectsDir = try createSubdirectory(in: tmpDir, withName: "MyProjects") + + // Create the copilot-xcode directory and project + let copilotXcodeDir = try createSubdirectory(in: myProjectsDir, withName: "copilot-xcode") + _ = try createXCProjectFolder(in: copilotXcodeDir, withName: "Copilot for Xcode.xcodeproj") + + // Create the SwiftLanguageWeather directory and project + let swiftWeatherDir = try createSubdirectory(in: myProjectsDir, withName: "SwiftLanguageWeather") + _ = try createXCProjectFolder(in: swiftWeatherDir, withName: "SwiftWeather.xcodeproj") + + // Create the workspace data file with a complex group structure + let xcworkspaceData = """ + + + + + + + + + + + + + + + + + + + + """ + + // Create a test workspace structure + let workspaceURL = try createXCWorkspaceFolder(in: tmpDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) + + let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) + XCTAssertEqual(subprojectURLs.count, 4) + let expectedPaths = [ + tryappDir.path, + webLibraryDir.path, + copilotXcodeDir.path, + swiftWeatherDir.path + ] + for expectedPath in expectedPaths { + XCTAssertTrue(subprojectURLs.contains { $0.path == expectedPath }, "Expected path not found: \(expectedPath)") + } + } + + func deleteDirectoryIfExists(at url: URL) { + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + print("Failed to delete directory at \(url.path)") + } + } + } + + func createTemporaryDirectory() throws -> URL { + let temporaryDirectoryURL = FileManager.default.temporaryDirectory + let directoryName = UUID().uuidString + let directoryURL = temporaryDirectoryURL.appendingPathComponent(directoryName) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + #if DEBUG + print("Create temp directory \(directoryURL.path)") + #endif + return directoryURL + } + + func createSubdirectory(in directory: URL, withName name: String) throws -> URL { + let subdirectoryURL = directory.appendingPathComponent(name) + try FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) + return subdirectoryURL + } + + func createFile(in directory: URL, withName name: String, contents: String) throws -> URL { + let fileURL = directory.appendingPathComponent(name) + let data = contents.data(using: .utf8) + FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil) + return fileURL + } + + func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL { + let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName) + if projectName.hasSuffix(".xcodeproj") { + _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents") + } + return projectURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + if let fileRefs { + _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs) + } + return xcworkspaceURL + } + + func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, xcworkspacedata: String) throws -> URL { + let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: xcworkspacedata) + return xcworkspaceURL + } + + func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL { + let contents = generateXCWorkspacedataContents(fileRefs: fileRefs) + return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents) + } + + func generateXCWorkspacedataContents(fileRefs: [String]) -> String { + var contents = """ + + + """ + for fileRef in fileRefs { + contents += """ + + + """ + } + contents += "" + return contents + } + + func testIsValidFile() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + // Test valid Swift file + let swiftFileURL = try createFile(in: tmpDir, withName: "ValidFile.swift", contents: "// Swift code") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + + // Test valid files with different supported extensions + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + let mdFileURL = try createFile(in: tmpDir, withName: "README.md", contents: "# Markdown") + XCTAssertTrue(try WorkspaceFile.isValidFile(mdFileURL)) + + let jsonFileURL = try createFile(in: tmpDir, withName: "config.json", contents: "{}") + XCTAssertTrue(try WorkspaceFile.isValidFile(jsonFileURL)) + + // Test case insensitive extension matching + let swiftUpperURL = try createFile(in: tmpDir, withName: "File.SWIFT", contents: "// Swift") + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftUpperURL)) + + // Test unsupported file extension + let unsupportedFileURL = try createFile(in: tmpDir, withName: "file.xyz", contents: "unsupported") + XCTAssertFalse(try WorkspaceFile.isValidFile(unsupportedFileURL)) + + // Test files matching skip patterns + let gitFileURL = try createFile(in: tmpDir, withName: ".git", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(gitFileURL)) + + let dsStoreURL = try createFile(in: tmpDir, withName: ".DS_Store", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(dsStoreURL)) + + let nodeModulesURL = try createFile(in: tmpDir, withName: "node_modules", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(nodeModulesURL)) + + // Test directory (should return false) + let subdirURL = try createSubdirectory(in: tmpDir, withName: "subdir") + XCTAssertFalse(try WorkspaceFile.isValidFile(subdirURL)) + + // Test Xcode workspace (should return false) + let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace") + _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcworkspaceURL)) + + // Test Xcode project (should return false) + let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj") + _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") + XCTAssertFalse(try WorkspaceFile.isValidFile(xcprojectURL)) + + } catch { + throw error + } + } + + func testIsValidFileWithCustomExclusionFilter() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let swiftFileURL = try createFile(in: tmpDir, withName: "TestFile.swift", contents: "// Swift code") + let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") + + // Test without custom exclusion filter + XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) + + // Test with custom exclusion filter that excludes Swift files + let excludeSwiftFilter: (URL) -> Bool = { url in + return url.pathExtension.lowercased() == "swift" + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeSwiftFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeSwiftFilter)) + + // Test with custom exclusion filter that excludes files with "Test" in name + let excludeTestFilter: (URL) -> Bool = { url in + return url.lastPathComponent.contains("Test") + } + + XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeTestFilter)) + XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeTestFilter)) + + } catch { + throw error + } + } + + func testIsValidFileWithAllSupportedExtensions() throws { + let tmpDir = try createTemporaryDirectory() + defer { + deleteDirectoryIfExists(at: tmpDir) + } + do { + let supportedExtensions = supportedFileExtensions + + for (index, ext) in supportedExtensions.enumerated() { + let fileName = "testfile\(index).\(ext)" + let fileURL = try createFile(in: tmpDir, withName: fileName, contents: "test content") + XCTAssertTrue(try WorkspaceFile.isValidFile(fileURL), "File with extension .\(ext) should be valid") + } + + } catch { + throw error + } + } +} diff --git a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift index 87276a06..d6d2e5ec 100644 --- a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift @@ -1,460 +1,230 @@ import XCTest import Foundation +import LanguageServerProtocol @testable import Workspace -class WorkspaceFileTests: XCTestCase { - func testMatchesPatterns() { - let url1 = URL(fileURLWithPath: "/path/to/file.swift") - let url2 = URL(fileURLWithPath: "/path/to/.git") - let patterns = [".git", ".svn"] - - XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns)) - XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns)) - } - - func testIsXCWorkspace() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - do { - let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace") - XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) - let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") - XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL)) - } catch { - throw error - } - } - - func testIsXCProject() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - do { - let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj") - XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL)) - let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") - XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL)) - } catch { - throw error - } - } - - func testGetFilesInActiveProject() throws { - let tmpDir = try createTemporaryDirectory() - do { - let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj") - _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "") - _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "") - _ = try createSubdirectory(in: tmpDir, withName: ".git") - let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir) - let fileNames = files.map { $0.url.lastPathComponent } - XCTAssertEqual(files.count, 2) - XCTAssertTrue(fileNames.contains("file1.swift")) - XCTAssertTrue(fileNames.contains("file2.swift")) - } catch { - deleteDirectoryIfExists(at: tmpDir) - throw error - } - deleteDirectoryIfExists(at: tmpDir) - } - - func testGetFilesInActiveWorkspace() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - do { - let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace") - let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [ - "container:myProject.xcodeproj", - "group:../notExistedDir/notExistedProject.xcodeproj", - "group:../myDependency",]) - let xcprojectURL = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj") - let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency") - - // Files under workspace should be included - _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "") - // unsupported patterns and file extension should be excluded - _ = try createFile(in: myWorkspaceRoot, withName: "unsupportedFileExtension.xyz", contents: "") - _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git") - - // Files under project metadata folder should be excluded - _ = try createFile(in: xcprojectURL, withName: "fileUnderProjectMetadata.swift", contents: "") - - // Files under dependency should be included - _ = try createFile(in: myDependencyURL, withName: "depFile1.swift", contents: "") - // Should be excluded - _ = try createSubdirectory(in: myDependencyURL, withName: ".git") - - // Files under unrelated directories should be excluded - _ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "") - - let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot) - let fileNames = files.map { $0.url.lastPathComponent } - XCTAssertEqual(files.count, 2) - XCTAssertTrue(fileNames.contains("file1.swift")) - XCTAssertTrue(fileNames.contains("depFile1.swift")) - } catch { - throw error - } +class WorkspaceTests: XCTestCase { + func testCalculateIncrementalChanges_IdenticalContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNil(changes, "Identical content should return nil") } - - func testGetSubprojectURLsFromXCWorkspace() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - - let workspaceDir = try createSubdirectory(in: tmpDir, withName: "workspace") - - // Create tryapp directory and project - let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") - _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") - - // Create Copilot for Xcode project - _ = try createXCProjectFolder(in: workspaceDir, withName: "Copilot for Xcode.xcodeproj") - - // Create Test1 directory - let test1Dir = try createSubdirectory(in: tmpDir, withName: "Test1") - - // Create Test2 directory and project - let test2Dir = try createSubdirectory(in: tmpDir, withName: "Test2") - _ = try createXCProjectFolder(in: test2Dir, withName: "project2.xcodeproj") - - // Create the workspace data file with our references - let xcworkspaceData = """ - - - - - - - - - - - - - - """ - let workspaceURL = try createXCWorkspaceFolder(in: workspaceDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) - - let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) - - XCTAssertEqual(subprojectURLs.count, 4) - let resolvedPaths = subprojectURLs.map { $0.path } - let expectedPaths = [ - tryappDir.path, - workspaceDir.path, // For Copilot for Xcode.xcodeproj - test1Dir.path, - test2Dir.path - ] - XCTAssertEqual(resolvedPaths, expectedPaths) + + func testCalculateIncrementalChanges_EmptyOldContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "" + let newContent = "New content" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range, LSPRange(start: Position(line: 0, character: 0), end: Position(line: 0, character: 0))) + XCTAssertEqual(changes?[0].text, "New content") } - - func testGetSubprojectURLsFromEmbeddedXCWorkspace() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - - // Create the workspace data file with a self reference - let xcworkspaceData = """ - - - - - - """ - - // Create the MyApp directory structure - let myAppDir = try createSubdirectory(in: tmpDir, withName: "MyApp") - let xcodeProjectDir = try createXCProjectFolder(in: myAppDir, withName: "MyApp.xcodeproj") - let embeddedWorkspaceDir = try createXCWorkspaceFolder(in: xcodeProjectDir, withName: "MyApp.xcworkspace", xcworkspacedata: xcworkspaceData) - - let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: embeddedWorkspaceDir) - XCTAssertEqual(subprojectURLs.count, 1) - XCTAssertEqual(subprojectURLs[0].lastPathComponent, "MyApp") - XCTAssertEqual(subprojectURLs[0].path, myAppDir.path) + + func testCalculateIncrementalChanges_EmptyNewContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Old content" + let newContent = "" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].text, "") + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].rangeLength, oldContent.utf16.count) } - - func testGetSubprojectURLsFromXCWorkspaceOrganizedByGroup() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - - // Create directories for the projects and groups - let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp") - _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj") - - let webLibraryDir = try createSubdirectory(in: tmpDir, withName: "WebLibrary") - - // Create the group directories - let group1Dir = try createSubdirectory(in: tmpDir, withName: "group1") - let group2Dir = try createSubdirectory(in: group1Dir, withName: "group2") - _ = try createSubdirectory(in: group2Dir, withName: "group3") - _ = try createSubdirectory(in: group1Dir, withName: "group4") - - // Create the MyProjects directory - let myProjectsDir = try createSubdirectory(in: tmpDir, withName: "MyProjects") - - // Create the copilot-xcode directory and project - let copilotXcodeDir = try createSubdirectory(in: myProjectsDir, withName: "copilot-xcode") - _ = try createXCProjectFolder(in: copilotXcodeDir, withName: "Copilot for Xcode.xcodeproj") - - // Create the SwiftLanguageWeather directory and project - let swiftWeatherDir = try createSubdirectory(in: myProjectsDir, withName: "SwiftLanguageWeather") - _ = try createXCProjectFolder(in: swiftWeatherDir, withName: "SwiftWeather.xcodeproj") - - // Create the workspace data file with a complex group structure - let xcworkspaceData = """ - - - - - - - - - - - - - - - - - - - - """ - - // Create a test workspace structure - let workspaceURL = try createXCWorkspaceFolder(in: tmpDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData) - - let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL) - XCTAssertEqual(subprojectURLs.count, 4) - let expectedPaths = [ - tryappDir.path, - webLibraryDir.path, - copilotXcodeDir.path, - swiftWeatherDir.path - ] - for expectedPath in expectedPaths { - XCTAssertTrue(subprojectURLs.contains { $0.path == expectedPath }, "Expected path not found: \(expectedPath)") - } + + func testCalculateIncrementalChanges_InsertAtBeginning() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "World" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].text, "Hello ") } - - func deleteDirectoryIfExists(at url: URL) { - if FileManager.default.fileExists(atPath: url.path) { - do { - try FileManager.default.removeItem(at: url) - } catch { - print("Failed to delete directory at \(url.path)") - } - } + + func testCalculateIncrementalChanges_InsertAtEnd() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello" + let newContent = "Hello World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, " World") } - - func createTemporaryDirectory() throws -> URL { - let temporaryDirectoryURL = FileManager.default.temporaryDirectory - let directoryName = UUID().uuidString - let directoryURL = temporaryDirectoryURL.appendingPathComponent(directoryName) - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) - #if DEBUG - print("Create temp directory \(directoryURL.path)") - #endif - return directoryURL + + func testCalculateIncrementalChanges_InsertInMiddle() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello Beautiful World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "Beautiful ") } - - func createSubdirectory(in directory: URL, withName name: String) throws -> URL { - let subdirectoryURL = directory.appendingPathComponent(name) - try FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) - return subdirectoryURL + + func testCalculateIncrementalChanges_DeleteFromBeginning() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "World" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 0) + XCTAssertEqual(changes?[0].range?.end.character, 6) + XCTAssertEqual(changes?[0].text, "") } - - func createFile(in directory: URL, withName name: String, contents: String) throws -> URL { - let fileURL = directory.appendingPathComponent(name) - let data = contents.data(using: .utf8) - FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil) - return fileURL + + func testCalculateIncrementalChanges_DeleteFromEnd() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, "") } + + func testCalculateIncrementalChanges_ReplaceMiddle() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello World" + let newContent = "Hello Swift" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) - func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL { - let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName) - if projectName.hasSuffix(".xcodeproj") { - _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents") - } - return projectURL + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "Swift") } - - func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL { - let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) - if let fileRefs { - _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs) - } - return xcworkspaceURL + + func testCalculateIncrementalChanges_MultilineInsert() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 3" + let newContent = "Line 1\nLine 2\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].text, "2\nLine ") } - - func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, xcworkspacedata: String) throws -> URL { - let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName) - _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: xcworkspacedata) - return xcworkspaceURL + + func testCalculateIncrementalChanges_MultilineDelete() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 2\nLine 3" + let newContent = "Line 1\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].range?.start.character, 5) + XCTAssertEqual(changes?[0].range?.end.line, 2) + XCTAssertEqual(changes?[0].text, "") } - - func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL { - let contents = generateXCWorkspacedataContents(fileRefs: fileRefs) - return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents) + + func testCalculateIncrementalChanges_MultilineReplace() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nOld Line\nLine 3" + let newContent = "Line 1\nNew Line\nLine 3" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].text, "New") } - - func generateXCWorkspacedataContents(fileRefs: [String]) -> String { - var contents = """ - - - """ - for fileRef in fileRefs { - contents += """ - - - """ - } - contents += "" - return contents + + func testCalculateIncrementalChanges_UTF16Characters() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Hello ไธ–็•Œ" + let newContent = "Hello ๐ŸŒ ไธ–็•Œ" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 0) + XCTAssertEqual(changes?[0].range?.start.character, 6) + XCTAssertEqual(changes?[0].text, "๐ŸŒ ") } - func testIsValidFile() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - do { - // Test valid Swift file - let swiftFileURL = try createFile(in: tmpDir, withName: "ValidFile.swift", contents: "// Swift code") - XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) - - // Test valid files with different supported extensions - let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") - XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) - - let mdFileURL = try createFile(in: tmpDir, withName: "README.md", contents: "# Markdown") - XCTAssertTrue(try WorkspaceFile.isValidFile(mdFileURL)) - - let jsonFileURL = try createFile(in: tmpDir, withName: "config.json", contents: "{}") - XCTAssertTrue(try WorkspaceFile.isValidFile(jsonFileURL)) - - // Test case insensitive extension matching - let swiftUpperURL = try createFile(in: tmpDir, withName: "File.SWIFT", contents: "// Swift") - XCTAssertTrue(try WorkspaceFile.isValidFile(swiftUpperURL)) - - // Test unsupported file extension - let unsupportedFileURL = try createFile(in: tmpDir, withName: "file.xyz", contents: "unsupported") - XCTAssertFalse(try WorkspaceFile.isValidFile(unsupportedFileURL)) - - // Test files matching skip patterns - let gitFileURL = try createFile(in: tmpDir, withName: ".git", contents: "") - XCTAssertFalse(try WorkspaceFile.isValidFile(gitFileURL)) - - let dsStoreURL = try createFile(in: tmpDir, withName: ".DS_Store", contents: "") - XCTAssertFalse(try WorkspaceFile.isValidFile(dsStoreURL)) - - let nodeModulesURL = try createFile(in: tmpDir, withName: "node_modules", contents: "") - XCTAssertFalse(try WorkspaceFile.isValidFile(nodeModulesURL)) - - // Test directory (should return false) - let subdirURL = try createSubdirectory(in: tmpDir, withName: "subdir") - XCTAssertFalse(try WorkspaceFile.isValidFile(subdirURL)) - - // Test Xcode workspace (should return false) - let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace") - _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "") - XCTAssertFalse(try WorkspaceFile.isValidFile(xcworkspaceURL)) - - // Test Xcode project (should return false) - let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj") - _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "") - XCTAssertFalse(try WorkspaceFile.isValidFile(xcprojectURL)) - - } catch { - throw error - } + func testCalculateIncrementalChanges_VeryLargeContent() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = String(repeating: "a", count: 220_000) + let newContent = String(repeating: "b", count: 220_000) + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + // Should fallback to nil for very large contents (> 200_000 characters) + XCTAssertNil(changes, "Very large content should return nil for fallback") } - func testIsValidFileWithCustomExclusionFilter() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - do { - let swiftFileURL = try createFile(in: tmpDir, withName: "TestFile.swift", contents: "// Swift code") - let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript") - - // Test without custom exclusion filter - XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL)) - XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL)) - - // Test with custom exclusion filter that excludes Swift files - let excludeSwiftFilter: (URL) -> Bool = { url in - return url.pathExtension.lowercased() == "swift" - } - - XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeSwiftFilter)) - XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeSwiftFilter)) - - // Test with custom exclusion filter that excludes files with "Test" in name - let excludeTestFilter: (URL) -> Bool = { url in - return url.lastPathComponent.contains("Test") - } - - XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeTestFilter)) - XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeTestFilter)) - - } catch { - throw error - } + func testCalculateIncrementalChanges_ComplexEdit() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = """ + func hello() { + print("Hello") + } + """ + let newContent = """ + func hello(name: String) { + print("Hello, \\(name)!") + } + """ + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + // Verify that a change was detected + XCTAssertFalse(changes?[0].text.isEmpty ?? true) } - func testIsValidFileWithAllSupportedExtensions() throws { - let tmpDir = try createTemporaryDirectory() - defer { - deleteDirectoryIfExists(at: tmpDir) - } - do { - let supportedExtensions = supportedFileExtensions - - for (index, ext) in supportedExtensions.enumerated() { - let fileName = "testfile\(index).\(ext)" - let fileURL = try createFile(in: tmpDir, withName: fileName, contents: "test content") - XCTAssertTrue(try WorkspaceFile.isValidFile(fileURL), "File with extension .\(ext) should be valid") - } - - } catch { - throw error - } + func testCalculateIncrementalChanges_NewlineVariations() { + let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) + let oldContent = "Line 1\nLine 2" + let newContent = "Line 1\nLine 2\n" + + let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) + + XCTAssertNotNil(changes) + XCTAssertEqual(changes?.count, 1) + XCTAssertEqual(changes?[0].range?.start.line, 1) + XCTAssertEqual(changes?[0].text, "\n") } }